diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97513ab..1db021a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,10 @@ jobs: node-version: "20" cache: "npm" - run: npm install + - run: npx playwright install --with-deps chromium - run: npm run build - run: npm test + - run: npm run test:react:release - run: | echo "validate examples" for f in examples/*.json; do diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b78bdda..5198b21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -72,9 +72,9 @@ jobs: - if: steps.release.outputs.should_release == 'true' run: npm ci - if: steps.release.outputs.should_release == 'true' - run: npm run build + run: npx playwright install --with-deps chromium - if: steps.release.outputs.should_release == 'true' - run: npm test + run: npm run release:wire-react:dry-run - name: Pack workspace packages if: steps.release.outputs.should_release == 'true' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4f1671..f154ce7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ ``` packages/ wire-core — schema, validation, normalization, layout - wire-renderers — React Flow / xyflow adapter + wire-renderers — static renderers and graph-canvas compatibility adapter wire-react — JSX facade wire-mcp — MCP server (stdio + HTTP) wire-cli — CLI binary diff --git a/README.md b/README.md index 0b218ed..e580795 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ LLMs and agents struggle with diagrams: they emit Mermaid blobs that almost rend - **Shared actions** — every human, hosted editor, CLI, and MCP edit flows through the `WireAction` reducer in `wire-core`. - **React library** — `WireEditor`, `WireViewer`, `WireCanvas`, palette, toolbar, inspector, validation panel, and JSX authoring. - **LLM-friendly React extensibility** — option catalogs, custom node cards, custom edge renderers, and custom group renderers without requiring app code to import a graph-canvas package. -- **Static renderers** — SVG, PNG, Mermaid, and optional React Flow conversion without pulling React into server-only consumers. +- **Static renderers** — SVG, PNG, Mermaid, and optional canvas-adapter conversion without pulling React into server-only consumers. - **MCP server** — diagram CRUD, direct action tools, atomic `apply_actions`, resources, prompts, render tools, and `v1_get_agent_guide` over stdio or HTTP. - **Hosted parity** — the playground/editor uses `@aigentive/wire-react`, stores canonical JSON, and renders from the same model as MCP. @@ -21,7 +21,7 @@ LLMs and agents struggle with diagrams: they emit Mermaid blobs that almost rend | Package | Description | |---|---| | [`@aigentive/wire-core`](packages/wire-core) | Schema, validation, IDs, graph normalization, layout, pure `WireAction` reducer | -| [`@aigentive/wire-renderers`](packages/wire-renderers) | Static renderers and adapters: SVG, PNG helpers, Mermaid, optional React Flow conversion | +| [`@aigentive/wire-renderers`](packages/wire-renderers) | Static renderers and adapters: SVG, PNG helpers, Mermaid, optional canvas-adapter conversion | | [`@aigentive/wire-react`](packages/wire-react) | Reusable React editor/viewer components and JSX authoring facade | | [`@aigentive/wire-mcp`](packages/wire-mcp) | MCP server (stdio + streamable-HTTP) | | [`@aigentive/wire-cli`](packages/wire-cli) | `wire` CLI (init, add, validate, export, ls) | @@ -82,6 +82,7 @@ Controlled editor: ```tsx import type { WireDiagram } from "@aigentive/wire-core"; import { WireEditor, WireViewer } from "@aigentive/wire-react"; +import "@aigentive/wire-react/styles.css"; export function WorkflowEditor({ diagram, @@ -101,6 +102,7 @@ export function WorkflowPreview({ diagram }: { diagram: WireDiagram }) { Custom editor surface: ```tsx +import "@aigentive/wire-react/styles.css"; import { WireCanvas, WireInspector, @@ -126,6 +128,7 @@ export function ProductEditor({ diagram, onChange }) { LLM-friendly custom cards and options: ```tsx +import "@aigentive/wire-react/styles.css"; import { WireWorkspace, type WireOptionCatalog @@ -154,6 +157,8 @@ export function AgentEditor({ diagram, onChange }) { `WireWorkspace` uses a decoupled event model: card/list clicks emit `node.inspect` and update selection; option panels can follow selection by default or receive an explicit `inspectNodeId` for fully controlled sidebars. +The package stylesheet is the supported npm-consumer styling path; no utility +source scan is required for the React package. See [`docs/REACT_COMPONENTS.md`](docs/REACT_COMPONENTS.md), the playground route `/docs`, and the playground route `/samples/agent-chain`. diff --git a/apps/playground/app/(marketing)/page.tsx b/apps/playground/app/(marketing)/page.tsx index 38f175c..b32a70c 100644 --- a/apps/playground/app/(marketing)/page.tsx +++ b/apps/playground/app/(marketing)/page.tsx @@ -46,7 +46,7 @@ npx -y @aigentive/wire-mcp@latest --http`; const PACKAGES = [ { name: "@aigentive/wire-core", desc: "Schema, validation, IDs, normalize, layout, the WireAction reducer." }, { name: "@aigentive/wire-react", desc: "Editor, viewer, canvas, palette, inspector, hooks, JSX facade." }, - { name: "@aigentive/wire-renderers", desc: "Static SVG, PNG helpers, Mermaid, optional React Flow conversion." }, + { name: "@aigentive/wire-renderers", desc: "Static SVG, PNG helpers, Mermaid, optional canvas-adapter conversion." }, { name: "@aigentive/wire-mcp", desc: "MCP server over stdio + streamable-HTTP with the full tool surface." }, { name: "@aigentive/wire-cli", desc: "wire init · add · validate · export · ls." }, { name: "apps/playground", desc: "Self-hostable Next.js editor, share API, and renderer service." } diff --git a/apps/playground/app/api/playground/chat/route.ts b/apps/playground/app/api/playground/chat/route.ts index d4bfa92..498d3ff 100644 --- a/apps/playground/app/api/playground/chat/route.ts +++ b/apps/playground/app/api/playground/chat/route.ts @@ -113,7 +113,7 @@ SCOPE AND TRUST BOUNDARIES: Expected outcome: - Produce one complete canonical WireDiagram JSON object. - Preserve useful existing node ids, positions, sizes, labels, and manual edits unless the user asks to replace them. -- Use only Wire schema fields. Never emit React Flow JSON, SVG, Markdown diagrams, or prose as the primary artifact. +- Use only Wire schema fields. Never emit adapter JSON, SVG, Markdown diagrams, or prose as the primary artifact. - Valid tones are only default, success, warning, error, info, ai. Never use danger. - Prefer clear workflow-wireframe diagrams with 4-8 nodes unless the user asks for more detail. - Every non-group workflow node renders as a card. Use node.kind for the card category, node.title for the card header, and node.description for body copy. diff --git a/apps/playground/app/docs/_components/nav.ts b/apps/playground/app/docs/_components/nav.ts index e918d65..a6f2af3 100644 --- a/apps/playground/app/docs/_components/nav.ts +++ b/apps/playground/app/docs/_components/nav.ts @@ -53,6 +53,15 @@ export const NAV: NavSection[] = [ { title: "Examples", items: [ + { href: "/docs/examples/package-css", label: "Package CSS", badge: "new" }, + { href: "/docs/examples/custom-shell", label: "Custom shell", badge: "new" }, + { href: "/docs/examples/options", label: "Options", badge: "new" }, + { href: "/docs/examples/controlled-state", label: "Controlled state", badge: "new" }, + { href: "/docs/examples/edge-inspection", label: "Edge inspection", badge: "new" }, + { href: "/docs/examples/accessibility", label: "Accessibility", badge: "new" }, + { href: "/docs/examples/theming", label: "Theming", badge: "new" }, + { href: "/docs/examples/wrappers", label: "Wrappers", badge: "new" }, + { href: "/docs/examples/read-only-inspector", label: "Read-only inspector", badge: "new" }, { href: "/docs/examples/layouts", label: "Layouts" }, { href: "/docs/examples/click-modal", label: "Click → modal" }, { href: "/docs/examples/click-sidebar", label: "Click → sidebar" } diff --git a/apps/playground/app/docs/examples/accessibility/page.tsx b/apps/playground/app/docs/examples/accessibility/page.tsx new file mode 100644 index 0000000..319abb7 --- /dev/null +++ b/apps/playground/app/docs/examples/accessibility/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { WireCanvas, WireProvider } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const ACCESSIBILITY_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { WireCanvas, WireProvider } from "@aigentive/wire-react"; + +export function AccessibleCanvas({ diagram, optionCatalog }) { + return ( + + \`Workflow node: \${node.title}\`, + edge: (edge) => \`Workflow edge: \${edge.label ?? edge.id}\`, + search: "Search workflow items", + connectionTarget: "Choose workflow connection target" + }} + isValidConnection={({ sourceNode, targetNode }) => + sourceNode.id === targetNode.id ? "Choose a different target." : true + } + /> + + ); +}`; + +export default function AccessibilityExamplePage() { + return ( + + +

Canvas accessibility

+

+ keyboardA11y is on by default. Override labels with{" "} + ariaLabelConfig, and return a string from isValidConnection + to announce connection rejection. +

+
+ + + `Workflow node: ${node.title}`, + edge: (edge) => `Workflow edge: ${edge.label ?? edge.id}`, + search: "Search workflow items", + connectionTarget: "Choose workflow connection target" + }} + isValidConnection={({ sourceNode, targetNode }) => + sourceNode.id === targetNode.id ? "Choose a different target." : true + } + /> + + + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/controlled-state/page.tsx b/apps/playground/app/docs/examples/controlled-state/page.tsx new file mode 100644 index 0000000..74340c6 --- /dev/null +++ b/apps/playground/app/docs/examples/controlled-state/page.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import { + WireWorkspace, + type WireDiagram, + type WireMode, + type WireSelection, + type WireViewport +} from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleMetric, ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const CONTROLLED_STATE_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { useState } from "react"; +import { + WireWorkspace, + type WireMode, + type WireSelection, + type WireViewport +} from "@aigentive/wire-react"; + +export function ControlledWorkspace({ initial, optionCatalog }) { + const [diagram, setDiagram] = useState(initial); + const [selection, setSelection] = useState({ nodeIds: ["qualify"], edgeIds: [] }); + const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); + const [mode, setMode] = useState("edit"); + const [dirty, setDirty] = useState(false); + + return ( + setSelection(next)} + viewport={viewport} + onViewportChange={(next) => setViewport(next)} + mode={mode} + onModeChange={(next) => setMode(next)} + dirty={dirty} + onDirtyChange={(next) => setDirty(next)} + optionCatalog={optionCatalog} + /> + ); +}`; + +export default function ControlledStateExamplePage() { + const [diagram, setDiagram] = useState(PRODUCTION_DIAGRAM); + const [selection, setSelection] = useState({ nodeIds: ["qualify"], edgeIds: [] }); + const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 }); + const [mode, setMode] = useState("edit"); + const [dirty, setDirty] = useState(false); + + return ( + + +

Runtime state

+

+ WireDiagram remains durable. Selection, viewport, mode, and dirty state can be + controlled by the host without becoming persisted diagram fields. +

+
+ +
+
+ + + + +
+ setSelection(next)} + viewport={viewport} + onViewportChange={(next) => setViewport(next)} + mode={mode} + onModeChange={(next) => setMode(next)} + dirty={dirty} + onDirtyChange={(next) => setDirty(next)} + optionCatalog={PRODUCTION_OPTIONS} + layout="embedded" + /> +
+ + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/custom-shell/page.tsx b/apps/playground/app/docs/examples/custom-shell/page.tsx new file mode 100644 index 0000000..34edacd --- /dev/null +++ b/apps/playground/app/docs/examples/custom-shell/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { + WireCanvas, + WireInspector, + WireNodeList, + WirePalette, + WireProvider, + WireToolbar, + WireValidationPanel +} from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const CUSTOM_SHELL_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { + WireCanvas, + WireInspector, + WireNodeList, + WirePalette, + WireProvider, + WireToolbar, + WireValidationPanel +} from "@aigentive/wire-react"; + +export function ProductShell({ diagram, onChange, optionCatalog }) { + return ( + + +
+ + + +
+
+ ); +}`; + +export default function CustomShellExamplePage() { + return ( + + +

Contract

+

+ The shell owns layout. WireProvider owns the diagram runtime state, and all edits + still flow through reducer actions against WireDiagram. +

+
+ + +
+ +
+ + + +
+
+
+ + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/edge-inspection/page.tsx b/apps/playground/app/docs/examples/edge-inspection/page.tsx new file mode 100644 index 0000000..52438fe --- /dev/null +++ b/apps/playground/app/docs/examples/edge-inspection/page.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { WireCanvas, WireInspector, WireProvider, type WireEvent } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const EDGE_INSPECTION_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { useState } from "react"; +import { WireCanvas, WireInspector, WireProvider } from "@aigentive/wire-react"; + +export function EdgeInspector({ diagram, optionCatalog }) { + const [edgeId, setEdgeId] = useState("edge-qualify-route"); + + return ( + { + if (event.type === "edge.click") setEdgeId(event.edgeId); + }} + > + + + + ); +}`; + +export default function EdgeInspectionExamplePage() { + const [edgeId, setEdgeId] = useState("edge-qualify-route"); + const onEvent = useCallback((event: WireEvent) => { + if (event.type === "edge.click") setEdgeId(event.edgeId); + }, []); + + return ( + + +

Edge tab

+

+ WireInspector edgeId opens edge details. Editable primitive fields dispatch the + current edge.patch action; endpoint facts stay read-only. +

+
+ + +
+ + +
+
+ + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/options/page.tsx b/apps/playground/app/docs/examples/options/page.tsx new file mode 100644 index 0000000..1e60b3d --- /dev/null +++ b/apps/playground/app/docs/examples/options/page.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { WireInspector, WireOptionPanel, WireProvider, WireWorkspace } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const OPTIONS_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { + WireInspector, + WireOptionPanel, + WireProvider, + WireWorkspace, + type WireOptionCatalog +} from "@aigentive/wire-react"; + +const optionCatalog: WireOptionCatalog = { + ai: [ + { key: "model", storage: "node", type: "select", options: ["gpt-4.1-mini", "gpt-4.1"] }, + { key: "temperature", type: "number", min: 0, max: 2, step: 0.1, width: "half" } + ], + action: [ + { key: "channel", type: "select", options: ["crm", "email", "chat"] } + ] +}; + +export function OptionsInWorkspace({ diagram, onChange }) { + return ( + + ); +} + +export function OptionsInCustomPanel({ diagram }) { + return ( + + + + + ); +}`; + +export default function OptionsExamplePage() { + return ( + + +

Entry points

+

+ Keep catalogs at the view layer: WireWorkspace optionCatalog,{" "} + WireInspector optionCatalog, WireCanvas optionCatalog, and{" "} + WireOptionPanel catalog. +

+
+ +
+ + + + +
+ + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/package-css/page.tsx b/apps/playground/app/docs/examples/package-css/page.tsx new file mode 100644 index 0000000..01c6f76 --- /dev/null +++ b/apps/playground/app/docs/examples/package-css/page.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { WireViewer } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM } from "../production-shared"; + +const PACKAGE_CSS_SNIPPET = `"use client"; + +import "@aigentive/wire-react/styles.css"; +import { WireViewer } from "@aigentive/wire-react"; + +export function WorkflowPreview({ diagram }) { + return ( +
+ +
+ ); +}`; + +export default function PackageCssExamplePage() { + return ( + + +

Pattern

+

+ Import @aigentive/wire-react/styles.css once. The package CSS covers the current + Wire surfaces without requiring a utility-class source scan from consumers. +

+
+ + + + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/production-shared.tsx b/apps/playground/app/docs/examples/production-shared.tsx new file mode 100644 index 0000000..6ddacbd --- /dev/null +++ b/apps/playground/app/docs/examples/production-shared.tsx @@ -0,0 +1,163 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { WireDiagram } from "@aigentive/wire-core"; +import type { WireOptionCatalog } from "@aigentive/wire-react"; + +export const PRODUCTION_OPTIONS: WireOptionCatalog = { + "*": [ + { + key: "owner", + label: "Owner", + storage: "data", + section: "Ownership", + placeholder: "revops" + } + ], + trigger: [ + { + key: "event", + label: "Event", + storage: "data", + section: "Runtime", + placeholder: "lead.created" + } + ], + ai: [ + { + key: "model", + label: "Model", + storage: "node", + type: "select", + section: "Runtime", + options: ["gpt-4.1-mini", "gpt-4.1", "o4-mini"] + }, + { + key: "temperature", + label: "Temperature", + type: "number", + min: 0, + max: 2, + step: 0.1, + section: "Runtime", + width: "half" + }, + { + key: "mode", + label: "Mode", + type: "select", + section: "Runtime", + width: "half", + options: ["classify", "rank", "draft"] + } + ], + condition: [ + { + key: "threshold", + label: "Threshold", + type: "number", + min: 0, + max: 100, + step: 5, + section: "Routing", + width: "half" + }, + { + key: "strategy", + label: "Strategy", + type: "select", + section: "Routing", + width: "half", + options: ["score", "segment", "owner"] + } + ], + action: [ + { + key: "channel", + label: "Channel", + type: "select", + section: "Delivery", + options: ["crm", "email", "chat", "ticket"] + }, + { + key: "retry", + label: "Retry on failure", + type: "boolean", + section: "Delivery" + } + ] +}; + +export const PRODUCTION_DIAGRAM: WireDiagram = { + version: 1, + id: "production-agent-router", + title: "Production agent router", + layout: "LR", + nodes: [ + { + id: "lead", + kind: "trigger", + title: "Lead received", + description: "CRM webhook", + data: { options: { event: "lead.created", owner: "revops" } } + }, + { + id: "qualify", + kind: "ai", + title: "Qualify lead", + description: "Rank fit and urgency", + model: "gpt-4.1-mini", + data: { options: { mode: "rank", temperature: 0.2, owner: "ai-platform" } } + }, + { + id: "route", + kind: "condition", + title: "Route account", + branches: ["enterprise", "smb"], + data: { options: { threshold: 70, strategy: "score", owner: "revops" } } + }, + { + id: "enterprise", + kind: "action", + title: "Create account plan", + tone: "success", + data: { options: { channel: "crm", retry: true, owner: "sales" } } + }, + { + id: "smb", + kind: "action", + title: "Send nurture", + tone: "info", + data: { options: { channel: "email", retry: false, owner: "marketing" } } + } + ], + edges: [ + { id: "edge-lead-qualify", from: "lead", to: "qualify", label: "event" }, + { id: "edge-qualify-route", from: "qualify", to: "route", label: "score", routing: "smoothstep" }, + { id: "edge-route-enterprise", from: "route", to: "enterprise", branch: "enterprise", label: "enterprise", tone: "success" }, + { id: "edge-route-smb", from: "route", to: "smb", branch: "smb", label: "smb", tone: "info" } + ] +}; + +export function ExampleSurface({ + height = 420, + children +}: { + height?: number; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +export function ExampleMetric({ label, value }: { label: string; value: ReactNode }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/playground/app/docs/examples/read-only-inspector/page.tsx b/apps/playground/app/docs/examples/read-only-inspector/page.tsx new file mode 100644 index 0000000..c5fa6b6 --- /dev/null +++ b/apps/playground/app/docs/examples/read-only-inspector/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { WireInspector, WireProvider, WireViewer } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const READ_ONLY_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { WireInspector, WireProvider, WireViewer } from "@aigentive/wire-react"; + +export function ReadOnlyInspector({ diagram, optionCatalog }) { + return ( + + + + + ); +}`; + +export default function ReadOnlyInspectorExamplePage() { + return ( + + +

Read-only behavior

+

+ readOnly disables built-in mutation paths in the inspector. Host-owned custom + inspector content should apply the same non-mutating rule. +

+
+ + +
+ + +
+
+ + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/theming/page.tsx b/apps/playground/app/docs/examples/theming/page.tsx new file mode 100644 index 0000000..40ac5d6 --- /dev/null +++ b/apps/playground/app/docs/examples/theming/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, type CSSProperties } from "react"; +import { WireWorkspace } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const THEME_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { WireWorkspace } from "@aigentive/wire-react"; + +export function ThemedWorkspace({ diagram, onChange, optionCatalog }) { + return ( + + ); +}`; + +type ExampleColorMode = "light" | "dark" | "system"; + +export default function ThemingExamplePage() { + const [colorMode, setColorMode] = useState("system"); + + return ( + + +

Theme contract

+

+ Package CSS supplies defaults. Hosts can set colorMode, override CSS variables, + or use unstyled and classNames for full control. +

+
+
+ {(["light", "dark", "system"] as ExampleColorMode[]).map((mode) => ( + + ))} +
+ + + + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/examples/wrappers/page.tsx b/apps/playground/app/docs/examples/wrappers/page.tsx new file mode 100644 index 0000000..4af8d25 --- /dev/null +++ b/apps/playground/app/docs/examples/wrappers/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { WireEditor, WireViewer, type WireDiagram } from "@aigentive/wire-react"; +import { DocsPage } from "../../_components/DocsPage"; +import { CodePreview } from "../../_components/CodePreview"; +import { Prose, InlineCode } from "../../_components/Prose"; +import { ExampleSurface, PRODUCTION_DIAGRAM, PRODUCTION_OPTIONS } from "../production-shared"; + +const WRAPPERS_SNIPPET = `import "@aigentive/wire-react/styles.css"; +import { WireEditor, WireViewer } from "@aigentive/wire-react"; + +export function WrapperPair({ diagram, onChange, optionCatalog }) { + return ( +
+ + +
+ ); +}`; + +export default function WrappersExamplePage() { + const [diagram, setDiagram] = useState(PRODUCTION_DIAGRAM); + + return ( + + +

Wrapper pass-through

+

+ WireEditor and WireViewer keep current canvas props while + handling provider setup for common screens. +

+
+ +
+ setDiagram(next)} + fitView + optionCatalog={PRODUCTION_OPTIONS} + /> + +
+ + } + /> +
+ ); +} diff --git a/apps/playground/app/docs/install/page.tsx b/apps/playground/app/docs/install/page.tsx index 18e46ca..b4f1630 100644 --- a/apps/playground/app/docs/install/page.tsx +++ b/apps/playground/app/docs/install/page.tsx @@ -10,7 +10,7 @@ export default function InstallPage() { @@ -30,52 +30,50 @@ export default function InstallPage() { -

Tailwind v4

+

Package CSS

- The components ship pre-styled with Tailwind utility classes. Tell Tailwind where to find them so the - classes survive purging, and opt into class-based dark mode. + Import the package stylesheet once in your app entry. npm consumers do not need to point a CSS compiler at + the package source.

- - {`@import "tailwindcss"; - -@source "../node_modules/@aigentive/wire-react"; - -/* opt into class-based dark mode */ -@custom-variant dark (&:where(.dark, .dark *));`} + + {`import "@aigentive/wire-react/styles.css";`} - - Pointing @source at the package source path (rather than the published bundle) is - fine when you're iterating in a workspace. + + Your app can still use its own CSS stack around Wire. Use colorMode,{" "} + unstyled, slot classNames, and CSS variables when you need + tighter design-system integration.

Light & dark mode

- Wire components are theme-aware via Tailwind’s dark: variant. There’s no - provider — add or remove the dark class on{" "} - {``} and every component follows. + Wire surfaces accept colorMode="light",{" "} + colorMode="dark", or{" "} + colorMode="system". You can also override the package CSS variables in your + app stylesheet.

- - {`// somewhere in your app -function setTheme(theme: "light" | "dark") { - document.documentElement.classList.toggle("dark", theme === "dark"); - window.localStorage.setItem("wire-theme", theme); -}`} + + {``}

Verify the install

Drop this component anywhere in your app. If you see the trigger card with its kind chip and title, install - + Tailwind setup are wired correctly. + and CSS import are wired correctly.

{`"use client"; +import "@aigentive/wire-react/styles.css"; import { WireProvider, WireCanvas, diff --git a/apps/playground/app/docs/page.tsx b/apps/playground/app/docs/page.tsx index 06f1f20..c9fccd7 100644 --- a/apps/playground/app/docs/page.tsx +++ b/apps/playground/app/docs/page.tsx @@ -15,7 +15,7 @@ const PATHS: Array<{ href: string; eyebrow: string; title: string; detail: strin href: "/docs/install", eyebrow: "Get started", title: "Install & setup", - detail: "Add the package, point Tailwind at the source, render your first canvas.", + detail: "Add the package, import the stylesheet, render your first canvas.", icon: Rocket }, { @@ -120,8 +120,8 @@ export default function DocsLanding() { as a self-contained SVG string or a Mermaid flowchart.
  • - Theme. Class-based dark mode ({``}); no - provider, no context, just a Tailwind variant. + Theme. Package CSS variables, colorMode,{" "} + unstyled, and slot classes let product shells match their own design system.
  • diff --git a/apps/playground/app/docs/quickstart/page.tsx b/apps/playground/app/docs/quickstart/page.tsx index 868a641..1982c44 100644 --- a/apps/playground/app/docs/quickstart/page.tsx +++ b/apps/playground/app/docs/quickstart/page.tsx @@ -45,6 +45,7 @@ const QUICKSTART_OPTIONS: WireOptionCatalog = { const VIEWER_SNIPPET = `"use client"; +import "@aigentive/wire-react/styles.css"; import { WireProvider, WireCanvas, @@ -82,6 +83,7 @@ export function AgentDiagram() { const OPTIONS_SNIPPET = `"use client"; import { useCallback, useState } from "react"; +import "@aigentive/wire-react/styles.css"; import { WireProvider, WireCanvas, @@ -127,6 +129,7 @@ export function AgentEditor({ diagram }) { const WORKSPACE_SNIPPET = `"use client"; import { useState } from "react"; +import "@aigentive/wire-react/styles.css"; import { WireWorkspace, type WireDiagram, diff --git a/apps/playground/app/globals.css b/apps/playground/app/globals.css index f1c2ae8..cc15f1f 100644 --- a/apps/playground/app/globals.css +++ b/apps/playground/app/globals.css @@ -514,46 +514,3 @@ 0 2px 6px rgba(0, 0, 0, 0.35); } } - -/* React Flow minimap — match Wire surface tokens. */ -.react-flow__minimap { - background-color: var(--wire-bg-surface) !important; - border: 1px solid var(--wire-border) !important; - border-radius: var(--wire-radius-md) !important; - box-shadow: var(--wire-shadow-sm) !important; -} -.react-flow__minimap-mask { - fill: rgba(15, 23, 42, 0.04); -} - -/* React Flow edges — dashed slate, blue when selected. */ -.react-flow__edge-path { - stroke: var(--wire-slate-400); - stroke-width: 1.5; - stroke-dasharray: 6 4; -} -.react-flow__edge.selected .react-flow__edge-path, -.react-flow__edge:focus .react-flow__edge-path { - stroke: var(--wire-blue-500); -} -.react-flow__arrowhead { - fill: var(--wire-slate-400); -} -.react-flow__edge.selected .react-flow__arrowhead { - fill: var(--wire-blue-500); -} - -/* React Flow edge labels (yes / no on condition branches). */ -.react-flow__edge-text { - font-family: var(--wire-font-mono); - font-size: 10.5px; - fill: var(--wire-fg-tertiary); -} -.react-flow__edge-textbg { - fill: var(--wire-bg-surface); -} - -/* React Flow attribution — keep canvas clean. */ -.react-flow__attribution { - display: none !important; -} diff --git a/apps/playground/package.json b/apps/playground/package.json index a1d8f5a..1583f9b 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -12,7 +12,7 @@ "dependencies": { "@aigentive/wire-core": "^1.0.4", "@aigentive/wire-mcp": "^1.0.7", - "@aigentive/wire-react": "^1.0.4", + "@aigentive/wire-react": "^1.1.0", "@aigentive/wire-renderers": "^1.0.2", "@libsql/client": "^0.17.3", "@resvg/resvg-js": "^2.6.2", diff --git a/docs/REACT_COMPONENTS.md b/docs/REACT_COMPONENTS.md index b8e6786..77bdfb8 100644 --- a/docs/REACT_COMPONENTS.md +++ b/docs/REACT_COMPONENTS.md @@ -3,8 +3,8 @@ This is the props reference for the reusable `@aigentive/wire-react` component surface. These components are intended for product apps and LLM-authored React screens. App code should pass Wire diagrams, option catalogs, render callbacks, -and event handlers. It should not need third-party graph-canvas nodes, hooks, -or state. +and event handlers. It should not use generic graph node/edge objects as the +application contract. For design examples, run the playground and open `/docs` or `/samples/agent-chain`. @@ -15,6 +15,7 @@ For design examples, run the playground and open `/docs` or |---|---|---| | [`WireWorkspace`](#wireworkspace) | Shell | Editor shell — provider, sidebar, canvas, inspector | | [`WireCanvas`](#wirecanvas) | Canvas | Canvas primitive — used inside `WireWorkspace` or directly | +| [`WireInspector`](#wireinspector) | Panel | Tabbed node/edge inspector — configure, style, validation, JSON, edge | | [`WireNodeCardView`](#cards-and-groups) | Renderer | Default node card — kind chip, title, options summary | | [`WireGroupFrame`](#cards-and-groups) | Renderer | Default group frame — child count and selection ring | | [`WireOptionPanel`](#wireoptionpanel) | Panel | Typed option form generated from `WireOptionCatalog` | @@ -25,35 +26,36 @@ For design examples, run the playground and open `/docs` or | Capability | How | |---|---| -| Light & dark theme | Add `@custom-variant dark` and toggle `` (Tailwind v4) | +| Package styling | Import `@aigentive/wire-react/styles.css`; no consumer utility-class scan is required | +| Light, dark, and system theme | Use `colorMode="light" | "dark" | "system"` or override package CSS variables | | Custom node card | `renderNodeCard={fn}` — receives `WireNodeRenderContext` | | Custom group frame | `renderGroup={fn}` | | Structured card content | `node.data.card` — badges, meta, progress, footer | | Custom list rows | ` …} />` | -| Read-only canvas | `` | -| Controlled inspector | `inspectNodeId` + `onInspectNodeChange` | -| Click behavior | `inspectOnClick`, `selectOnClick`, `selectOnEdgeClick`, `clearSelectionOnPaneClick` | +| Read-only canvas | `` or `` | +| Controlled runtime state | `selection`, `viewport`, `mode`, and `dirty` props on provider-backed surfaces | +| Controlled inspector | `inspectNodeId`, `inspectEdgeId`, `onInspectNodeChange`, `onInspectEdgeChange` | +| Click behavior | `inspectOnNodeClick`, `inspectOnEdgeClick`, `selectOnNodeClick`, `selectOnEdgeClick`, `clearSelectionOnPaneClick` | | Validation observer | `` (or read `useWireValidation()`) | -| Decoupled events | `onEvent={fn}` — five event types listed in [Events](#events) | +| Decoupled events | `onEvent={fn}` — node, edge, pane, and selection events listed in [Events](#events) | -## Tailwind CSS v4 +## Package CSS -The playground uses Tailwind CSS v4 with `tailwindcss@^4.1.18` and -`@tailwindcss/postcss`. If a consuming app uses Tailwind and imports the -prebuilt Wire React components, include the package in Tailwind's source scan: +Product apps should import the package stylesheet once near their app root: -```css -@import "tailwindcss"; - -@source "../node_modules/@aigentive/wire-react"; +```ts +import "@aigentive/wire-react/styles.css"; ``` -In a monorepo workspace, point `@source` at the package source path instead. +The stylesheet includes package-owned structure, visual styling, focus rings, +handles, controls, minimap styles, panel styles, and CSS variables. Consumers +can still add `className`, slot `classNames`, `unstyled`, and CSS variable +overrides. npm consumers do not need utility-class source scanning. ## `WireWorkspace` `WireWorkspace` is the easiest reusable app shell. It composes a provider, node -list, canvas, option panel, and validation panel. +list, canvas, and tabbed inspector. ```tsx import { @@ -90,22 +92,38 @@ export function AgentEditor({ diagram, onChange }) { | `onEvent` | `(event) => void` | - | Receives decoupled UI events. | | `validateOnChange` | `boolean` | provider default | Revalidates after changes. | | `history` | `boolean` | provider default | Enables undo/redo state. | +| `selection` / `defaultSelection` | `WireSelection` | provider state | Controlled or initial selected node/edge ids. | +| `onSelectionChange` | `(selection, event) => void` | - | Receives normalized selection plus `selection.change` metadata. | +| `viewport` / `defaultViewport` | `WireViewport` | provider state | Controlled or initial pan/zoom. | +| `onViewportChange` | `(viewport, event) => void` | - | Receives source, cause, previous viewport, and intent metadata. | +| `mode` / `defaultMode` | `WireMode` | `"edit"` | Controlled or initial runtime mode. | +| `onModeChange` | `(mode, event) => void` | - | Receives source and cause metadata. | +| `dirty` / `defaultDirty` | `boolean` | `false` | Controlled or initial dirty state. | +| `onDirtyChange` | `(dirty, event) => void` | - | Fires after durable edits or clean resets. | | `optionCatalog` | `WireOptionCatalog` | - | Defines editable node options. | -| `inspectNodeId` | `string` | - | Controlled node shown in the option panel. | +| `readOnly` | `boolean` | `false` | Locks built-in inspector mutations. | +| `colorMode` | `"light" | "dark" | "system"` | - | Applies `data-wire-theme` and `wire-theme-*` classes to owned surfaces. | +| `unstyled` | `boolean` | `false` | Preserves structure, ARIA, and data attributes while omitting package visual classes on owned surfaces. | +| `classNames` | object | - | Slot classes for root, header, sidebar, canvas region, canvas, inspector, node list, option panel, and validation panel. | +| `inspectNodeId` | `string` | - | Controlled node shown in the inspector. | | `defaultInspectNodeId` | `string` | - | Initial uncontrolled inspected node. | | `onInspectNodeChange` | `(nodeId, event) => void` | - | Called on `node.inspect` and optional pane clear. | +| `inspectEdgeId` | `string` | - | Controlled edge shown in the inspector. | +| `defaultInspectEdgeId` | `string` | - | Initial uncontrolled inspected edge. | +| `onInspectEdgeChange` | `(edgeId, event) => void` | - | Called on edge inspection and optional pane clear. | | `clearInspectOnPaneClick` | `boolean` | `false` | Clears inspected node when the canvas pane is clicked. | | `title` | `ReactNode` | `"Wire"` | Sidebar title. | | `subtitle` | `ReactNode` | - | Sidebar subtitle. | | `sidebar` | `ReactNode` | - | Replaces the default node list area. | -| `inspector` | `ReactNode` | - | Replaces default options and validation panels. | +| `inspector` | `ReactNode` | - | Replaces the default inspector. | | `showNodeList` | `boolean` | `true` | Hides default node list when false. | -| `showOptions` | `boolean` | `true` | Hides default option panel when false. | -| `showValidation` | `boolean` | `true` | Hides default validation panel when false. | +| `showOptions` | `boolean` | `true` | Hides default configure/style/edge/JSON inspector tabs when false. | +| `showValidation` | `boolean` | `true` | Hides default validation inspector tab when false. | | `layout` | `"fixed" | "embedded"` | `"fixed"` | Use `embedded` inside docs/product pages. | | `renderNodeCard` | `WireNodeRenderer` | `WireNodeCardView` | Custom non-group node renderer. | | `renderGroup` | `WireNodeRenderer` | `WireGroupFrame` | Custom group renderer. | | `canvasProps` | `WireCanvasProps` subset | - | Passed to `WireCanvas`. | +| `inspectorProps` | `WireInspectorProps` subset | - | Passed to the owned `WireInspector`. | | `className` | `string` | - | Root classes. | | `sidebarClassName` | `string` | - | Sidebar classes. | | `canvasClassName` | `string` | - | Canvas section classes. | @@ -134,9 +152,10 @@ Use `WireCanvas` when building a custom screen around `WireProvider`. | `selectOnNodeClick` | `boolean` | `true` in edit, `false` in view | Selects clicked nodes. | | `selectOnEdgeClick` | `boolean` | `true` in edit, `false` in view | Selects clicked edges. | | `inspectOnNodeClick` | `boolean` | `true` | Emits `node.inspect` from node clicks. | +| `inspectOnEdgeClick` | `boolean` | `true` | Emits `edge.click` with `intent: "inspect"`. | | `clearSelectionOnPaneClick` | `boolean` | `true` in edit, `false` in view | Clears canvas selection on pane click. | | `fitView` | `boolean` | `true` | Fits diagram in viewport. | -| `fitViewPadding` | `number` | `0.08` | Fit-view padding. | +| `fitViewPadding` | `number` | `0.2` | Shared Fit view and Fit selection padding. | | `panOnDrag` | `boolean` | `true` | Enables canvas drag panning in view and edit mode. | | `zoomOnScroll` | `boolean` | `true` | Enables wheel/trackpad zoom in view and edit mode. | | `zoomStep` | `number` | `1.1` | Zoom multiplier used by wheel gestures and controls. | @@ -145,15 +164,81 @@ Use `WireCanvas` when building a custom screen around `WireProvider`. | `showBackground` | `boolean` | `true` | Shows the dotted background. | | `showControls` | `boolean` | `true` | Shows zoom controls. | | `showMiniMap` | `boolean` | `false` | Shows minimap. | +| `readOnly` | `boolean` | `false` | Disables canvas-originated durable mutations while preserving focus, selection, pan, and zoom. | +| `colorMode` | `"light" | "dark" | "system"` | - | Applies theme attributes/classes to the canvas root. | +| `unstyled` | `boolean` | `false` | Omits package visual classes while keeping geometry, ARIA, focus, and `data-wire-*` hooks. | +| `classNames` | object | - | Slot classes for root, viewport, background, node, group, edge, handle, controls, minimap, status, search, and connection picker. | +| `keyboardA11y` | `boolean` | `true` | Enables managed root-scoped keyboard behavior. | +| `nodesFocusable` | `boolean` | `true` | Includes node shells in roving focus. | +| `edgesFocusable` | `boolean` | `true` | Includes edge shells in roving focus. | +| `autoPanOnNodeFocus` | `boolean` | `true` | Pans focused nodes into view when measured canvas size is available. | | `optionCatalog` | `WireOptionCatalog` | - | Passed into render context. | | `renderNodeCard` | `WireNodeRenderer` | default card | Runtime card renderer. | | `renderGroup` | `WireNodeRenderer` | default group | Runtime group renderer. | | `renderEdge` | `WireEdgeRenderer` | default edge | Runtime edge renderer. | | `edgeStyle` | `EdgeStyle` | default stroke | Diagram-level edge style override. | | `edgeRouting` | `EdgeRouting` | `bezier` | Diagram-level edge routing override. | +| `ariaLabelConfig` | object | package defaults | Localizes canvas, node, edge, minimap, handle, status, and control labels. | +| `isValidConnection` | `(context) => boolean | string` | - | Rejects pointer connections before dispatch and announces the reason. | | `className` | `string` | - | Root classes. | | `style` | `CSSProperties` | - | Root inline styles. | +When selection is non-empty, the built-in controls add `Fit selection`. It uses +the current `fitViewPadding`, emits viewport metadata with +`intent: "fit-selection"`, keeps focus on a selected item when possible, and +announces the fitted item count through the canvas status region. + +Large-diagram mode activates when the rendered diagram exceeds 1,000 nodes or +1,200 edges. It keeps nodes, edges, selection, search, keyboard connection, +skip-to-inspector, and Fit selection available; it marks the root with +`data-wire-large-diagram="true"`, disables nonessential package motion, defers +nonessential measurement work, and simplifies the minimap to viewport and +selection bounds. + +Keyboard behavior is scoped to the focused canvas root or managed node/edge +shells. `Enter`/`Space` select and inspect the focused item, `Escape` clears +selection, Delete/Backspace remove the selected item in edit mode, arrow keys +nudge selected/focused nodes in edit mode, and `n`/`p`/`e` traverse managed +node/edge focus. The first tab stop after the canvas root is a skip control with +the label `Skip to inspector and controls`. + +## `WireInspector` + +`WireInspector` is the built-in production inspector. It follows the current +single selection by default, or accepts explicit `nodeId` and `edgeId`. +Explicit `nodeId` wins when both ids are supplied. + +```tsx + audit(option.key, action)} +/> +``` + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `nodeId` | `string` | selected node | Explicit node to inspect. | +| `edgeId` | `string` | selected edge | Explicit edge to inspect when no node is active. | +| `optionCatalog` | `WireOptionCatalog` | - | Drives Configure tab fields. | +| `tabs` | array | applicable defaults | Any of `configure`, `style`, `validation`, `json`, `edge`. | +| `defaultTab` | tab name | first applicable | Initial tab when applicable. | +| `readOnly` | `boolean` | `false` | Prevents title, style, option, JSON, and edge mutations. | +| `renderField` | function | - | Custom option field inside the package field wrapper. | +| `renderSection` | function | - | Custom option section wrapper. | +| `onOptionCommit` | function | - | Called after option fields dispatch a `node.patch`. | +| `ariaLabelConfig` | object | defaults | Optional tab/field/section label overrides. | +| `colorMode` | `"light" | "dark" | "system"` | - | Applies theme attributes/classes to the inspector root. | +| `unstyled` | `boolean` | `false` | Keeps structure/ARIA while omitting package visual classes. | +| `classNames` | object | - | Slot classes for root, tabs, tab, panel, field, section, validation, JSON, and edge. | + +Edge inspection edits only explicit persisted edges with ids. Derived edges from +node relationships are shown read-only. Phase 3A editable edge fields are +`label`, `tone`, and `routing`; style, label style, data, endpoint, branch, and +handle facts are read-only summaries. + ## Cards And Groups `WireNodeCardView` and `WireGroupFrame` are default renderers. The default card @@ -225,6 +310,10 @@ function AgentCard(ctx: WireNodeRenderContext) { | `options` | `Record` | `node.data.options`. | | `optionSpecs` | `WireOptionSpec[]` | Specs matching the node kind. | +`WireNodeCardView` accepts `unstyled` and `classNames` slots for `root`, +`content`, `badge`, `meta`, `progress`, and `footer`. `WireGroupFrame` accepts +`unstyled` and `classNames` slots for `root`, `header`, `title`, and `count`. + ## `WireOptionPanel` `WireOptionPanel` renders typed controls from a `WireOptionCatalog` and patches @@ -235,6 +324,12 @@ nodes through `node.patch`. | `catalog` | `WireOptionCatalog` | required | Option specs by node kind. | | `nodeId` | `string` | selected node | Explicit node to edit. | | `title` | `string` | `"Options"` | Panel heading. | +| `readOnly` | `boolean` | `false` | Prevents option mutations. | +| `renderField` | function | - | Custom option field inside the package field wrapper. | +| `renderSection` | function | - | Custom option section wrapper. | +| `onOptionCommit` | function | - | Called after an option field dispatches. | +| `unstyled` | `boolean` | `false` | Preserves structure/ARIA and omits package visual classes. | +| `classNames` | object | - | Slot classes for root, field, section, and validation. | | `className` | `string` | - | Root classes. | | `style` | `CSSProperties` | - | Root inline styles. | @@ -261,6 +356,11 @@ const options: WireOptionCatalog = { | `defaultValue` | `string | number | boolean` | Display default. | | `min` / `max` / `step` | `number` | Number input constraints. | | `storage` | `"data.options" | "data" | "node"` | Write target. Defaults to `data.options`. | +| `group`, `section`, `order`, `width` | metadata | Ordering and grouped layout hints. | +| `required`, `readOnly`, `disabled`, `hidden` | boolean or predicate | Runtime field state. | +| `validate`, `parse`, `format` | functions | Runtime-only validation and value conversion. | +| `commitMode` | `"change" | "blur" | "submit"` | Controls when `node.patch` dispatches. | +| `debounceMs` | `number` | Debounces change-mode commits. | ## `WireNodeList` @@ -272,9 +372,31 @@ const options: WireOptionCatalog = { | `inspectOnClick` | `boolean` | `true` | Emits `node.inspect`. | | `selectOnClick` | `boolean` | `true` | Updates provider selection. | | `renderItem` | `(context) => ReactNode` | built-in row | Custom row renderer. | +| `unstyled` | `boolean` | `false` | Preserves structure and omits package visual classes. | +| `classNames` | object | - | Slot classes for root, item, and empty state. | +| `className` | `string` | - | Root classes. | +| `style` | `CSSProperties` | - | Root inline styles. | + +## `WireValidationPanel` + +`WireValidationPanel` renders the provider validation result. + +| Prop | Type | Default | Notes | +|---|---|---|---| +| `unstyled` | `boolean` | `false` | Preserves structure and omits package visual classes. | +| `classNames` | object | - | Slot classes for root, header, list, issue, and empty state. | | `className` | `string` | - | Root classes. | | `style` | `CSSProperties` | - | Root inline styles. | +## `WireToolbar` And `WirePalette` + +Standalone tools can be mounted inside any `WireProvider`. + +| Component | Styling props | +|---|---| +| `WireToolbar` | `unstyled`, `classNames.root`, `classNames.group`, `classNames.button`, `className`, `style` | +| `WirePalette` | `unstyled`, `classNames.root`, `classNames.item`, `className`, `style` | + ## Events Events are small and app-level. They decouple card/list clicks from sidebars. @@ -291,11 +413,11 @@ Events are small and app-level. They decouple card/list clicks from sidebars. | Event | Shape | |---|---| -| `node.click` | `{ type, source, nodeId }` | -| `node.inspect` | `{ type, source, nodeId }` | -| `edge.click` | `{ type, source, edgeId }` | +| `node.click` | `{ type, source, nodeId, input? }` | +| `node.inspect` | `{ type, source, nodeId, input? }` | +| `edge.click` | `{ type, source, edgeId, input?, intent? }` | | `pane.click` | `{ type, source }` | -| `selection.change` | `{ type, source, selection }` | +| `selection.change` | `{ type, source, selection, previousSelection?, cause? }` | Source labels are `"canvas"`, `"node-card"`, `"node-list"`, `"option-panel"`, `"validation-panel"`, `"workspace"`, or `"api"`. Built-in components currently @@ -310,4 +432,4 @@ handlers, or programmatic integrations. - The playground `/docs/listen` route shows event recipes and source labels. - The playground `/samples/agent-chain` route shows a full app screen. - The package README (`packages/wire-react/README.md`) covers the JSX facade - (``, node components) and Tailwind setup. + (``, node components) and package CSS import. diff --git a/docs/REACT_EDITOR_ARCHITECTURE.md b/docs/REACT_EDITOR_ARCHITECTURE.md index b3fcda5..95fa1e7 100644 --- a/docs/REACT_EDITOR_ARCHITECTURE.md +++ b/docs/REACT_EDITOR_ARCHITECTURE.md @@ -1,8 +1,9 @@ # React Editor Architecture `@aigentive/wire-react` is a controlled React editor for canonical -`WireDiagram` JSON. React Flow, DOM layout, custom cards, and inspector widgets -are runtime concerns; persistence remains the Wire schema plus reducer actions. +`WireDiagram` JSON. DOM layout, custom cards, canvas viewport state, and +inspector widgets are runtime concerns; persistence remains the Wire schema plus +reducer actions. ## State Ownership @@ -14,10 +15,12 @@ are runtime concerns; persistence remains the Wire schema plus reducer actions. - `viewport`: pan and zoom state. - `history`: undo and redo stacks. - `mode`: edit or view. +- `dirty`: runtime dirty flag compared against a provider clean baseline. Apps can control the diagram with `diagram`/`onChange` or provide -`defaultDiagram` for local state. In both cases, changes are emitted as -`WireEvent` records so hosts can observe the reducer action behind each edit. +`defaultDiagram` for local state. Selection, viewport, mode, and dirty state can +also be controlled through the same provider-backed surfaces. Runtime callbacks +carry source/cause metadata; durable edits still flow through reducer actions. ## Reducer Contract @@ -45,6 +48,8 @@ Canvas responsibilities: - convert pointer gestures into world coordinates; - snap new node positions to the configured grid; - emit reducer actions for move, resize, connect, disconnect, and selection; +- keep keyboard commands scoped to the active canvas root; +- expose focusable node and edge shells with configurable ARIA labels; - keep runtime geometry out of persisted node data. Layout commands are explicit. `layout.apply` can reposition nodes through the @@ -68,9 +73,11 @@ to persist and remain serializable. ## Option Catalogs Wire does not define a universal set of AI/tool/action options. Consumers pass a -`WireOptionCatalog` to `WireWorkspace` or `WireCanvas`. +`WireOptionCatalog` to `WireWorkspace`, `WireInspector`, `WireOptionPanel`, or +`WireCanvas`. -`WireOptionPanel` reads specs for the selected node and emits `node.patch`: +`WireInspector` and `WireOptionPanel` read specs for the selected or explicit +node and emit `node.patch`: - `storage: "node"` updates canonical top-level node fields such as `model`, `ref`, or `branches`. @@ -83,12 +90,18 @@ contract used by canvas and inspector components. ## Inspector Model -The inspector is selected-node oriented: +The inspector resolves one runtime target: -- title and description edit common node fields; -- configure controls come from the option catalog; -- style controls patch node appearance; -- validation is shown separately through `WireValidationPanel`. +- explicit `nodeId`; +- explicit `edgeId`; +- single selected node; +- single selected edge; +- empty or mixed selection summary. + +For nodes, title, description, Configure, Style, Validation, and read-only JSON +tabs operate on canonical node fields and option catalog fields. For edges, +explicit persisted edges with ids can edit `label`, `tone`, and `routing` +through `edge.patch`; derived relationship edges are inspectable but read-only. Apps that need a domain-specific inspector can replace the default panel and still use `useWireDiagram`, `useWireSelection`, and `useWireDispatch` to emit the @@ -99,7 +112,7 @@ same reducer actions. Persist only canonical `WireDiagram` JSON. Do not persist: - React component instances; -- React Flow node/edge objects; +- generic graph node/edge objects; - viewport pan/zoom; - transient hover, drag, or selection state; - non-serializable callbacks or runtime objects. @@ -119,4 +132,3 @@ Product apps usually choose one of two integration levels: Both paths keep the same reducer protocol, validation behavior, and history semantics. That is the contract shared with MCP tools and cloud APIs. - diff --git a/docs/REACT_PRODUCTION_IMPLEMENTATION_PLAN.md b/docs/REACT_PRODUCTION_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..df7120a --- /dev/null +++ b/docs/REACT_PRODUCTION_IMPLEMENTATION_PLAN.md @@ -0,0 +1,2292 @@ +# React Production Implementation Plan + +## Executive Summary + +This plan hardens the existing `@aigentive/wire-react` package in place. The +winning path is the lowest-risk incremental plan, grafted with the strongest +adoption, styling, accessibility, edge-inspector, and documentation requirements +from the other candidate plans. + +The core rule is unchanged: durable state is `WireDiagram`, and durable edits +flow through `WireAction` reducers. React state, viewport state, focused item +state, render callbacks, and form widgets remain runtime concerns. + +The implementation order is: + +1. Lock the public contract and remove docs drift. +2. Ship package-owned CSS and a `styles.css` export. +3. Add controlled provider state for selection, viewport, mode, and dirty state. +4. Establish the performance harness before inspector and canvas expansion. +5. Merge catalog-driven options into `WireInspector` using current option names. +6. Harden `WireCanvas` accessibility, keyboard behavior, edge editing, and + current render callbacks. +7. Add docs, examples, package smoke tests, and release gates. + +The plan deliberately avoids a second React package, alternate option catalog, +provider-level catalog fallback, versioned option names, generic graph state as +the app contract, and npm consumer dependence on utility-class source scanning. + +## Convergence Summary + +### Research + +Seven analyst passes inspected the handoff spec, current React docs, option +catalog docs, package source, provider, hooks, canvas, inspector, package +metadata, and tests. + +Key findings: + +- `WireOptionSpec` and `WireOptionCatalog` are already the public option names in + `packages/wire-react/src/options.ts` and are exported from + `packages/wire-react/src/index.ts`. +- `optionCatalog` is currently a view-layer prop on `WireWorkspace` and + `WireCanvas`; `WireOptionPanel` uses `catalog`. `WireProvider` does not accept + a catalog. +- `WireInspector` currently edits title, description, and appearance only. It + does not accept `nodeId` or `optionCatalog`. +- `WireOptionPanel` dispatches `node.patch` on every input change, which is + compatible but noisy for undo history. +- `WireProvider` controls only `diagram`; selection, viewport, mode, dirty, + undo, and redo are internal. +- `WireCanvas` has useful internal handles, controls, minimap, drag, pan, zoom, + and connection behavior, but no public add-on or registry contract. +- Current keyboard behavior is incomplete and delete handling is window-scoped. +- Package metadata exposes only `.` and `./compile`; there is no package CSS + export, while docs still describe utility-class source scanning for consumers. +- Current tests cover core React behavior, but release gates need package CSS + smoke, controlled-state tests, keyboard tests, and browser examples. + +### Design + +Three candidate plans were produced: + +- Plan A: lowest-risk incremental plan with contract lock, package CSS, + controlled state, options/inspector convergence, canvas hardening, docs, and + release gates. +- Plan B: most complete plan with broader schema and provider changes. +- Plan C: fastest useful adoption plan with strong CSS and keyboard focus but + lighter contract and documentation coverage. + +### Judge Outcome + +All three judges selected Plan A as the winner. + +Required grafts: + +- From Plan C: keep adoption scope tight, include keyboard hardening, and defer + new store, instance, add-on, port, and culling APIs. +- From Plan B: include comprehensive CSS coverage, accessibility acceptance + criteria, edge inspector support through current actions, and complete docs. + +Rejected from Plan B: + +- A provider-level catalog fallback. +- Named port/schema work in the first implementation. +- Broad action/schema expansion bundled with React package adoption work. + +## Strict Non-Goals + +- Do not create a second React package. +- Do not create alternate option catalog names or versioned option names. +- Do not add a catalog prop to the provider. +- Do not make generic graph node/edge objects the app contract. +- Do not persist React components, render callbacks, viewport state, focus state, + drag state, or catalog functions. +- Do not require utility-class source scanning from npm consumers. +- Do not add named port schema or new canonical edge endpoint fields in this + plan. +- Do not add public store, instance, or add-on component APIs in this plan. +- Do not remove existing exports, hooks, props, or render callbacks. + +## Exact Current API Names To Extend + +Extend only these current names: + +- `@aigentive/wire-react` +- `Flow` +- `FlowComponentProps` +- `LayoutDirection` +- `ReactNode` +- `useCompiledWireDiagram` +- `compile` +- `FlowProps` +- `flattenChildren` +- JSX marker exports: `TriggerNode`, `ActionNode`, `AINode`, `ToolNode`, + `ConditionNode`, `HumanNode`, `MemoryNode`, `RetrievalNode`, + `GuardrailNode`, `EndNode`, `Note`, `Group`, `TriggerProps`, + `ActionProps`, `AIProps`, `ToolProps`, `ConditionProps`, `HumanProps`, + `MemoryProps`, `RetrievalProps`, `GuardrailProps`, `EndProps`, `NoteProps`, + and `GroupProps` +- `WireWorkspace` +- `WireWorkspaceProps` +- `WireEditor` +- `WireEditorProps` +- `WireViewer` +- `WireViewerProps` +- `WireToolbar` +- `WireToolbarProps` +- `WirePalette` +- `WirePaletteProps` +- `WireNodeList` +- `WireNodeListProps` +- `WireNodeListRenderContext` +- `WireValidationPanel` +- `WireValidationPanelProps` +- `WireCanvas` +- `WireCanvasProps` +- `WireProvider` +- `WireProviderProps` +- `WireContext` +- `EMPTY_SELECTION` +- `DEFAULT_VIEWPORT` +- `assertWireContext` +- `WireDiagram` +- `WireAction` +- `WireContextValue` +- `WireActions` +- `WireSelection` +- `WireViewport` +- `WireMode` +- `WireEventSource` +- `WireEvent` +- `WireChangeEvent` +- `WireHistoryState` +- `WireHistoryActions` +- `WireSelectionActions` +- `WireViewportActions` +- `WireEventActions` +- `WireReactState` +- `WireInspector` +- `WireInspectorProps` +- `WireOptionPanel` +- `WireOptionPanelProps` +- `WireOptionSpec` +- `WireOptionCatalog` +- `WireOptionInputType` +- `WireOptionStorage` +- `WireOptionPrimitive` +- `WireOptionChoice` +- option helpers: `wireOptionSpecsForNode`, `wireNodeOptions`, + `readWireOption`, `patchWireOption`, `optionChoiceLabel`, + `optionChoiceValue`, `optionChoiceKey`, `inferOptionType` +- `WirePosition` +- `WireCanvasPosition` +- `SIDE_TO_POSITION` +- `POSITION_TO_SIDE` +- `asSide` +- `WireNodeTheme` +- `WireNodeRenderContext` +- `WireNodeRenderer` +- `WireNodeData` +- `WireNodeCardProps` +- `createWireNodeRenderContext` +- `WireNodeCard` +- `DEFAULT_NODE_RENDERERS` +- `WireEdgeRenderContext` +- `WireEdgeRenderer` +- `WireCanvasInteractionOptions` +- `WireCanvasInteraction` +- `miniMapViewportRect` +- `resolveWireCanvasInteraction` +- `wireActionsFromCanvasDragCommit` +- `WireNodeCardView` +- `WireNodeCardViewProps` +- `WireGroupFrame` +- `WireGroupFrameProps` +- `WireCardBadgeTone` +- `WireCardBadge` +- `WireCardMetaItem` +- `WireCardProgress` +- `WireCardContent` +- `cardStyleForNode` +- `wireCardContentForNode` +- current primitives exported from `packages/wire-react/src/primitives/index.ts`: + `KindChip`, `Eyebrow`, `Ref`, `InlineCode`, `CodeBlock`, `StatusPill`, + `Edge`, `DotGrid`, `GroupFrame`, `NodeCard`, `kindChipKey`, + `kindChipLabel`, `KindChipProps`, `EyebrowProps`, `RefProps`, + `InlineCodeProps`, `CodeBlockProps`, `StatusPillProps`, `StatusPillKind`, + `EdgeProps`, `DotGridProps`, `GroupFrameProps`, `NodeCardProps`, + `WireKindChipKey`, and `WireNodeKind` +- existing hooks: `useWireContext`, `useWireDiagram`, `useWireValidation`, + `useWireSelection`, `useWireViewport`, `useWireActions`, `useWireHistory`, + `useWireMode`, `useWireDispatch`, `useWireEvents` + +Do not add provider catalog state. After implementation, catalog entry points +are: + +- `WireWorkspace optionCatalog` +- `WireCanvas optionCatalog` +- `WireInspector optionCatalog` +- `WireOptionPanel catalog` + +`WireEditor` and `WireViewer` continue their current `WireCanvasProps` +pass-through behavior. If their current inherited props include +`WireCanvas optionCatalog`, that remains a canvas-level pass-through rather than +a new catalog owner. Do not add catalog storage, lookup, or fallback behavior to +the wrappers. + +The proposed TypeScript snippets may reference these current core type names +already exposed through existing React package declaration signatures: +`WireNode`, `ApplyWireActionResult`, `EdgeStyle`, `EdgeRouting`, and `Side`. +They are not new `@aigentive/wire-react` root exports, and this plan must not +add root re-exports for them. + +The snippets may also reference React's ambient declaration types such as +`React.ReactNode` and `React.CSSProperties`. These are external React type +references, not new `@aigentive/wire-react` public names. + +## Proposed TypeScript Shapes + +These are additive shapes on current public names. Do not export helper type +names for predicates, validators, ARIA labels, class slots, connection +validation, or field renderers in this implementation. If implementation helper +types are useful internally, keep them module-private or inline them into the +current public interfaces shown below. `WireSelectionActions`, +`WireViewportActions`, `WireContextValue`, and related provider/context names +shown below are existing root exports from the current package; the plan extends +their current method signatures in place and does not introduce replacement +action containers. + +### Options + +```ts +export type WireOptionInputType = + | "text" + | "textarea" + | "number" + | "boolean" + | "select"; + +export interface WireOptionSpec { + key: string; + label?: string; + type?: WireOptionInputType; + description?: string; + placeholder?: string; + options?: WireOptionChoice[]; + defaultValue?: WireOptionPrimitive; + min?: number; + max?: number; + step?: number; + storage?: WireOptionStorage; + group?: string; + section?: string; + order?: number; + width?: "full" | "half" | "third"; + required?: boolean; + readOnly?: boolean | ((context: { + node: WireNode; + diagram: WireDiagram; + value: unknown; + option: WireOptionSpec; + }) => boolean); + disabled?: boolean | ((context: { + node: WireNode; + diagram: WireDiagram; + value: unknown; + option: WireOptionSpec; + }) => boolean); + hidden?: boolean | ((context: { + node: WireNode; + diagram: WireDiagram; + value: unknown; + option: WireOptionSpec; + }) => boolean); + validate?: (context: { + node: WireNode; + diagram: WireDiagram; + value: unknown; + option: WireOptionSpec; + }) => + | { message: string; severity?: "error" | "warning" | "info" } + | Array<{ message: string; severity?: "error" | "warning" | "info" }> + | null + | undefined; + parse?: (input: unknown, context: { + node: WireNode; + diagram: WireDiagram; + value: unknown; + option: WireOptionSpec; + }) => unknown; + format?: (value: unknown, context: { + node: WireNode; + diagram: WireDiagram; + value: unknown; + option: WireOptionSpec; + }) => unknown; + commitMode?: "change" | "blur" | "submit"; + debounceMs?: number; +} + +export type WireOptionCatalog = + Partial>; +``` + +Rules: + +- Existing catalogs remain valid. +- Default `commitMode` remains `"change"` for compatibility. +- Phase 3A keeps the published `WireOptionInputType` union to the current five + input types plus grouping, ordering, visibility, validation, and commit + behavior. +- Deferred post-release field-type expansion extends the same + `WireOptionInputType` name one literal at a time only when that field type + ships with UX, keyboard, ARIA, parsing, formatting, serialization, + persistence-safety, and tests. Deferred literals must not appear in emitted + declarations before their implementation ships. +- The planned in-place field-type queue is `multiSelect`, `json`, `code`, + `color`, `date`, `url`, `secret`, `array`, `object`, and `custom`; this queue + is not a public contract until each literal is implemented. +- `defaultValue` stays `WireOptionPrimitive` in Phase 3A. Any later support for + non-primitive defaults must be introduced only with compatibility fixtures that + prove existing consumers reading primitive defaults still typecheck. +- `commitMode: "submit"` renders explicit Apply and Revert controls. Enter + commits single-line fields, Ctrl+Enter or Cmd+Enter commits multiline fields, + Escape reverts the pending value, and selection change or unmount discards + uncommitted pending values with a polite status announcement. `onOptionCommit` + fires only after Apply or the defined keyboard commit path. +- For debounced fields, `debounceMs` schedules a pending commit for the current + inspected node only. Blur flushes a valid pending value. Selection change, + inspected item change, unmount, read-only toggle, disabled toggle, or parse + invalid state cancels the pending timer and discards the uncommitted value + with a polite status announcement. No stale debounced commit may dispatch + after the inspected item changes. +- Parse exceptions, invalid numbers, empty required values, and parse-invalid + pending values are local non-mutating field errors. They set `aria-invalid`, + attach the error text with `aria-describedby`, keep Apply disabled for submit + mode, do not call `onOptionCommit`, and do not dispatch `node.patch` or + `edge.patch`. Blur/change commit modes keep the last committed value until the + field parses successfully. +- `parse` output must be JSON-serializable and valid for the target storage + before dispatch. Non-serializable values, functions, React nodes, symbols, and + invalid canonical node patches are treated as local non-mutating field errors. +- `hidden`, `disabled`, `readOnly`, and `validate` can be functions only in + runtime React catalogs. Persisted diagrams must not serialize these functions. +- `storage: "node"` must continue to patch top-level node fields through + `node.patch`; invalid canonical patches must surface validation errors. + +### Inspector + +```ts +export interface WireInspectorProps { + nodeId?: string; + edgeId?: string; + optionCatalog?: WireOptionCatalog; + tabs?: Array<"configure" | "style" | "validation" | "json" | "edge">; + defaultTab?: "configure" | "style" | "validation" | "json" | "edge"; + readOnly?: boolean; + renderField?: (context: { + fieldId: string; + labelId: string; + descriptionId?: string; + errorId?: string; + describedBy?: string; + node: WireNode; + diagram: WireDiagram; + option: WireOptionSpec; + value: unknown; + disabled: boolean; + readOnly: boolean; + required: boolean; + issues: Array<{ message: string; severity?: "error" | "warning" | "info" }>; + onChange(value: unknown): void; + onCommit?(value: unknown): void; + }) => React.ReactNode; + renderSection?: (context: { + section: string; + options: WireOptionSpec[]; + children: React.ReactNode; + }) => React.ReactNode; + onOptionCommit?: (context: { + node: WireNode; + option: WireOptionSpec; + value: unknown; + action: WireAction; + }) => void; + ariaLabelConfig?: { + tab?: (tab: "configure" | "style" | "validation" | "json" | "edge") => string; + optionField?: (option: WireOptionSpec) => string; + section?: (section: string) => string; + }; + colorMode?: "light" | "dark" | "system"; + unstyled?: boolean; + classNames?: { + root?: string; + tabs?: string; + tab?: string; + panel?: string; + field?: string; + section?: string; + validation?: string; + json?: string; + edge?: string; + }; + className?: string; + style?: React.CSSProperties; +} +``` + +Rules: + +- If both `nodeId` and `edgeId` are supplied to `WireInspector`, explicit + `nodeId` wins, edge-only controls are suppressed, and the `edgeId` value is + ignored for that render. +- If `nodeId` is omitted, the inspector follows single-node selection. +- If `edgeId` is omitted and a single edge is selected, edge inspection is + available. +- Configure uses `WireOptionCatalog`. +- Style preserves all current node appearance controls. +- Edge inspection uses existing edge fields and `edge.patch` / `edge.remove`. +- `readOnly` disables all mutating controls, including title, description, + Configure, Style, JSON, Edge controls, and custom `renderField` callbacks. +- Inspector `ariaLabelConfig` entries are optional overrides. Missing, empty, or + whitespace-only tab, option-field, and section labels fall back to deterministic + defaults. Tab defaults are `Configure`, `Style`, `Validation`, `JSON`, and + `Edge`; option-field defaults use the option label, then option key; section + defaults use the section name, then `Options`; tests must cover omitted and + empty or whitespace-only override values. +- Inspector tabs use `tablist`, `tab`, and `tabpanel` semantics, including + `aria-controls`, `aria-labelledby`, roving `tabIndex`, selected state, + automatic activation on horizontal ArrowLeft/ArrowRight navigation, Home/End + navigation, focus retention, and deterministic initial panel focus. + ArrowLeft/ArrowRight wrap within the applicable tab set; Home moves to the + first applicable tab; End moves to the last applicable tab. ArrowUp/ArrowDown + are ignored unless a vertical tab orientation is added later in the same + `WireInspector` API with matching tests. +- Tab enters the inspector tablist at the active tab when focus approaches from + the previous inspector control, then the next Tab moves into the active + panel's first focusable control or panel heading when no control is focusable. + Shift+Tab from the active panel returns to the active tab, and Shift+Tab from + the active tab moves to the previous focusable control outside the tablist. + The roving inactive tabs remain out of the sequential tab order. `readOnly` + keeps tab navigation, panel headings, help text, validation text, read-only + summaries, and copyable values reachable; only mutating controls become + disabled, read-only, or no-op according to the field rules. +- If explicit or selected inspection is edge-only, the initial tab is `edge`. + If `defaultTab` is not applicable to the inspected item, the inspector falls + back to the first applicable tab. The active tab resets when the inspected + node or edge changes. +- If `tabs` is supplied as an empty array, or if all supplied tabs are invalid or + inapplicable for the inspected item, `WireInspector` renders a labelled, + non-mutating empty state without a `tablist`; focus lands on the inspector root + or empty-state heading. If `tabs` is omitted, the default applicable tab set is + used. +- If the focused tab disappears after node, edge, or mixed-selection changes, + focus moves to the newly active applicable tab, the tab panel heading receives + a polite status announcement, and hidden or inapplicable tabs are removed from + the roving tab order. +- The JSON tab is read-only in Phase 3A. It shows the inspected node JSON for + node inspection and the inspected edge JSON for edge inspection. Mixed + selection has no editable JSON body. If editable JSON ships later, parse + errors must be non-mutating, node edits must dispatch only `node.patch`, and + edge edits must dispatch only `edge.patch`. +- Generated option fields in `WireInspector` and `WireOptionPanel` must use + stable ids, `label`/`htmlFor` or equivalent `aria-label`, description and + validation linkage through `aria-describedby`, `aria-invalid` for errors, + required semantics, and `fieldset`/`legend` or labelled group semantics for + sections. +- Custom `renderField` output is always hosted inside a package-owned field + wrapper that supplies stable ids, label text, description text, error text, + required markers, disabled/read-only state, and `data-wire-*` attributes. + Custom renderers must attach the provided `fieldId`, `labelId`, + `describedBy`, and `errorId` to their focusable control or return a control + that is labelled by the wrapper. Acceptance tests must include custom fields + that preserve label, description, error, required, invalid, disabled, and + read-only semantics. +- `disabled` fields use native `disabled` where available, `aria-disabled` plus + removed sequential tab stops for non-native controls, hide field-level + validation unless a durable diagram issue already exists, and receive no-op + `onChange` and `onCommit` callbacks in custom renderers. +- `readOnly` fields remain focusable and copyable, use native `readOnly` or + `aria-readonly`, keep help and validation text visible, and receive no-op + mutating callbacks in custom renderers. +- If a field resolves to both disabled and read-only, disabled wins for focus, + mutation, and validation visibility semantics. Parent `WireWorkspace readOnly` + cannot make a disabled field focusable; it only makes otherwise enabled fields + read-only. Tests must cover field-level disabled plus field-level read-only, + parent read-only plus field-level disabled, and parent read-only plus + field-level read-only. +- Edge controls appear only when exactly one edge is selected or `edgeId` is + supplied. Explicit `nodeId` wins over selection. Mixed node+edge selection + renders a non-mutating summary. +- Edge inspection Phase 3A edits only primitive edge fields that have accessible + controls with the current option input types: `label`, `tone`, and `routing`. + `style`, `labelStyle`, and `data` are shown as read-only summaries until the + matching object/json/code field support ships with parsing, validation, and + accessibility tests. `from`, `to`, `branch`, `fromHandle`, and `toHandle` are + shown as read-only connection facts and are changed only by existing canvas + connection workflows. Editable edge fields are available only for explicit + edges present in `diagram.edges` with an id that can be patched. Synthesized + edges derived from node `from` relationships are inspectable but read-only and + use deterministic runtime-only ids with the reserved prefix + `wire-derived-edge:` followed by a base64url-encoded canonical JSON tuple: + `[sourceNodeId, targetNodeId, branch ?? null, fromHandle ?? null, + toHandle ?? null, relationshipIndex]`. The tuple fields are emitted in that + exact order; `relationshipIndex` is the zero-based index in the canonical + relationship-owner node's normalized `from` relationship array. In this plan, + the relationship-owner node is the node whose `from` relationship produces the + derived edge. Absent optional values are canonical `null`, strings are not + concatenated directly, and the encoded payload is decoded only by the + synthesized-edge lookup. Explicit edges always take lookup precedence by exact id, + including explicit persisted edge ids that already start with the reserved + runtime prefix. The synthesized-edge lookup is consulted only after no explicit + edge with the requested id exists. These ids are rebuilt from the current + `WireDiagram` on render, never enter persisted state, never dispatch + `edge.patch` or `edge.remove`, and resolve only through the inspector's + synthesized-edge lookup. If the source relationship disappears, the stale + synthesized id renders the same non-mutating empty state as a missing explicit + edge. New explicit edge ids created by React package UI must not use this + reserved runtime prefix. Synthesized edges expose an affordance to create an + explicit edge if that workflow ships later. Invalid edge patch input is blocked + before dispatch when it can be parsed locally, and reducer validation errors + are shown in the field summary plus Validation tab after dispatch. +- Phase 3A edge fields use built-in controls only. `renderField`, + `renderSection`, and `onOptionCommit` apply to node option fields, not Edge + tab fields. Edge field customization is deferred until a current-name, + edge-aware renderer contract is specified. +- `WireOptionPanel` remains a smaller primitive and shares field-rendering + helpers with `WireInspector`. + +```ts +export interface WireOptionPanelProps { + catalog: WireOptionCatalog; + nodeId?: string; + title?: string; + readOnly?: boolean; + renderField?: WireInspectorProps["renderField"]; + renderSection?: WireInspectorProps["renderSection"]; + onOptionCommit?: (context: { + node: WireNode; + option: WireOptionSpec; + value: unknown; + action: WireAction; + }) => void; + unstyled?: boolean; + classNames?: { + root?: string; + field?: string; + section?: string; + validation?: string; + }; + className?: string; + style?: React.CSSProperties; +} +``` + +### Provider + +```ts +export type WireEventSource = + | "canvas" + | "node-card" + | "node-list" + | "option-panel" + | "validation-panel" + | "workspace" + | "api"; + +export type WireEvent = + | { type: "node.click"; source: WireEventSource; nodeId: string; input?: "pointer" | "keyboard" } + | { type: "node.inspect"; source: WireEventSource; nodeId: string; input?: "pointer" | "keyboard" } + | { type: "edge.click"; source: WireEventSource; edgeId: string; input?: "pointer" | "keyboard"; intent?: "select" | "inspect" } + | { type: "pane.click"; source: WireEventSource } + | { type: "selection.change"; source: WireEventSource; selection: WireSelection; previousSelection?: WireSelection; cause?: "node" | "edge" | "pane" | "keyboard" | "api" }; + +export interface WireProviderProps { + diagram?: WireDiagram; + defaultDiagram?: WireDiagram; + onChange?: (diagram: WireDiagram, event: WireChangeEvent) => void; + onAction?: (action: WireAction, result: ApplyWireActionResult) => void; + onEvent?: (event: WireEvent) => void; + validateOnChange?: boolean; + history?: boolean; + + selection?: WireSelection; + defaultSelection?: WireSelection; + onSelectionChange?: ( + selection: WireSelection, + event: Extract + ) => void; + + viewport?: WireViewport; + defaultViewport?: WireViewport; + onViewportChange?: (viewport: WireViewport, event: { + source: WireEventSource; + viewport: WireViewport; + previousViewport?: WireViewport; + cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api"; + intent?: "fit-view" | "fit-selection"; + }) => void; + + mode?: WireMode; + defaultMode?: WireMode; + onModeChange?: (mode: WireMode, event: { + source: WireEventSource; + mode: WireMode; + previousMode?: WireMode; + cause?: "toolbar" | "keyboard" | "api"; + }) => void; + + dirty?: boolean; + defaultDirty?: boolean; + onDirtyChange?: (dirty: boolean, event: { + source: WireEventSource; + dirty: boolean; + previousDirty?: boolean; + cause?: "edit" | "undo" | "redo" | "reset" | "api"; + }) => void; + + children: React.ReactNode; +} + +export interface WireSelectionActions { + setSelection(selection: WireSelection, event?: { + source?: WireEventSource; + previousSelection?: WireSelection; + cause?: "node" | "edge" | "pane" | "keyboard" | "api"; + }): void; + clearSelection(event?: { + source?: WireEventSource; + previousSelection?: WireSelection; + cause?: "node" | "edge" | "pane" | "keyboard" | "api"; + }): void; +} + +export interface WireViewportActions { + setViewport(viewport: WireViewport, event?: { + source?: WireEventSource; + previousViewport?: WireViewport; + cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api"; + intent?: "fit-view" | "fit-selection"; + }): void; +} + +export interface WireContextValue extends WireReactState { + actions: WireActions; + selectionActions: WireSelectionActions; + viewportActions: WireViewportActions; + eventActions: WireEventActions; + historyActions: WireHistoryActions; + setMode(mode: WireMode, event?: { + source?: WireEventSource; + previousMode?: WireMode; + cause?: "toolbar" | "keyboard" | "api"; + }): void; + markClean(event?: { + source?: WireEventSource; + previousDirty?: boolean; + cause?: "reset" | "api"; + }): void; +} +``` + +Rules: + +- Controlled selection, viewport, mode, and dirty state mirror editor runtime + state only. +- `WireDiagram` remains the only persisted diagram state. +- `dispatch` and `dispatchMany` remain the mutation path for durable edits. +- `WireSelectionActions.setSelection`, `WireSelectionActions.clearSelection`, + `WireViewportActions.setViewport`, and `setMode` keep their existing call + shape and accept an optional event metadata argument. Existing one-argument + calls keep working. `markClean` is additive on current `WireContextValue`; it + is not a replacement action container and does not introduce a reducer action. +- `WireEventSource` keeps the current published literal set shown in the + provider shape: `canvas`, `node-card`, `node-list`, `option-panel`, + `validation-panel`, `workspace`, and `api`. Do not add source literals in this + implementation. +- Source mapping uses the current literals consistently: canvas root, handles, + minimap, controls, and canvas keyboard actions use `canvas`; node card + interactions use `node-card`; node list interactions use `node-list`; + `WireOptionPanel` and option-driven `WireInspector` fields use `option-panel`; + standalone `WireInspector` built-in title, description, style, JSON, tab, and + edge controls use `workspace`; standalone `WireToolbar` and `WirePalette` + interactions use `workspace`; workspace-owned toolbar, palette, inspector-tab, + layout, shell, and focus-transfer actions use `workspace`; validation panel + interactions use `validation-panel`; host calls through exposed actions without + explicit metadata use `api`. +- `WireEvent["type"]` does not expand in the initial production implementation. + Node inspection keeps `node.inspect`; edge inspection uses existing + `edge.click` with `intent: "inspect"`. Selection keeps existing + `selection.change`. Viewport, mode, and dirty callbacks receive the inline + metadata objects shown above and do not emit `onEvent`. +- Runtime-state callbacks receive the exact metadata shape for the state being + changed. If no metadata is supplied, the provider creates metadata with + `source: "api"` and `cause: "api"`. +- Setter arguments are the only source of truth for next selection, viewport, and + mode values. Setter metadata accepts only `source`, `cause`, and previous-state + hints; it must not include duplicate next-state values. The provider constructs + callback metadata by combining the authoritative setter argument with those + hints. +- Fit view and Fit selection both report viewport callbacks with + `cause: "fit-view"` to keep the existing cause literal set. They differ by + `intent: "fit-view"` or `intent: "fit-selection"` metadata. Tests must cover + omitted and explicit `fitViewPadding` values for both intents and prove same + resulting viewport values still suppress callbacks. +- Same-value controlled runtime updates are no-ops: they do not emit + `onSelectionChange`, `onViewportChange`, `onModeChange`, `onDirtyChange`, or + `onEvent`. +- Same-value checks are deterministic. `WireSelection` equality compares sorted + unique `nodeIds` and sorted unique `edgeIds`. `WireViewport` equality compares + finite `x`, `y`, and `zoom` values with `Object.is` after normalizing `-0` to + `0`; non-finite viewport values are invalid and rejected before state update. + `WireMode` and dirty equality are direct scalar comparisons. Diagram structural + equality uses the same canonical stable JSON representation used for dirty + baseline comparison after reducer normalization. +- Stored and callback `WireSelection` values are normalized to sorted unique + `nodeIds` and `edgeIds`; setter input order and duplicate ids are not preserved. +- Dirty state has an explicit clean baseline. Initial `diagram` or + `defaultDiagram` establishes the baseline. `defaultDirty` initializes only the + uncontrolled dirty flag and notification state; it does not make the initial + diagram dirty relative to a different baseline. When `defaultDirty={true}`, + undo/redo still compare against the initial diagram baseline until the first + durable edit or `markClean`. Successful durable edits set dirty true; undo/redo + compare canonical stable JSON for the current diagram to canonical stable JSON + for the baseline. `WireContextValue.markClean` records the resolved current + diagram as the new clean baseline and sets dirty false. In controlled dirty + mode it invokes `onDirtyChange(false, ...)` when dirty was true; when controlled + dirty is already false it still updates the provider's baseline without + emitting a same-value dirty callback. In uncontrolled mode it updates internal + dirty state and baseline directly. Host apps can also reset dirty by changing + controlled dirty state from true to `dirty={false}`; that transition records the + resolved current diagram as the new clean baseline, matching `markClean`. In + controlled diagram mode, the resolved current diagram is the current `diagram` prop. In uncontrolled + mode, it is the provider's internal current diagram; `defaultDiagram` is read + only at initialization and is not re-read for reset. The provider tracks the + canonical result of provider-originated controlled edits after `onChange`; when + the host echoes that same diagram back through the controlled `diagram` prop, + the echo does not reset the clean baseline and dirty remains true unless the + host also controls `dirty={false}` or calls `markClean`. A controlled `diagram` + prop change that does not match a pending provider-originated edit is treated as + an external diagram replacement and becomes the new clean baseline unless the + host also supplies `dirty={true}`. No new reducer action is introduced for dirty + reset. +- `dispatch` and `dispatchMany` apply reducer actions first. If the resulting + diagram is referentially or structurally unchanged, they return the reducer + result without calling `onAction`, `onChange`, changing dirty state, or adding + history. +- For successful durable edits, callback order is `onAction`, then `onChange`, + then dirty-state update notification, then `onEvent` if an event is emitted + for the same user interaction. +- `dispatchMany` calls `onChange` once with `{ actions, result }` and calls + `onAction` for each input action in input order with the aggregate result. + Tests must document this ordering. +- Edge inspection emits existing `edge.click` with `intent: "inspect"` from + pointer and keyboard inspection paths when inspection is enabled. Workspace + edge-inspection state listens to that event, not to a generic graph event. +- Provider implementation must use internal helpers instead of concentrating all + controlled runtime logic inside `WireProvider.tsx`. The provider owns the + public context and callback wiring; internal helper modules own selection and + viewport equality, canonical diagram snapshots, dirty clean baselines, + provider-originated controlled edit echo detection, same-value no-op checks, + callback metadata construction, and `dispatchMany` transaction ordering. These + helpers are package-private and are not exported from `packages/wire-react/src/index.ts`. + +### Canvas + +```ts +export interface WireCanvasProps { + mode?: "view" | "edit"; + selectOnNodeClick?: boolean; + selectOnEdgeClick?: boolean; + inspectOnNodeClick?: boolean; + inspectOnEdgeClick?: boolean; + clearSelectionOnPaneClick?: boolean; + fitView?: boolean; + fitViewPadding?: number; + panOnDrag?: boolean; + zoomOnScroll?: boolean; + zoomStep?: number; + minZoom?: number; + maxZoom?: number; + showBackground?: boolean; + showControls?: boolean; + showMiniMap?: boolean; + readOnly?: boolean; + optionCatalog?: WireOptionCatalog; + renderNodeCard?: WireNodeRenderer; + renderGroup?: WireNodeRenderer; + renderEdge?: WireEdgeRenderer; + edgeStyle?: EdgeStyle; + edgeRouting?: EdgeRouting; + keyboardA11y?: boolean; + nodesFocusable?: boolean; + edgesFocusable?: boolean; + autoPanOnNodeFocus?: boolean; + colorMode?: "light" | "dark" | "system"; + unstyled?: boolean; + classNames?: { + root?: string; + viewport?: string; + background?: string; + node?: string; + group?: string; + edge?: string; + handle?: string; + controls?: string; + minimap?: string; + status?: string; + }; + ariaLabelConfig?: { + canvas?: string; + node?: (node: WireNode) => string; + edge?: (edge: WireEdgeRenderContext["edge"]) => string; + handle?: (context: { node: WireNode; side: Side; role: "source" | "target" }) => string; + search?: string; + connectionTarget?: string; + connectionSourceSide?: string; + connectionTargetSide?: string; + connectionSuccess?: (context: { + sourceNode: WireNode; + targetNode: WireNode; + sourceSide: Side; + targetSide: Side; + edgeId?: string; + }) => string; + minimap?: string; + validationStatus?: string; + controls?: { + zoomIn?: string; + zoomOut?: string; + fitView?: string; + fitSelection?: string; + }; + }; + isValidConnection?: (context: { + sourceNode: WireNode; + targetNode: WireNode; + sourceSide: Side; + targetSide: Side; + diagram: WireDiagram; + }) => boolean | string; + className?: string; + style?: React.CSSProperties; +} +``` + +Rules: + +- Keyboard behavior is scoped to the focused or active canvas root, not the + window. +- Defaults: `keyboardA11y`, `nodesFocusable`, `edgesFocusable`, and + `autoPanOnNodeFocus` default to `true` in edit mode and to focusable but + non-mutating behavior in view mode. +- When `keyboardA11y={false}`, the canvas does not install package keyboard + shortcuts, roving item traversal, search shortcut, or keyboard connection + picker. The labelled canvas root remains a sequential tab stop and ordinary + focusable host content inside custom renderers still follows normal browser + focus order, but package-managed node and edge shells receive no package + `tabIndex` and no roving active item exists. `nodesFocusable` and + `edgesFocusable` cannot re-enable managed shell focus while `keyboardA11y` is + false; pointer selection and inspection still work when their pointer props are + enabled. When `nodesFocusable={false}` or `edgesFocusable={false}` and + `keyboardA11y` is true, that item kind is omitted from the roving managed-item + set and keyboard traversal skips it; the item can still be selected by pointer + if pointer selection is enabled. When `autoPanOnNodeFocus={false}`, keyboard + focus can move to off-viewport managed items without automatically changing + viewport. Tests must cover each false prop separately and combined. +- `ariaLabelConfig` entries are optional overrides. Missing, empty, or + whitespace-only string overrides fall back to package defaults. Function + overrides that return empty or whitespace-only strings also fall back to + defaults. Defaults are deterministic and tested: canvas defaults to + `Wire diagram canvas`; node labels use node title, kind, and id fallback; edge + labels use explicit label or source/target ids; handle labels include node + label, side, and source/target role; search defaults to `Search diagram items`; + connection target defaults to `Choose connection target`; side controls default + to `Choose source side` and `Choose target side`; connection success announces + source label, target label, source side, target side, and edge id when available; + minimap defaults to `Diagram minimap`; validation status defaults to + `Validation status`; controls default to `Zoom in`, `Zoom out`, and `Fit view`. + Fit-selection control labels default to `Fit selection`. +- A canvas becomes active on focus within its root, pointer down inside its root, + or programmatic focus. It loses keyboard-command ownership on any focusout + where the next focused element is outside the canvas root, including host UI + and inspector UI, and on pointer interaction outside the root. Moving focus + into the inspector relinquishes canvas keyboard-command ownership until focus + returns to the canvas root. Text fields, controls, host controls, links, + buttons, selects, contenteditable regions, and inspector fields must not leak + keyboard actions to inactive canvases. +- Pointer down on a node or edge moves DOM focus to that managed shell before + selection/inspection side effects run when `keyboardA11y` is true and the item + kind is focusable. When `keyboardA11y` is false, pointer down on a node or edge + keeps package focus on the canvas root and does not create a managed shell + `tabIndex`; `nodesFocusable` and `edgesFocusable` cannot restore shell focus in + that mode. Pointer down on the pane focuses the canvas root. This keeps + subsequent root-scoped keyboard commands attached to the visible active canvas. + Tests cover pointer selection and inspection with `keyboardA11y={false}` and + both node/edge focus flags true. +- Canvas status messages use one package-owned status element inside the canvas + root with `role="status"` and polite live-region behavior. The element has a + stable id used by described-by relationships for connection rejection, + keyboard search, fit-selection, large-diagram mode, slow-render feedback, and + focus-recovery messages. Repeated identical announcements update a monotonic + status key so assistive technology receives the repeat without creating + multiple competing live regions. +- Focused nodes and edges use DOM roving focus with one active managed item. The + canvas root is a labelled `role="region"` entry point. Exactly one managed + node or edge shell has `tabIndex=0`, inactive managed shells have + `tabIndex=-1`, and keyboard handlers run from the focused shell or bubbled + events inside the canvas root. Custom renderers must leave the outer focusable + shell and `data-wire-*` attributes intact. +- Canvas keyboard handlers ignore events whose composed path contains native + interactive elements (`input`, `textarea`, `select`, `button`, `a[href]`, + `summary`), `[contenteditable]`, any enabled focusable descendant that is not + the managed node/edge shell itself, any descendant with an interactive ARIA + role, or an element marked `data-wire-keyboard="ignore"`. Interactive ARIA + roles include at least `button`, `link`, `menuitem`, `checkbox`, `radio`, + `switch`, `slider`, `spinbutton`, `combobox`, `listbox`, `textbox`, `searchbox`, + `tab`, `option`, `treeitem`, `gridcell`, `menu`, `menubar`, `tablist`, `grid`, + `tree`, and `dialog`. Custom node and edge renderers can use that data + attribute for non-focusable interactive regions without changing the outer + managed shell. +- If the active managed item is deleted, hidden, filtered out, or becomes + unfocusable, roving focus moves to the next item of the same kind in diagram + order, then the previous item of the same kind, then the first item of the other + kind. If no managed items remain, the canvas root becomes the sole `tabIndex=0` + entry point and the status region announces that no diagram items are focusable. +- Roving traversal is deterministic and separate from node movement: `n` moves + focus to the next node in diagram order, `p` moves to the previous node, `e` + moves to the next edge, Shift+E moves to the previous edge, Home/End move to + the first/last item in the active item kind, and `/` opens a canvas search box + that filters by node or edge title/id and moves roving focus to the chosen + result without mutating the diagram. +- Canvas search is labelled by `ariaLabelConfig.search` or the default + `Search diagram items` and owns its own composite result semantics while open. + The input uses a labelled `role="combobox"` pattern with `aria-expanded`, + `aria-controls` pointing at the result popup, a `role="listbox"` result popup, + `role="option"` results, `aria-selected` on the active result, and + `aria-activedescendant` only for the active search result inside that popup; the + canvas roving-focus model remains DOM focus based. A polite status announces + result count, active result label, and no-result states. ArrowDown/ArrowUp move + through results, Home/End move to the first/last result, Enter moves DOM roving + focus to the active result and closes search. If there are zero results, or if + the active result becomes stale while search is open, Enter leaves focus in the + search input, keeps the diagram unchanged, and announces that no result is + selected. Pointer result selection happens on pointer down before blur cleanup, + Escape closes search and restores focus to the previously focused canvas item, + blur closes search without changing focus selection, and canvas shortcuts are + suspended while the search input owns focus except for search navigation, + Escape, and Enter. +- Enter and Space select and inspect the focused node or edge when inspection is + enabled. Node inspection emits `node.inspect`; edge inspection emits existing + `edge.click` with `intent: "inspect"`. +- Shift+Enter moves focus to the inspector only inside `WireWorkspace`'s owned + canvas/inspector pair. Standalone `WireCanvas` leaves focus in place after + emitting the inspect event. Custom inspector owners handle focus from + `onEvent`. +- Alt+Shift+Enter is handled by the owned `WireWorkspace` inspector container, + not by standalone `WireCanvas`. It returns DOM focus to the currently inspected + or previously focused canvas shell through the shared workspace inspection and + canvas-focus helpers. If that item was deleted, hidden, filtered out, or became + unfocusable while inspector focus was active, the normal roving-focus recovery + order chooses the replacement target. If no managed item remains, focus returns + to the canvas root and the status region announces that no diagram items are + focusable. +- Arrow-key movement dispatches `node.move` in edit mode. If selected nodes + exist, it moves all selected nodes; otherwise it moves the focused node and + selects it first. Default nudge is 8 diagram units, Shift+Arrow is 32 units, + Alt+Arrow is 1 unit, and Ctrl/Meta do not change the default movement + contract. +- Delete and Backspace dispatch existing delete actions. +- Connection validation blocks dispatch and communicates rejection through + a live status region, `aria-invalid` where relevant, and described-by linkage + that is cleared when the rejection no longer applies. +- `isValidConnection` returns `true` to allow dispatch, `false` to reject with a + default message, or a string to reject with that message. Rejection state is + applied to the active handle/connection target and cleared on successful + connection, connection cancel, pointer leave from the target, Escape, or a new + connection attempt. +- Keyboard connection mode opens a labelled target picker instead of using + unstructured global typing. Pressing `c` on a focused node opens a target + picker with a combobox input labelled by `ariaLabelConfig.connectionTarget` or + the default `Choose connection target`, `aria-expanded`, `aria-controls`, a + listbox popup, option rows with `aria-selected`, and `aria-activedescendant` for + the active target. Typing filters eligible targets; ArrowDown/ArrowUp/Home/End + move the active target; side selection is exposed as labelled source-side and + target-side controls; Tab and Shift+Tab follow normal focus order through the + picker controls and never cycle candidates or dispatch connections. Enter + dispatches the existing connect action for the active target, closes the picker, + moves DOM focus to the new or existing edge shell when that shell is focusable + and otherwise to the source node shell, and announces the successful connection + through the status region. If the filtered target list is empty or the active + target becomes stale while the picker is open, Enter does not dispatch, leaves + focus in the target picker, keeps connection mode active, and announces that no + valid target is selected. Escape cancels and restores focus to the source node, + and focus leaving the picker cancels connection mode unless the next focus + target is the visible connection status or the source node. +- Side handles remain the only persisted handle model in this plan. +- `readOnly` disables canvas-originated durable mutations, including drag, + connect, keyboard move, delete, and style-affecting commands, while keeping + focus, selection, inspection, pan, zoom, and copyable text available. + +Additive render context fields: + +```ts +export interface WireNodeRenderContext { + focused?: boolean; + hovered?: boolean; + connecting?: boolean; + connectionRole?: "source" | "target" | null; + validationState?: "valid" | "warning" | "error" | "unknown"; +} + +export interface WireEdgeRenderContext { + focused?: boolean; + hovered?: boolean; + connectionPreview?: boolean; + validationState?: "valid" | "warning" | "error" | "unknown"; +} +``` + +### Wrappers + +```ts +export interface WireWorkspaceProps { + diagram?: WireDiagram; + defaultDiagram?: WireDiagram; + onChange?: (diagram: WireDiagram, event: WireChangeEvent) => void; + onAction?: (action: WireAction, result: ApplyWireActionResult) => void; + onEvent?: (event: WireEvent) => void; + validateOnChange?: boolean; + history?: boolean; + + selection?: WireSelection; + defaultSelection?: WireSelection; + onSelectionChange?: (selection: WireSelection, event: Extract) => void; + viewport?: WireViewport; + defaultViewport?: WireViewport; + onViewportChange?: (viewport: WireViewport, event: { + source: WireEventSource; + viewport: WireViewport; + previousViewport?: WireViewport; + cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api"; + intent?: "fit-view" | "fit-selection"; + }) => void; + mode?: WireMode; + defaultMode?: WireMode; + onModeChange?: (mode: WireMode, event: { + source: WireEventSource; + mode: WireMode; + previousMode?: WireMode; + cause?: "toolbar" | "keyboard" | "api"; + }) => void; + dirty?: boolean; + defaultDirty?: boolean; + onDirtyChange?: (dirty: boolean, event: { + source: WireEventSource; + dirty: boolean; + previousDirty?: boolean; + cause?: "edit" | "undo" | "redo" | "reset" | "api"; + }) => void; + + optionCatalog?: WireOptionCatalog; + readOnly?: boolean; + inspectNodeId?: string; + defaultInspectNodeId?: string; + onInspectNodeChange?: (nodeId: string | undefined, event: Extract) => void; + inspectEdgeId?: string; + defaultInspectEdgeId?: string; + onInspectEdgeChange?: (edgeId: string | undefined, event: Extract) => void; + clearInspectOnPaneClick?: boolean; + + title?: React.ReactNode; + subtitle?: React.ReactNode; + sidebar?: React.ReactNode; + inspector?: React.ReactNode; + showNodeList?: boolean; + showOptions?: boolean; + showValidation?: boolean; + layout?: "fixed" | "embedded"; + renderNodeCard?: WireNodeRenderer; + renderGroup?: WireNodeRenderer; + canvasProps?: Omit; + inspectorProps?: Omit; + colorMode?: "light" | "dark" | "system"; + unstyled?: boolean; + classNames?: { + root?: string; + header?: string; + sidebar?: string; + canvasRegion?: string; + canvas?: string; + inspector?: string; + nodeList?: string; + optionPanel?: string; + validationPanel?: string; + }; + className?: string; + sidebarClassName?: string; + canvasClassName?: string; + inspectorClassName?: string; + style?: React.CSSProperties; +} + +export interface WireEditorProps extends Omit { + diagram?: WireDiagram; + defaultDiagram?: WireDiagram; + onChange?: (diagram: WireDiagram, event: WireChangeEvent) => void; + onAction?: (action: WireAction, result: ApplyWireActionResult) => void; + onEvent?: (event: WireEvent) => void; + selection?: WireSelection; + defaultSelection?: WireSelection; + onSelectionChange?: (selection: WireSelection, event: Extract) => void; + viewport?: WireViewport; + defaultViewport?: WireViewport; + onViewportChange?: (viewport: WireViewport, event: { + source: WireEventSource; + viewport: WireViewport; + previousViewport?: WireViewport; + cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api"; + intent?: "fit-view" | "fit-selection"; + }) => void; + mode?: WireMode; + defaultMode?: WireMode; + onModeChange?: (mode: WireMode, event: { + source: WireEventSource; + mode: WireMode; + previousMode?: WireMode; + cause?: "toolbar" | "keyboard" | "api"; + }) => void; + dirty?: boolean; + defaultDirty?: boolean; + onDirtyChange?: (dirty: boolean, event: { + source: WireEventSource; + dirty: boolean; + previousDirty?: boolean; + cause?: "edit" | "undo" | "redo" | "reset" | "api"; + }) => void; +} + +export interface WireViewerProps extends Omit { + diagram: WireDiagram; + selection?: WireSelection; + defaultSelection?: WireSelection; + onSelectionChange?: (selection: WireSelection, event: Extract) => void; + viewport?: WireViewport; + defaultViewport?: WireViewport; + onViewportChange?: (viewport: WireViewport, event: { + source: WireEventSource; + viewport: WireViewport; + previousViewport?: WireViewport; + cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api"; + intent?: "fit-view" | "fit-selection"; + }) => void; + onEvent?: (event: WireEvent) => void; +} +``` + +Rules: + +- `WireWorkspace` passes provider props to `WireProvider`, option props to the + canvas and inspector, and inspection props to its owned inspector state. Its + built-in canvas and inspector are the only pair that participates in + Shift+Enter focus transfer. +- `WireWorkspace` treats inspected node and inspected edge as one runtime target. + A `node.inspect` event sets the inspected node and clears the inspected edge, + calling `onInspectNodeChange(nodeId, event)` and + `onInspectEdgeChange(undefined, event)`. An `edge.click` event with + `intent: "inspect"` sets the inspected edge and clears the inspected node, + calling `onInspectEdgeChange(edgeId, event)` and + `onInspectNodeChange(undefined, event)`. A pane click with + `clearInspectOnPaneClick` clears both. Controlled hosts must mirror both + callbacks; if both controlled ids are still supplied, `inspectNodeId` wins for + compatibility. +- Workspace, inspector, and canvas focus-transfer paths must share one internal + inspected-target resolver. That resolver applies the precedence once: + explicit inspector `nodeId`, explicit inspector `edgeId`, controlled or + uncontrolled workspace inspected node, controlled or uncontrolled workspace + inspected edge, single selected node, single selected edge, then empty or mixed + summary. `WireInspector`, `WireWorkspace`, and focus-return logic consume the + resolver result instead of each reimplementing precedence locally. +- `WireWorkspace readOnly` atomically locks the built-in canvas, inspector, + option panel, toolbar, palette, and validation-affecting controls. Custom + `inspector` content remains host-owned and must enforce its own lock. +- Top-level `WireWorkspace` props dominate nested props for owned surfaces: + `readOnly` cannot be weakened by `canvasProps.readOnly` or + `inspectorProps.readOnly`; provider runtime `mode`/`defaultMode`/`onModeChange` + on `WireWorkspace` own runtime mode state, and `canvasProps.mode` is omitted so + nested props cannot create a second mode source. If `mode` is supplied it is + authoritative; otherwise `defaultMode` initializes runtime mode; otherwise the + workspace defaults runtime mode to `edit`. Canvas render mode is `view` only + when the resolved provider mode is `view`; all other current provider modes + render the canvas in `edit` mode, with mutating commands still gated by + `readOnly` and the specific mode's command rules. `colorMode` and `classNames` + merge top-level workspace slots first and nested component slots second for + component-specific slots only. `unstyled` is monotonic: if `WireWorkspace` + receives `unstyled={true}`, owned child surfaces omit visual classes even when + nested props set `unstyled={false}`; nested `unstyled={true}` can opt out a child + when the workspace is styled. +- With new props omitted, `WireWorkspace` preserves the current visible defaults: + `title`, `subtitle`, `sidebar`, custom `inspector`, `showNodeList`, + `showOptions`, `showValidation`, `layout`, render callbacks, legacy class-name + props, and `style` keep their existing behavior. The built-in inspector can + share internals with `WireOptionPanel`, but `showOptions` and `showValidation` + must preserve legacy visibility semantics. +- `WireEditor` passes controlled provider props, including `mode`, + `defaultMode`, and `onModeChange`, into `WireProvider`. It defaults runtime mode + to `edit` when no mode props are supplied. Canvas render mode is `view` only + when the resolved provider mode is `view`; all other current provider modes + render the canvas in `edit` mode, with mutating commands still gated by + `readOnly` and the specific mode's command rules. `canvasProps.mode` is omitted + from `WireEditorProps` and cannot override the resolved provider mode. +- `WireViewer` passes view-safe runtime props into `WireProvider` with + `history={false}` and all canvas props into `WireCanvas` with `mode="view"`. + It does not enable mutating keyboard commands. + +### Styling + +Additive props by component: + +- `WireWorkspace`: `colorMode`, `unstyled`, and `classNames` slots `root`, + `header`, `sidebar`, `canvasRegion`, `canvas`, `inspector`, `nodeList`, + `optionPanel`, and `validationPanel`. +- `WireCanvas`: `colorMode`, `unstyled`, and `classNames` slots `root`, + `viewport`, `background`, `node`, `group`, `edge`, `handle`, `controls`, + `minimap`, and `status`. +- `WireInspector`: `colorMode`, `unstyled`, and `classNames` slots `root`, + `tabs`, `tab`, `panel`, `field`, `section`, `validation`, `json`, and + `edge`. +- `WireOptionPanel`: `unstyled` and `classNames` slots `root`, `field`, + `section`, and `validation`. +- `WireToolbar`: `unstyled` and `classNames` slots `root`, `group`, and + `button`. +- `WirePalette`: `unstyled` and `classNames` slots `root` and `item`. +- `WireNodeList`: `unstyled` and `classNames` slots `root`, `item`, and + `empty`. +- `WireValidationPanel`: `unstyled` and `classNames` slots `root`, `issue`, and + `empty`. +- `WireNodeCardView` and `WireGroupFrame`: `unstyled` and `classNames` slots + `root`, `content`, `badge`, `meta`, `progress`, and `footer`. +- Exported primitives keep their current `className`/`style` props. Package CSS + must cover them, but this plan does not add slot maps to primitives. + +Apply these only where the current component owns styling. Required `data-wire-*` +attributes, ARIA attributes, geometry, and interaction handlers must not be +removed by styling overrides. + +Slot class precedence: required package structural classes are applied first, +component `className` props are appended to the root slot, and `classNames` +entries are appended to their matching internal slot. `unstyled` removes visual +package classes but never removes structural classes, inline geometry, data +attributes, or ARIA attributes. Parent `unstyled={true}` is monotonic for owned +child surfaces and cannot be re-enabled by nested props. + +Class taxonomy: + +- Structural classes and `data-wire-*` attributes identify parts, preserve + geometry hooks, and stay present in all modes. Structural part classes use the + plain `.wire-` form, such as `.wire-workspace`, `.wire-canvas`, + `.wire-node`, `.wire-edge`, `.wire-inspector`, and `.wire-option-field`. + Package CSS may target structural classes only for required layout, geometry, + hit testing, and accessibility mechanics. +- Package visual classes opt into package CSS colors, spacing, borders, focus + rings, handles, controls, and panel styling. Visual classes use the paired + `.wire---styled` form or a `.wire-theme-*` class. Package CSS visual + rules must target these visual classes, not the structural class alone. +- Runtime state styling uses `data-wire-state-*` and existing ARIA attributes + rather than new public class names. Required selectors cover focused, hovered, + connecting, preview, validation severity, disabled, and read-only states; these + attributes remain present when `unstyled` is true so host CSS can target them. +- Retained legacy utility classes stay in default rendered markup for the first + non-major release so existing utility-class source-scanning consumers keep + current visuals. +- `unstyled={true}` omits package visual classes and retained legacy utility + classes for that component subtree, while preserving structural classes, + `data-wire-*` attributes, ARIA, inline geometry, and event handlers. The + legacy utility fixture covers default rendering, not `unstyled` rendering. +- `colorMode="system"` renders stable markup for server and client output and + relies on CSS media queries for the initial theme so hydration does not rewrite + the tree. Operating-system preference changes must update colors without + remounting components or losing focus. Forced-colors/high-contrast mode uses + system colors, preserves visible focus indicators, keeps handles and controls + distinguishable, and never conveys required state by color alone. +- `prefers-reduced-motion: reduce` disables or shortens nonessential transitions + for pan/zoom affordances, minimap viewport movement, drag previews, hover + emphasis, tab/field reveal effects, and status feedback while preserving + visible state changes, focus rings, live-region announcements, and layout + stability. Under reduced motion, named package transitions and animations on + workspace, canvas, node, edge, handle, controls, minimap, inspector tab, field, + and status slots must either be removed or have transition duration, animation + duration, and delay <= 50 ms, animation iteration count <= 1, and total active + animation time <= 50 ms. Tests assert computed duration, delay, iteration count, + and total active-time thresholds. + +## Phased Implementation Roadmap + +### Phase 0 - Contract Lock And Drift Cleanup + +Goal: ensure docs, tests, and package exports describe only current names and +planned in-place extensions. + +Work: + +- Audit `docs/REACT_COMPONENTS.md`, `docs/REACT_EDITOR_ARCHITECTURE.md`, and + `docs/WIRE_OPTIONS_CATALOG_SPEC.md`. +- Mark proposed-only behavior as planned unless it ships in the same phase. +- Add type/export guards for existing root exports. +- Keep catalog flow on `WireWorkspace`, `WireCanvas`, `WireInspector`, and + `WireOptionPanel`; do not add provider catalog state. + +Acceptance: + +- Docs no longer imply provider-level catalog ownership. +- Docs no longer imply `WireInspector` renders catalog fields until the + inspector phase ships. +- Export guard tests pass. + +### Phase 1 - Package CSS Export And Theme Contract + +Goal: make `@aigentive/wire-react` usable from npm without utility-class source +scanning. + +Work: + +- Add `packages/wire-react/src/styles.css`. +- Add paired structural and visual package classes such as `.wire-workspace` plus + `.wire-workspace--styled`, `.wire-canvas` plus `.wire-canvas--styled`, + `.wire-node` plus `.wire-node--styled`, `.wire-inspector` plus + `.wire-inspector--styled`, and `.wire-option-field` plus + `.wire-option-field--styled`, while keeping existing utility classes in markup + for the first non-major release. Do not require consumer utility-class source + scanning for new consumers, but do not break existing consumers that followed + the previous scanning guidance. Removing legacy utility classes is deferred + outside this non-major plan. +- Add a package-local build helper, for example + `packages/wire-react/scripts/build.mjs`, that removes `dist` with `fs.rm`, + runs `tsc`, and copies the authored CSS to + `packages/wire-react/dist/styles.css`. Do not depend on POSIX-only `rm` or + `cp` behavior inside package scripts. +- Add `./styles.css` to `packages/wire-react/package.json` exports. +- Ensure package `files` includes the emitted stylesheet. +- Move reusable Wire CSS variables and component classes into package CSS. +- Keep playground-only layout and marketing styles in the playground app. +- Add `colorMode`, `unstyled`, and `classNames` to current components where + needed. +- Add the packed CSS-only consumer fixture and the legacy utility-scanning + consumer fixture in this phase so CSS compatibility can be closed before later + documentation and release-gate wiring. +- Update `packages/wire-react/README.md`, `docs/REACT_COMPONENTS.md`, and + install docs to import `@aigentive/wire-react/styles.css`. + +Acceptance: + +- `npm run build --workspace @aigentive/wire-react` emits `dist/styles.css`. +- `npm pack --workspace @aigentive/wire-react --dry-run` includes the CSS file. +- A clean build removes stale `dist` before emitting JS, declarations, and CSS. +- A minimal React consumer can render `WireWorkspace`, `WireCanvas`, + `WireInspector`, `WireOptionPanel`, `WireEditor`, `WireViewer`, + `WireToolbar`, `WirePalette`, `WireNodeList`, `WireValidationPanel`, and the + exported primitives with only the package CSS import. +- The packed CSS-only consumer fixture must assert the same concrete computed + style gates as the legacy utility fixture: non-transparent backgrounds for + authored surfaces, visible text and icon colors, normal-text contrast of at + least 4.5:1, large/icon-state contrast of at least 3:1, visible focus outline + or focus ring, nonzero layout dimensions for every rendered surface, node-card + padding and minimum hit area, handles at least 8 px by 8 px, controls at least + 32 px by 32 px, and no text/control overlap in smoke screenshots. +- A legacy utility-scanning consumer fixture can render the same current + surfaces without the new package CSS import and retain baseline computed + styles. Pass/fail assertions must cover non-transparent backgrounds for + authored surfaces, visible text and icon colors, normal-text contrast of at + least 4.5:1, large/icon-state contrast of at least 3:1, visible focus outline + or focus ring, nonzero layout dimensions for every rendered surface, node-card + padding and minimum hit area, handles at least 8 px by 8 px, controls at least + 32 px by 32 px, and no text/control overlap in smoke screenshots. If this gate + cannot pass, this non-major plan stops until compatibility is preserved or a + separate breaking-release plan is approved. +- Existing playground pages still render. +- `unstyled` keeps interaction and data attributes intact. +- Computed-style smoke tests cover inspector, option fields, focus states, + handles, controls, theme selectors, every exported component, and every + exported primitive. +- Theme smoke tests cover light, dark, system preference changes, server/client + hydration stability for system mode, and forced-colors/high-contrast rendering. + +### Phase 2 - Controlled Runtime State + +Goal: allow product apps to own runtime editor state without changing persisted +diagram state. + +Work: + +- Extend `WireProviderProps` with controlled and default selection, viewport, + mode, and dirty props. +- Pass these props through `WireWorkspace`, `WireEditor`, and view-safe runtime + props through `WireViewer` exactly as defined in the wrapper shapes. +- Preserve current hook names and return shapes. +- Extend current context action methods with optional event metadata while + preserving one-argument calls. +- Use provider-owned setters for selection, viewport, and mode so callbacks fire + from one place instead of duplicating canvas event logic. +- Add callback ordering tests for selection, viewport, mode, and dirty. +- Document that controlled state mirrors runtime editor state only. + +Acceptance: + +- Controlled and uncontrolled modes both work. +- Controlled callbacks fire exactly once per logical change. +- Controlled selection callbacks receive the matching `WireEvent` variant. + Controlled viewport, mode, and dirty callbacks receive their inline metadata + objects. All controlled callbacks preserve `source`, `cause`, previous state, + and next state as defined in their metadata shape. +- Same-value controlled runtime updates do not fire callbacks or events. +- Reducer exceptions propagate without state updates, dirty updates, history + writes, `onAction`, `onChange`, or `onEvent`. +- `dispatchMany` remains one transaction for undo/redo. +- `dispatchMany` calls `onChange` once and `onAction` once per input action in + order. +- For successful edits, `onAction`, `onChange`, dirty notification, and + interaction events follow the ordering defined in the provider rules. +- Dirty can be controlled, initialized, set true after edits, and reset by host + apps. +- `WireWorkspace`, `WireEditor`, and `WireViewer` pass-through behavior is + covered by prop-forwarding tests. + +### Phase 2.5 - Performance Harness Baseline + +Goal: put measurable render and interaction instrumentation in place before +inspector and canvas expansion. + +Work: + +- Add deterministic fixture builders for 500-node/600-edge, + 1,000-node/1,200-edge, and 2,000-node/2,400-edge diagrams. +- Add the production-build browser harness, warmup/measured-run protocol, + artifact capture, and current-baseline reporting. +- Add the root `test:performance` script in this phase so the baseline gate can + run before inspector and canvas expansion. +- Record advisory current-baseline numbers before Phase 3 and Phase 4 changes + land. These advisory numbers do not block Phase 2.5 unless the harness cannot + run, but later phases must compare against them when they change inspector, + canvas, focus, search, minimap, or edge rendering paths. +- Add the large-diagram mode detector using the documented node and edge count + thresholds, but keep release-blocking UX and timing thresholds in Phase 5. + +Acceptance: + +- `npm run test:performance` can run against a production React build and emits + median, p95, raw samples, and fixture metadata artifacts. +- Fixture generation is deterministic from checked-in seeds. +- Baseline artifacts are committed for Phase 3 and Phase 4 comparison. The + machine-readable baseline JSON is committed to the repo; review attachments + can supplement it with raw browser traces or screenshots. +- Baseline JSON is append-only by fixture and interaction key. Each entry records + fixture name, node count, edge count, interaction name, path owner, commit SHA, + schema version, CI runner identity, browser engine/version, sample count, + median, p95, large-diagram mode flag, and whether the path is advisory or + release-blocking. New-path baselines add entries without replacing existing + Phase 2.5 entries. +- The 2,000-node/2,400-edge fallback fixture can render, focus the canvas root, + run existing selection and viewport smoke interactions, and report whether the + large-diagram mode detector activated. Search, keyboard roving traversal, and + keyboard connection picker checks are added later when those paths ship. + +### Phase 3 - Inspector And Options Convergence + +Goal: make the inspector the cohesive production surface for node configuration, +style, validation, JSON, and edge details. + +Work: + +- Implement Phase 3 as mergeable slices with separate tests and rollback + boundaries: +- Slice 3.1: extend `WireOptionSpec` in place with grouping, ordering, + visibility, validation, read-only/disabled, and commit behavior while keeping + the current five input types. Keep `WireOptionCatalog` as the only catalog + name. +- Slice 3.2: add shared option field rendering helpers used by both + `WireOptionPanel` and `WireInspector`; prove `WireOptionPanel` compatibility + before wiring the inspector. +- Slice 3.3: establish `inspectionState.ts` resolver ownership for explicit + node inspection and single-selection fallback, then extend `WireInspectorProps` + with `nodeId`, `optionCatalog`, tabs, field/section renderers, and option + commit callback; preserve current title, description, and appearance controls; + add Configure, Style, Validation, and read-only JSON tab behavior. +- Slice 3.4: add explicit `edgeId` inspection, primitive Edge tab editing, and + synthesized-edge lookup by extending the shared inspected-target resolver. Edge + fields patch through current reducer actions only. +- Slice 3.5: update `WireWorkspace` to share field-rendering and + inspected-target resolver behavior while preserving custom `inspector`, + `showOptions`, and `showValidation` semantics. Add `inspectEdgeId`, + `defaultInspectEdgeId`, and `onInspectEdgeChange` to `WireWorkspaceProps`. + `inspectNodeId` wins over edge inspection when explicitly provided. +- Slice 3.6: add `inspectOnEdgeClick` to `WireCanvasProps`; it emits existing + `edge.click` with `intent: "inspect"`, uses workspace state, and does not add a + provider catalog or external graph state. +- Document the deferred field-type queue for after the initial production + release: `multiSelect`, `json`, `code`, `color`, `date`, `url`, `secret`, + `array`, `object`, and `custom`. Do not emit a literal in + `WireOptionInputType` until that literal's complete behavior ships with + keyboard, ARIA, parsing, formatting, validation, serialization, and tests. +- Phase 3 covers pointer edge inspection and explicit `edgeId` inspection. + Keyboard edge inspection lands in Phase 4 with focusable edge shells and the + root-scoped keyboard contract. +- Keep `WireOptionPanel` as a compatibility primitive. +- Add or update the legacy runtime fixture in this phase so old default provider, + workspace, canvas, inspector, and option panel flows with new props omitted can + be closed before release wiring. + +Acceptance: + +- Each Phase 3 slice can land with its own focused tests while keeping the public + package build green; later slices must not rewrite completed earlier slices + except through reviewed helper contracts. +- Slices 3.3, 3.4, 3.5, and 3.6 must run `npm run test:performance` and compare + affected inspector render, workspace inspector render, edge inspection, and + canvas edge-click paths against the committed Phase 2.5 baseline before + landing. If a path is new and has no Phase 2.5 measurement, the slice records + and commits the first baseline in the same baseline artifact format and Phase 5 + turns that path into a threshold. +- Existing simple catalogs render unchanged. +- `WireInspector nodeId` works without selection. +- `WireWorkspace optionCatalog` drives canvas render context and inspector + fields. +- Inspection precedence is explicit: explicit `nodeId`, explicit `edgeId`, + controlled workspace inspected node, controlled workspace inspected edge, + single selected node, single selected edge, empty or mixed summary. +- If both explicit `nodeId` and explicit `edgeId` are supplied to + `WireInspector`, `nodeId` wins and edge-only controls are unavailable for that + render. +- Configure fields patch through `node.patch`. +- Edge fields patch through `edge.patch`. +- Edge inspection exposes editable `label`, `tone`, and `routing` in Phase 3A. + `style`, `labelStyle`, `data`, endpoint fields, branch, and handles are + read-only until their complete accessible editor support ships. +- Stale or deleted `edgeId` renders a non-mutating empty state. +- Edge inspection is driven by `edge.click` with `intent: "inspect"` for pointer + paths in Phase 3 and keyboard paths in Phase 4. +- Inspector `readOnly` prevents all title, description, Configure, Style, JSON, + Edge, and custom field mutations. +- The JSON tab is read-only in Phase 3A and never mutates the diagram. +- Inspector tabs satisfy keyboard and ARIA tablist behavior, including roving + tab stop, automatic activation, and applicable-tab fallback. +- Edge-only inspection opens the Edge tab by default and resets the active tab + when the inspected edge changes. +- Option fields and sections satisfy label/id, description, validation, + required, invalid, and grouping semantics in both `WireInspector` and + `WireOptionPanel`. +- Text input can use blur, submit, or debounce commit behavior to avoid noisy + undo history. +- Submit-mode option fields have Apply/Revert controls, defined Enter/Escape + behavior, pending-value cleanup on selection change or unmount, and unsaved + change announcements. +- With new props omitted, `WireWorkspace` keeps legacy visible defaults: + `showOptions` still governs the option/style area, `showValidation` still + governs validation visibility, custom `inspector` still overrides the built-in + inspector area, and the legacy runtime fixture must pass. + +### Phase 4 - Canvas Accessibility And Interaction Hardening + +Goal: make canvas keyboard and screen-reader behavior an explicit package +contract while retaining current pointer workflows. + +Work: + +- Implement Phase 4 as mergeable slices with helper ownership: +- Slice 4.1: move keyboard ownership from window scope to the active canvas root, + add core accessible label defaults and fallback helpers for canvas, nodes, + edges, handles, and base controls, add focusable node and edge shells, and keep + pointer workflows unchanged. The minimal active-item and `tabIndex` resolver is + created in `packages/wire-react/src/canvas/focus.ts` in this slice and extended + in Slice 4.2. +- Slice 4.2: add deterministic roving focus, focus recovery, and + inspector-to-canvas focus return through internal canvas-focus helpers. +- Slice 4.3: add keyboard select, inspect, clear, delete, movement, and command + guards through internal keyboard-command helpers. +- Slice 4.4: add minimap labels, side-control labels, connection success + announcements, and canvas status/live-region helpers. Search and connection + picker labels are implemented and tested in the slices that introduce those + widgets. +- Slice 4.5: add canvas search with labelled combobox/listbox semantics and + root-scoped shortcut suspension while search owns focus. +- Slice 4.6: add keyboard connection target picker, connection validation + feedback, retry/cancel behavior, and target-handle fallback fixes so preview + and dispatch use the correct target side and point. +- Slice 4.7: extend render contexts with focus, hover, connection, and validation + state as additive fields, then run performance comparison against the Phase 2.5 + baseline. +- Add visible focus styles through package CSS. +- Add connection validation callback on current `WireCanvasProps`. +- `WireCanvas.tsx` orchestrates these helpers, but focus ownership, keyboard + command guards, search state, connection picker state, label construction, and + status messages must live in package-private modules with focused tests. + +Acceptance: + +- Each Phase 4 slice can land independently with targeted tests and must compare + affected render or interaction paths against the Phase 2.5 baseline before the + next slice starts. If a path did not exist in Phase 2.5, such as canvas search + or keyboard connection picker, the slice that introduces it records the first + measured baseline in the same artifact format, commits that baseline JSON under + the shared performance baseline path, and Phase 5 turns that path into a + release-blocking threshold. +- Slice 4.1 acceptance includes a non-empty accessible name for every canvas + root, node shell, edge shell, handle, or base control that can receive focus or + `tabIndex=0`; focusable shells must not land before their label helper + fallback tests pass. If Slice 4.1 lands before deterministic roving traversal + in Slice 4.2, it must still preserve the one-managed-tab-stop invariant: + exactly one managed shell has `tabIndex=0`, inactive managed shells have + `tabIndex=-1`, and the active shell is chosen deterministically from selection + first, then diagram order. +- Enter and Space select focused nodes/edges and emit the matching inspection + event when inspection is enabled: `node.inspect` for nodes and `edge.click` + with `intent: "inspect"` for edges. Shift+Enter moves focus to the active tab + in the owning `WireWorkspace` inspector. Alt+Shift+Enter moves focus back to + the active canvas item. Shift+Enter focus target resolution is ordered: active + tab, first focusable control in the active panel, active panel heading, + empty-state heading, then inspector root. Empty or all-inapplicable tab states + therefore remain valid focus-transfer targets without fields. +- Focus transfer into the inspector releases canvas keyboard-command ownership + until focus returns to the canvas root. +- Escape clears selection or exits connection mode. +- Arrow keys move selected nodes in edit mode by 8 diagram units, Shift+Arrow by + 32 units, and Alt+Arrow by 1 unit. If no nodes are selected, the focused node + is selected and moved. Edge focus never triggers node movement. +- Keyboard connection creation uses the current side-handle model. Pressing `c` + on a focused node enters keyboard connection mode with the default source side + and moves focus to the labelled target picker. Typing filters eligible target + nodes by title or id, ArrowDown/ArrowUp/Home/End move through the filtered + result set ordered by distance from the source then diagram order, labelled + side controls choose source/target sides, the status region announces result + count and active target position, Enter dispatches the existing connect action + only when the active target is still valid, closes the picker, moves focus to + the new or existing edge shell when focusable and otherwise to the source node + shell, and announces success. If there is no active valid target, Enter keeps + focus in the picker, keeps connection mode active, and announces that no valid + target is selected. Escape cancels and restores focus to the source node, Tab + follows normal focus order within the picker controls, and focus leaving the + picker cancels connection mode unless focus returns to the source node or status + region. + Keyboard reconnect for existing edges is deferred until endpoint preservation, + explicit/synthesized edge behavior, and undo transaction shape are specified. +- Delete and Backspace remove selected items only when the canvas owns focus and + editing is enabled. +- Any focusout from the canvas root to host UI, inspector UI, or document body + releases canvas keyboard-command ownership. Text fields, interactive custom + renderer controls, and elements marked `data-wire-keyboard="ignore"` never + trigger canvas keyboard actions. +- Interactive descendants in custom node and edge renderers, including generic + focusable descendants and ARIA widget roles, never trigger canvas Enter, Space, + Arrow, Delete, Backspace, search, or connection shortcuts while they own focus. +- Missing, empty, and whitespace-only `ariaLabelConfig` overrides fall back to + deterministic defaults for every labelled canvas control and announcement. +- Multiple canvases do not compete for keyboard actions. +- Read-only mode remains non-mutating. +- Connection rejections are announced and cleared through accessible status + semantics, with `false` using a default message and string returns using that + string as the message, for both pointer and keyboard connection workflows. + Feedback must be visible near the canvas connection target and mirrored in the + live region; a visually hidden status-only implementation is not sufficient. +- After a connection rejection, focus remains on the source handle or keyboard + connection target, connection mode stays active for retry, and the user can + choose another target or press Escape to cancel. Repeating the same rejected + target re-announces the message by updating a monotonically increasing status + key so assistive technology receives the repeat. +- Canvas search has accessible combobox/listbox semantics, a visible and + programmatic label, `aria-expanded`, `aria-controls`, active descendant, + selected-option state, no-results announcement, pointer result selection before + blur cleanup, deterministic result order, Enter-to-focus, Escape and blur + cleanup, focus restoration to the prior canvas item, and shortcut suppression + while the search field owns focus. + +### Phase 5 - Performance Baseline And Render Stability + +Goal: set measurable performance expectations before adding heavier public +extension APIs. + +Work: + +- Extend the Phase 2.5 large-diagram fixtures and committed baseline metrics for + drag preview, minimap, focus recovery, search, connection picker, and + fit-selection; do not replace the Phase 2.5 fixture seeds or baseline artifact. +- Memoize stable node/edge render work where safe. +- Avoid full layout work during transient drag overlays where possible. +- Reduce measurement churn from observing every node on every diagram update. +- Document render/update budgets with numeric thresholds. +- Add large-diagram UX checks for orientation, focus recovery, minimap + usefulness, and slow-render feedback. +- Extend the existing 2,000-node / 2,400-edge fallback fixture to validate the + documented fallback activation policy and keyboard/search/navigation usability + above the measured budget fixtures. +- Add a package-owned Fit selection control to the existing canvas controls when + selection is non-empty. Keyboard users reach it through normal focus order; it + does not add a global shortcut. Activating it fits selected items with the + current `fitViewPadding` value, or the internal `DEFAULT_FIT_VIEW_PADDING = 0.2` + constant when that prop is omitted. The same constant must be used by Fit view + and Fit selection. Tests must cover omitted `fitViewPadding`, explicit + `fitViewPadding`, and shared Fit view/Fit selection default behavior. The + control preserves DOM focus on the active selected item when it remains visible, + otherwise moves focus to the first selected focusable item in diagram order, + and announces the fitted item count in the status region. +- Define large-diagram mode as an internal render mode that activates when node + count exceeds 1,000 or edge count exceeds 1,200. In that mode the canvas keeps + node and edge rendering, selection, roving focus, search, visible selection, + connection cancellation, and fit-selection behavior; package CSS disables + nonessential motion, nonessential measurement work is deferred, and minimap + rendering is simplified to viewport bounds plus selection bounds. The status + region announces large-diagram mode once per diagram load and again when the + mode changes. +- Defer viewport culling until measured budgets prove it is needed. + +Acceptance: + +- Large fixture budgets are documented for at least 500 nodes / 600 edges and + 1,000 nodes / 1,200 edges. +- Metrics include initial render time, selection update time, viewport update + time, drag-preview frame budget, minimap on/off cost, roving focus traversal, + search/filter latency, focus recovery, and auto-pan-on-focus timing. +- Benchmarks run against a production React build, not development mode, with + three warmup runs and seven measured runs. Report median and p95. +- Initial release thresholds: + + | Fixture | Initial render median | Selection p95 | Viewport p95 | Drag p95 | Minimap p95 | Focus/search p95 | + |---|---:|---:|---:|---:|---:|---:| + | 500 nodes / 600 edges | <= 1,500 ms | <= 80 ms | <= 80 ms | <= 24 ms | <= 250 ms | <= 80 ms | + | 1,000 nodes / 1,200 edges | <= 3,000 ms | <= 140 ms | <= 120 ms | <= 32 ms | <= 450 ms | <= 140 ms | + +- CI allows at most 20% variance over the threshold before failing. Threshold + changes require benchmark evidence in the implementation review. +- Large-diagram UX remains usable: fit-to-selection works for keyboard and + pointer selection, auto-pan keeps focused nodes visible without stealing + focus, minimap viewport reflects pan/zoom accurately at both fixture sizes, + the canvas exposes a skip-to-inspector/control path, roving focus prevents + thousands of Tab stops, and slow initial render shows non-blocking progress + feedback after 250 ms with `aria-busy` on the canvas region and a polite + `role="status"` announcement. +- Large-diagram UX checks are release-blocking: fit-to-selection must reveal + every selected item with the configured padding, focus recovery must restore + the active item after pan/zoom/search within the focus/search p95 budget, + minimap viewport error must stay within 2 px of scaled viewport bounds, the + skip control must be the next Tab stop after the canvas root with label + "Skip to inspector and controls", and slow-render feedback must appear by + 250 ms and clear when rendering completes. +- If timing budgets fail, the implementation must either improve the measured + path or ship a documented fallback such as disabling minimap by default above + the failing size. Any fallback for diagrams larger than the measured fixtures + activates deterministically when node count exceeds 1,000 or edge count exceeds + 1,200, and must be verified against the 2,000-node / 2,400-edge fallback + fixture. The fallback must preserve keyboard selection, roving focus, search, + skip-to-inspector, visible selection, connection cancellation, and non-mutating + slow-render feedback; it must use the large-diagram mode behavior above and + may not remove fit-selection or inspector focus transfer. Do not add viewport + culling as a public contract until this evidence exists. +- Drag preview does not relayout the full diagram unnecessarily. +- Selection and viewport updates have explicit pass/fail thresholds recorded in + tests or benchmark snapshots. +- Performance fixtures are generated deterministically from a checked-in seed + and run in the repo browser automation harness against the current CI runtime + and a production build. The harness uses browser performance marks around + scripted interactions, records raw samples as artifacts, and fails only after + the warmup and measured-run rules above. + +### Phase 6 - Docs, Examples, And Release Readiness + +Goal: make the implementation adoptable and releasable. + +Work: + +- Update `docs/REACT_COMPONENTS.md` as the current props reference. +- Update `docs/REACT_EDITOR_ARCHITECTURE.md` with controlled runtime state and + inspector behavior. +- Update `docs/WIRE_OPTIONS_CATALOG_SPEC.md` to match implemented inspector + behavior. +- Add package README examples for CSS import, controlled editor, custom shell, + inspector options, edge inspection, read-only viewer, theming, and keyboard + behavior. +- Add playground docs examples for options, controlled state, custom shell, + package CSS, and accessibility. +- Update root `README.md`, machine-readable docs under `apps/playground/app/llm`, + and `packages/wire-mcp/src/docs-shape.ts` if they describe React package + usage. +- Remove stale or missing route references. +- Add a docs link checker, markdown snippet typecheck, packed-consumer smoke, + publish dry-run, version bump, and lockfile/workspace dependency sync to the + release checklist. The docs checker must also reject external inspiration + source names, external inspiration package names, URLs, and documentation paths + in React package docs, examples, and implementation plans. +- Ensure root `package.json` scripts exist for the release gates introduced here; + `test:performance` is added in Phase 2.5 and re-used by this release wiring: + `test:consumer-pack`, `test:api-compat`, `test:docs-links`, + `test:docs-source-names`, `test:legacy-runtime`, `test:docs-snippets`, + `test:persistence`, `test:performance`, `test:release-notes`, + `test:legacy-utility-consumer`, `check:semver`, `check:registry-version`, + `check:workspace-ranges`, `check:lockfile`, `check:api-diff`, and + `release:wire-react:dry-run`. +- Add `scripts/check-docs-source-names.mjs` and + `scripts/docs-source-name-denylist.json`. The script scans `README.md`, + `docs/**/*.md`, `packages/wire-react/README.md`, + `packages/wire-react/src/**/*.{ts,tsx,md,mdx}`, + `apps/playground/app/**/*.{md,mdx,ts,tsx}`, and + `packages/wire-mcp/src/docs-shape.ts`. It excludes dependency folders, + package output folders, coverage output, local framework output, local browser + test output, and static preview output by category; exact exclusion tokens live + only in the checked-in script and its tests, not in this plan. Files marked + with `@generated` are excluded only when they are not checked-in public docs + surfaces; checked-in generated public docs must be scanned directly, or their + checked-in source template must be scanned in the same run. The script checks + file contents and relative file paths, normalizes path separators, lowercases + text, strips punctuation boundaries for token matching, rejects external URL + patterns, and checks case-insensitive matches from the checked-in denylist. The + denylist is the source of truth for external inspiration source names, external + inspiration package names, and external documentation paths, and the plan must + not print those blocked tokens. +- Wire the Phase 1 packed-consumer fixture under + `tests/fixtures/wire-react-consumer` into root scripts and CI, and add API + compatibility fixtures under `tests/fixtures/wire-react-api-compat`. +- Wire the Phase 3 legacy runtime fixture under + `tests/fixtures/wire-react-legacy` into root scripts and CI to render old + default behavior with all new props omitted. +- Wire the Phase 1 legacy utility-scanning consumer fixture under + `tests/fixtures/wire-react-utility-legacy`. +- Add historical persisted diagram fixtures under + `tests/fixtures/wire-diagrams-historical`. +- Keep `.github/workflows/ci.yml` and `.github/workflows/release.yml` aligned + with the new gates before publishing. + +Acceptance: + +- Every public API change has a runnable example. +- Example coverage is feature-specific: CSS export, controlled runtime state, + inspector options, edge inspection, canvas keyboard accessibility, theming, + `WireEditor`, `WireViewer`, custom renderers, and read-only mode each have at + least one runnable package README or playground example. +- Docs never describe unimplemented APIs as current. +- Root README, package README, markdown docs, playground docs, and + machine-readable docs agree. +- Stale route and broken internal link checks pass. +- `npm run test:docs-source-names` rejects external inspiration source names, + external inspiration package names, URLs, and documentation paths in React + package docs, examples, and implementation plans. +- Markdown code snippets compile or are explicitly marked non-runnable. +- Package release can be validated with the documented command set. +- The packed-consumer fixture covers `WireEditor`, `WireViewer`, all current + package components, and exported primitives. +- The packed-consumer fixture runs install, typecheck, and production build + against the packed tarball and `@aigentive/wire-react/styles.css`. +- The legacy runtime fixture proves omitted new props preserve old default + behavior for provider, workspace, canvas, inspector, and option panel flows. +- Registry version uniqueness is checked before publish. + +## File-By-File Implementation Notes + +| Path | Notes | +|---|---| +| `package.json` | Add root release-gate scripts for packed consumer, API compatibility, legacy runtime, docs links, docs source-name guard, docs snippets, persistence, performance, semver, registry uniqueness, workspace ranges, lockfile sync, API diff, and release dry-run; `test:performance` is owned by Phase 2.5 and remains part of PR CI and release dry-run. | +| `packages/wire-react/package.json` | Add `./styles.css` export, include CSS in files, add CSS side-effect metadata so bundlers preserve the stylesheet import, add a `clean` script, and update the build script to call the package-local build helper. | +| `packages/wire-react/scripts/build.mjs` | Cross-platform clean/build/copy helper using runtime filesystem APIs for `dist` and CSS emission. | +| `packages/wire-react/src/styles.css` | New package-owned CSS variables, theme selectors, focus styles, controls, minimap, inspector, option fields, cards, handles. | +| `packages/wire-react/src/index.ts` | Preserve the current root export names exactly. Do not add new root export names; the only new export surface is the `./styles.css` subpath. | +| `packages/wire-react/src/options.ts` | Keep `WireOptionInputType` to current literals in Phase 3A; extend `WireOptionSpec`, helpers, predicate evaluation, validation, grouping, field ordering, and commit helpers. | +| `packages/wire-react/src/components/optionFields.tsx` | Package-private shared field renderer used by `WireOptionPanel` and `WireInspector`; owns wrapper semantics, labels, descriptions, errors, disabled/read-only behavior, custom renderer hosting, pending values, and commit modes. | +| `packages/wire-react/src/components/inspectionState.ts` | Package-private inspected-target resolver, synthesized-edge lookup, stale target handling, and workspace/inspector precedence rules. | +| `packages/wire-react/src/components/WireOptionPanel.tsx` | Refactor field rendering into shared helpers; preserve current props and behavior. Add optional renderers and commit behavior. | +| `packages/wire-react/src/components/WireInspector.tsx` | Add `nodeId`, `edgeId`, `optionCatalog`, tabs, Configure/Style/Validation/JSON/Edge views, field renderers, and edge patch controls. | +| `packages/wire-react/src/components/WireWorkspace.tsx` | Pass controlled runtime props to provider; share improved inspector internals while preserving current default visibility and custom inspector override; define node/edge inspection precedence. | +| `packages/wire-react/src/components/WireEditor.tsx` | Pass through controlled runtime props where the component wraps provider/workspace behavior. | +| `packages/wire-react/src/components/WireViewer.tsx` | Accept package styling props and keep view-only behavior non-mutating. | +| `packages/wire-react/src/components/WireToolbar.tsx` | Replace utility-only styling with package classes while preserving current props and command behavior. | +| `packages/wire-react/src/components/WirePalette.tsx` | Replace utility-only styling with package classes and keep drag/add behavior unchanged. | +| `packages/wire-react/src/components/WireNodeList.tsx` | Replace utility-only styling with package classes and preserve selection/render-item behavior. | +| `packages/wire-react/src/components/WireValidationPanel.tsx` | Replace utility-only styling with package classes and preserve validation rendering. | +| `packages/wire-react/src/primitives/*` | Ensure package CSS covers current primitives without changing their exports. | +| `packages/wire-react/src/canvas/WireCanvas.tsx` | Orchestrate package-private canvas helpers; add root-scoped keyboard handling, focusable nodes/edges, ARIA labels, connection validation, focus styles, and target-handle fix without concentrating all behavior in this file. | +| `packages/wire-react/src/canvas/focus.ts` | Package-private roving focus, active canvas ownership, focus recovery, auto-pan-on-focus, inspector return target resolution, and large-diagram focus behavior. | +| `packages/wire-react/src/canvas/keyboard.ts` | Package-private keyboard command mapping, composed-path guards, edit/read-only gating, movement/delete behavior, and shortcut suppression while nested widgets own focus. | +| `packages/wire-react/src/canvas/accessibility.ts` | Package-private ARIA label defaults, override fallback handling, status/live-region message construction, reduced-motion hooks, and forced-colors state coverage. | +| `packages/wire-react/src/canvas/search.tsx` | Package-private canvas search input, result ordering, combobox/listbox semantics, stale-result behavior, focus restoration, and status announcements. | +| `packages/wire-react/src/canvas/connectionPicker.tsx` | Package-private keyboard connection picker, side controls, validation rejection state, retry/cancel behavior, success focus, and connection announcements. | +| `packages/wire-react/src/canvas/performance.ts` | Package-private fixture-size detection, large-diagram mode flags, nonessential measurement deferral, and minimap simplification switches. | +| `packages/wire-react/src/canvas/nodeTypes.tsx` | Extend render context fields without replacing current render callbacks. | +| `packages/wire-react/src/canvas/geometry.ts` | Keep side-handle behavior; support performance baseline changes without schema expansion. | +| `packages/wire-react/src/provider/types.ts` | Add controlled runtime state props, callback types, and optional metadata fields on existing `WireEvent` variants and current action methods; do not add new `WireEvent["type"]` or `WireEventSource` literals. | +| `packages/wire-react/src/provider/context.ts` | Extend context only for current controlled runtime state and current action helpers. | +| `packages/wire-react/src/provider/runtimeState.ts` | Package-private controlled/uncontrolled selection, viewport, mode, and dirty resolution; same-value no-op checks; metadata construction; and callback ordering helpers. | +| `packages/wire-react/src/provider/dirtyState.ts` | Package-private canonical stable JSON snapshots, clean baseline management, `markClean` behavior, controlled edit echo detection, and external diagram replacement handling. | +| `packages/wire-react/src/provider/WireProvider.tsx` | Wire provider props and context to package-private runtime helpers; keep reducer dispatch, history, and callback wiring visible at the provider boundary. | +| `packages/wire-react/src/hooks.ts` | Preserve current hook names and return shapes. Do not add store or instance hooks in this plan. | +| `packages/wire-react/src/provider/WireProvider.controlled.test.tsx` | Controlled/uncontrolled selection, viewport, mode, dirty, same-value no-ops, callback metadata, dirty reset, reducer exception, and `dispatchMany` ordering coverage. | +| `packages/wire-react/src/provider/runtimeState.test.ts` | Direct helper coverage for selection normalization, viewport finite-value validation, viewport equality, mode equality, same-value callback suppression, event metadata construction, and callback ordering. | +| `packages/wire-react/src/provider/dirtyState.test.ts` | Canonical snapshot equality, clean baseline reset, controlled edit echo, external replacement, and `markClean` helper coverage. | +| `packages/wire-react/src/components/optionFields.test.tsx` | Shared field renderer wrapper, labels, descriptions, errors, required state, disabled/read-only semantics, pending values, custom renderers, and commit modes. | +| `packages/wire-react/src/components/inspectionState.test.ts` | Direct resolver coverage for explicit node, explicit edge, workspace node, workspace edge, single selection fallback, mixed/empty summary, stale targets, synthesized-edge lookup, and focus-return target choice. | +| `packages/wire-react/src/components/WireInspector.test.tsx` | Node/edge explicit inspection, tabs, Configure/Style/Validation/JSON/Edge behavior, read-only non-mutation, custom fields, edge patch controls, stale edge state, and tab focus behavior. | +| `packages/wire-react/src/components/WireWorkspace.inspection.test.tsx` | Workspace inspected-target resolver, node/edge precedence, callbacks, pane clear, custom inspector compatibility, and wrapper prop pass-through. | +| `packages/wire-react/src/canvas/focus.test.ts` | Roving focus, active canvas ownership, deletion/filter recovery, inspector return focus, auto-pan-on-focus, large-diagram focus, and multi-canvas isolation. | +| `packages/wire-react/src/canvas/keyboard.test.ts` | Root-scoped shortcuts, movement/delete gating, interactive descendant guards, read-only behavior, search/connection shortcut suppression, and keyboard inspection events. | +| `packages/wire-react/src/canvas/search.test.tsx` | Search combobox/listbox semantics, deterministic result order, no-result and stale-result Enter behavior, pointer selection, Escape/blur cleanup, and focus restoration. | +| `packages/wire-react/src/canvas/connectionPicker.test.tsx` | Keyboard connection picker semantics, side selection, validation rejection/retry/cancel, repeated rejection announcements, success focus, and target-handle fallback. | +| `packages/wire-react/src/canvas/accessibility.test.ts` | ARIA label fallbacks, status messages, reduced-motion behavior, forced-colors state coverage, and connection success announcements. | +| `packages/wire-react/src/canvas/performance.test.ts` | Large-diagram mode threshold detection, minimap simplification flags, nonessential measurement deferral, and fit-selection focus/status behavior. | +| `tests/e2e/wire-react-css-consumer.spec.ts` | Packed CSS-only consumer smoke, computed-style assertions, and package resolution from fixture dependencies. | +| `tests/e2e/wire-canvas-keyboard.spec.ts` | Keyboard-only canvas selection, inspection, movement, delete, search, connection, rejection recovery, focus transfer, and large-diagram fallback smoke. | +| `tests/e2e/wire-react-docs-examples.spec.ts` | Runnable docs examples for CSS import, controlled state, inspector options, edge inspection, theming, read-only viewer, and accessibility. | +| `tests/performance/wire-react-fixtures.ts` | Deterministic fixture builders and checked-in seeds for 500-node/600-edge, 1,000-node/1,200-edge, and 2,000-node/2,400-edge diagrams. | +| `tests/performance/wire-react-performance.spec.ts` | Production-build browser harness for render, selection, viewport, drag preview, minimap, focus/search when available, large-diagram mode, and raw sample artifacts. | +| `tests/performance/baselines/wire-react-current.json` | Advisory Phase 2.5 baseline artifact and later release-threshold comparison data; entries are append-only by fixture and interaction key so later search, connection picker, edge inspection, and focus baselines do not replace earlier render/selection/viewport data. | +| `tests/fixtures/wire-react-consumer/*` | From a temporary directory outside the monorepo workspace, install the packed tarball, import package CSS, typecheck, production-build, assert package resolution comes from fixture `node_modules`, not repo workspaces or symlinks, and run computed-style assertions against packaged CSS. | +| `tests/fixtures/wire-react-api-compat/*` | Typecheck legacy imports, props, hooks, catalogs, render callbacks, and diagram fixtures against built declarations. | +| `tests/fixtures/wire-react-legacy/*` | Runtime-render old default provider, workspace, canvas, inspector, and option panel flows with new props omitted. | +| `tests/fixtures/wire-react-utility-legacy/*` | Render current package surfaces through the previous utility-scanning setup without importing package CSS and assert the concrete computed-style gates from Phase 1. | +| `tests/fixtures/wire-diagrams-historical/*` | Historical persisted `WireDiagram` JSON fixtures from prior releases that render, validate, and round-trip without migration. | +| `scripts/check-docs-source-names.mjs` | CI script for the docs source-name guard; scans the documented React package docs/example globs, rejects external URLs, and checks tokens from the denylist without printing the blocked token list in this plan. | +| `scripts/docs-source-name-denylist.json` | Checked-in source of truth for blocked external inspiration source names, package names, and documentation paths. | +| `.github/workflows/ci.yml` | PR CI runs build, typecheck, unit tests, React tests, React coverage, e2e tests, playground build, docs checks, docs source-name guard, docs snippets, release-notes check, packed consumer, API compatibility, legacy runtime, legacy utility-scanning consumer, workspace range, lockfile, API diff, semver check, persistence tests, and the performance gate. It does not run registry lookup or publish dry-run. | +| `.github/workflows/release.yml` | Release-candidate and publish workflows require successful same-SHA PR CI, then run only `npm run release:wire-react:dry-run` for release validation before publish. | +| `docs/*.md` | Align current docs with implemented API and planned roadmap; include root README and machine-readable docs surfaces. | +| `apps/playground/app/docs/*` | Add runnable examples and remove npm consumer utility-scanning requirement after CSS export ships. | + +## Test Matrix + +| Area | Tests | +|---|---| +| Package export | Clean build emits CSS; packed package includes CSS; root, compile, and CSS imports resolve; package manifest preserves stylesheet side-effect metadata for authored and emitted CSS paths. | +| CSS-only consumer | Install the packed tarball into an isolated fixture outside workspace resolution, import `@aigentive/wire-react/styles.css`, typecheck, production-build, assert package resolution from fixture `node_modules`, and render `WireWorkspace`, `WireCanvas`, `WireInspector`, `WireOptionPanel`, `WireEditor`, `WireViewer`, `WireToolbar`, `WirePalette`, `WireNodeList`, `WireValidationPanel`, controls, minimap, cards, handles, and exported primitives with package CSS only. This isolated fixture must run computed-style assertions for package CSS, not only workspace tests. | +| Legacy utility-scanning consumer | Render current surfaces with the old utility-scanning setup and no package CSS import; computed-style smoke asserts backgrounds, colors, contrast, focus outlines, dimensions, handle/control hit areas, and no overlap. If it fails, this non-major plan stops until compatibility is preserved or a separate breaking-release plan is approved. | +| Styling | Light/dark/system themes, `unstyled`, slot `classNames`, focus-visible styles, computed styles for every exported component and primitive, inspector/options/handles/controls coverage, data attributes retained. | +| Provider | Controlled/uncontrolled diagram, selection, viewport, mode, dirty; same-value no-ops; reducer exceptions; callback ordering; dirty reset; history on/off. | +| Actions/history | `dispatch`, `dispatchMany`, multi-node drag undo, multi-select delete undo, edge disconnect undo, group ungroup undo. | +| Options | Existing catalogs, all current input types, storage targets, grouping, ordering, hidden, disabled, read-only, validation, parse/format, commit modes; declaration tests prove deferred input literals are absent until implemented; each additional input type has its own UX/a11y test before shipping. | +| Inspector | Node selection, explicit `nodeId`, explicit `edgeId`, both ids supplied with node winning, mixed selection summary, Configure/Style/Validation/JSON/Edge tabs, read-only JSON in Phase 3A, primitive edge-field editing, read-only edge object/endpoint fields, edge-only default tab, tab reset on inspected item change, read-only non-mutation, tablist keyboard behavior. | +| Option panel compatibility | Current `WireOptionPanel` tests continue to pass; shared field renderer behaves the same for simple catalogs. | +| Canvas pointer | Drag, pan, zoom, fit view, connect, select, edge click, custom `renderEdge` hit path. | +| Canvas keyboard | Roving focus for nodes/edges, focus recovery after deletion/filtering/empty diagrams, accessible canvas search, Enter/Space select and inspect, Shift+Enter inspector focus transfer with Alt+Shift+Enter return, Escape clear/cancel, arrow movement, semantic keyboard connection target picker, Delete/Backspace delete, text-entry and interactive-custom-content guards, multi-canvas scoping. | +| Accessibility | ARIA labels and fallback defaults, `aria-selected`, DOM roving focus order, control labels, handle labels, canvas search combobox/listbox semantics and status announcements, connection target picker semantics, option field label/id/description/error/required semantics for generated and custom-rendered fields, tablist roving tab stop and panel labels, empty/all-inapplicable inspector tabs, live region for pointer and keyboard connection rejection, reduced motion, forced-colors/high-contrast behavior. | +| Performance | `npm run test:performance` runs the deterministic browser production-build harness for 500-node/600-edge and 1,000-node/1,200-edge fixture render, selection, viewport, drag preview, minimap on/off budgets with the numeric thresholds above, plus fallback checks that preserve keyboard/search/navigation usability on the 2,000-node/2,400-edge fixture; committed baseline JSON validates append-only entries, schema version, path owner, commit SHA, CI runner identity, browser engine/version, sample count, median, and p95. | +| Persistence safety | Serialize after inspector/options/controlled-state workflows and assert no React callbacks, selection, viewport, mode, dirty, inspected node/edge ids, focus, drag, or catalog functions enter `WireDiagram`; render, validate, and round-trip historical persisted `WireDiagram` JSON fixtures from prior releases without migration. | +| Compatibility fixtures | Typecheck legacy import, prop, hook, catalog, render callback, and diagram fixtures against the built package; run a full emitted declaration/API diff against the last published package; include exhaustive `WireEvent` and `WireEventSource` switch fixtures for semver validation; runtime-render legacy defaults with all new props omitted. | +| Docs/examples | Playground docs build, examples render, package CSS examples run, feature-specific root/package markdown snippets typecheck, internal links pass. | + +Required local gates: + +```bash +npm run clean --workspace @aigentive/wire-react +npm run build --workspace @aigentive/wire-react +npm run build +npm run build --workspace @aigentive/wire-playground +npm run typecheck +npm test +npm run test:react +npm run test:react:coverage +npm run test:e2e +npm run test:consumer-pack +npm run test:api-compat +npm run test:legacy-runtime +npm run test:legacy-utility-consumer +npm run test:docs-links +npm run test:docs-source-names +npm run test:docs-snippets +npm run test:persistence +npm run test:performance +npm run test:release-notes +npm run check:semver +npm run check:workspace-ranges +npm run check:lockfile +npm run check:api-diff +``` + +Add these scripts to root `package.json` and gate them in PR CI: + +```bash +npm run clean --workspace @aigentive/wire-react +npm run build --workspace @aigentive/wire-react +npm run build +npm run build --workspace @aigentive/wire-playground +npm run typecheck +npm test +npm run test:react +npm run test:react:coverage +npm run test:e2e +npm run test:consumer-pack +npm run test:api-compat +npm run test:legacy-runtime +npm run test:legacy-utility-consumer +npm run test:docs-links +npm run test:docs-source-names +npm run test:docs-snippets +npm run test:persistence +npm run test:performance +npm run test:release-notes +npm run check:semver +npm run check:workspace-ranges +npm run check:lockfile +npm run check:api-diff +``` + +Release-candidate CI and release publish must require same-SHA PR CI success and +then run this release-only aggregate: + +```bash +npm run release:wire-react:dry-run +``` + +## Docs And Examples Matrix + +| Doc/example | Required update | +|---|---| +| `packages/wire-react/README.md` | Runnable CSS import, controlled editor, inspector options, edge inspection, custom shell, read-only viewer, theming, and keyboard behavior examples. | +| Root `README.md` | Package install, CSS import, current docs routes, no stale sample links. | +| `docs/REACT_COMPONENTS.md` | Current props reference for CSS, provider controlled state, inspector, options, canvas accessibility. | +| `docs/REACT_EDITOR_ARCHITECTURE.md` | Runtime state ownership, controlled state, catalog flow, inspector model, persistence rules. | +| `docs/WIRE_OPTIONS_CATALOG_SPEC.md` | Current catalog behavior, in-place option additions, inspector integration. | +| `apps/playground/app/docs/install/page.tsx` | Replace consumer utility-scanning requirement with package CSS import. | +| `apps/playground/app/docs/examples/_shared.tsx` | Shared runnable diagrams, catalogs, controlled-state helpers, and example shell used by feature-specific routes. | +| `apps/playground/app/docs/examples/package-css/page.tsx` | Show a minimal npm-style consumer import path and package CSS-only styling coverage. | +| `apps/playground/app/docs/examples/custom-shell/page.tsx` | Show current wrapper and render-callback customization without alternate package or option names. | +| `apps/playground/app/docs/examples/options/page.tsx` | Show implemented `WireOptionCatalog`, `WireInspector optionCatalog`, `WireOptionPanel catalog`, and storage targets. | +| `apps/playground/app/docs/examples/controlled-state/page.tsx` | Show host-owned selection, viewport, mode, dirty, and diagram. | +| `apps/playground/app/docs/examples/edge-inspection/page.tsx` | Show `edge.click` with `intent: "inspect"`, `inspectEdgeId`, read-only endpoints, and current `edge.patch` controls. | +| `apps/playground/app/docs/examples/accessibility/page.tsx` | Show keyboard-only node/edge selection, movement, deletion, labels, connection rejection recovery, and focus transfer. | +| `apps/playground/app/docs/examples/theming/page.tsx` | Show CSS variables, `colorMode`, `unstyled`, and `classNames`. | +| `apps/playground/app/docs/examples/wrappers/page.tsx` | Show `WireWorkspace`, `WireEditor`, and `WireViewer` pass-through behavior. | +| `apps/playground/app/docs/examples/read-only-inspector/page.tsx` | Show that `WireWorkspace readOnly` locks built-in surfaces while host-owned custom inspector content must enforce its own non-mutating behavior. | +| `apps/playground/app/llm/*` docs | Keep React package shape aligned with current APIs. | +| `packages/wire-mcp/src/docs-shape.ts` | Keep generated guidance aligned with current APIs. | +| Docs source-name guard | `npm run test:docs-source-names` rejects external inspiration source names, external inspiration package names, URLs, and documentation paths in React package docs, examples, implementation plans, checked-in generated public docs, and checked-in source templates for generated docs. | +| `scripts/docs-source-name-denylist.json` | Checked-in source of truth for blocked external inspiration tokens used by `npm run test:docs-source-names`; keep blocked tokens out of this implementation plan. | + +Public-addition example coverage: + +| Public addition | Required runnable coverage | +|---|---| +| `WireOptionSpec commitMode`, `debounceMs`, `parse`, `format`, `validate`, `hidden`, `disabled`, and `readOnly` | Package README or playground options example with typechecked snippets and form interaction tests. | +| `WireInspector onOptionCommit`, `renderField`, `renderSection`, `tabs`, `nodeId`, `edgeId`, and `readOnly` | Inspector options and read-only examples showing node fields, edge details, and non-mutating custom renderers. | +| `WireCanvas isValidConnection`, keyboard connection target picker, search labels, and `ariaLabelConfig` additions | Accessibility example covering pointer rejection, keyboard rejection/retry, successful connection focus, search, and configurable labels. | +| `colorMode`, `unstyled`, and per-component `classNames` slots | Theming example covering workspace, canvas, inspector, option panel, toolbar, palette, node list, validation panel, node card view, and group frame slots. | +| Controlled `selection`, `viewport`, `mode`, `dirty`, and inspected edge/node state | Controlled-state example showing host mirroring, dirty reset, and diagram persistence safety. | + +## Migration And Compatibility Notes + +- Existing consumers can keep importing from `@aigentive/wire-react`. +- Existing `WireOptionCatalog` objects remain valid. +- Existing `WireOptionPanel` usage remains valid. +- Existing `WireWorkspace optionCatalog` and `WireCanvas optionCatalog` remain + valid. +- Existing `renderNodeCard`, `renderGroup`, and `renderEdge` callbacks remain + valid. +- Existing hooks keep their names and return shapes. +- Existing diagrams do not require migration. +- Existing public props are covered by compatibility fixtures. +- Consumers can keep their own styling, but npm consumers get a first-class CSS + file and no longer need utility-class source scanning. +- Existing consumers that followed the previous utility-scanning guidance must + keep baseline computed styles in the first non-major release. The legacy + utility-scanning consumer fixture proves this with concrete style, contrast, + focus, dimension, hit-area, and overlap assertions. If the implementation + cannot preserve that path and requires adding `@aigentive/wire-react/styles.css`, + the non-major release must stop until compatibility is restored or a separate + breaking-release plan is approved. +- Controlled runtime state is optional and does not change persisted diagram + shape. +- Serialization tests prove runtime-only state and catalog functions never enter + persisted `WireDiagram` JSON. + +## Release And Package Export Notes + +Package export target: + +```json +{ + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./compile": { + "types": "./dist/compile.d.ts", + "default": "./dist/compile.js" + }, + "./styles.css": "./dist/styles.css" + }, + "sideEffects": [ + "./dist/styles.css", + "./src/styles.css" + ] +} +``` + +Build requirements: + +- `npm run build --workspace @aigentive/wire-react` must emit JS, declarations, + and CSS. +- `npm run clean --workspace @aigentive/wire-react` must exist and remove only + `packages/wire-react/dist`. +- Preserve top-level package metadata used by older tooling, including `main` + and `types`, while adding `./styles.css` to `exports`. +- Add CSS side-effect metadata for the authored and emitted stylesheet paths so + consumer bundlers do not tree-shake the package CSS import. +- `npm pack --workspace @aigentive/wire-react --dry-run` must show CSS in the + package. +- `npm publish --dry-run --workspace @aigentive/wire-react` must pass. +- A packed-consumer fixture must install the tarball and import + `@aigentive/wire-react/styles.css`. +- CSS export and additive React props require a minor version bump for + `@aigentive/wire-react` because this plan avoids exported `WireEvent` + discriminant expansion. Docs-only or bugfix-only follow-up work can use a + patch bump. Any breaking runtime behavior is out of scope for this plan. +- `npm run check:semver` validates patch/minor expectations and runs exhaustive + `WireEvent` and `WireEventSource` fixtures to prove the event discriminant + sets did not expand. +- `npm run check:api-diff` compares emitted declarations and root export names + against the last published `@aigentive/wire-react` package. Any removed export, + narrowed prop, incompatible type change, or unexpected new root export blocks + a minor release. +- Before publish, `npm view @aigentive/wire-react@ version + --registry=` must fail with a registry not-found + response for the candidate version, proving registry uniqueness against the + same registry used by `publishConfig`. Network, auth, rate-limit, mirror, or + registry availability errors are hard failures. `npm run + check:registry-version` is the root script for this check. +- Package version changes must follow the repo release process, update the + package lockfile, and keep internal workspace dependency ranges aligned for + packages and apps that depend on `@aigentive/wire-react`. `npm run + check:workspace-ranges` validates workspace dependency ranges and `npm run + check:lockfile` validates lockfile sync. +- `npm run release:wire-react:dry-run` is the single release aggregate. It runs + clean, package build, full repo build, playground build, typecheck, unit tests, + React tests, React coverage, e2e tests, packed consumer, API compatibility, + legacy runtime, legacy utility consumer, docs links, docs source-name guard, + docs snippets, release-notes check, persistence, performance, workspace range, + lockfile, API diff, semver, registry, pack, and publish dry-run gates in order. +- Release workflows invoke the aggregate dry-run script after same-SHA PR CI + success. They do not separately invoke semver, registry, pack, or publish + dry-run commands outside the aggregate. +- `.github/workflows/release.yml` must require a successful CI workflow for the + same commit SHA before publish, even when `release:wire-react:dry-run` is run + locally. +- `npm run test:release-notes` validates the candidate release notes artifact at + `docs/releases/wire-react-.md`. The `` segment must match the + candidate `@aigentive/wire-react` package version. The release notes must call + out the CSS import path plus user-facing additions from the public-addition + example coverage table: controlled runtime state, inspector/options behavior, + edge inspection, canvas accessibility, theming, wrapper pass-through, + compatibility constraints, and any docs-only changes. +- No package versioning scheme or alternate package name is needed for these + changes. + +## Locked Decisions + +- `dispatchMany` calls `onChange` once and `onAction` once per input action in + input order. +- Package CSS uses semantic package classes and CSS variables. Playground chrome + stays in the playground app. +- Edge controls appear only for explicit `edgeId` or exactly one selected edge. +- When `WireInspector` receives both explicit `nodeId` and explicit `edgeId`, + `nodeId` wins. +- The JSON inspector tab is read-only in Phase 3A. +- Enter and Space both select and inspect the focused canvas item when + inspection is enabled. +- Phase 3A ships current option input types plus form metadata. Additional + option input types are deferred until after the initial production release and + ship one at a time with their own UX/a11y/serialization tests. +- Initial performance budgets cover 500-node/600-edge and 1,000-node/1,200-edge + fixtures with the numeric thresholds above. +- CSS/API additions use a minor package bump because the plan avoids exported + event discriminant expansion. Docs-only or bugfix-only follow-up work can use + a patch bump. +- Edge inspection is runtime state driven by existing `edge.click` with + `intent: "inspect"`; durable edits use current `edge.patch` and `edge.remove` + actions. +- Canvas keyboard ownership clears on focusout outside the canvas root. + +## Acceptance Checklist + +- [ ] Current public names are extended in place. +- [ ] Complete current root export coverage is guarded. +- [ ] No alternate option catalog names are introduced. +- [ ] `WireProvider` remains catalog-free. +- [ ] `WireDiagram` and reducer actions remain the durable state contract. +- [ ] Package CSS export works from a packed package. +- [ ] Package manifest preserves stylesheet side-effect metadata for authored and + emitted CSS paths. +- [ ] Packed-consumer fixture imports CSS and renders without utility-class + source scanning. +- [ ] Packed-consumer fixture runs outside workspace resolution and resolves the + package from fixture `node_modules`. +- [ ] Packed-consumer fixture covers every current exported component and + primitive. +- [ ] Packed-consumer fixture typechecks and production-builds from the packed + tarball. +- [ ] Packed-consumer fixture runs computed-style assertions against packaged CSS. +- [ ] Legacy utility-scanning consumer fixture proves existing styled consumers + meet concrete computed-style, contrast, focus, dimension, hit-area, and + overlap assertions, or this non-major plan stops. +- [ ] Npm consumer docs do not require utility-class source scanning. +- [ ] Controlled runtime state works without changing persisted diagrams. +- [ ] Controlled runtime events use the defined `WireEvent` variants. +- [ ] Controlled runtime no-op and callback ordering rules are tested. +- [ ] Semver gate proves exported `WireEvent` and `WireEventSource` + discriminants do not expand in this release. +- [ ] Wrapper prop pass-through is tested for `WireWorkspace`, `WireEditor`, and + `WireViewer`. +- [ ] `WireWorkspace readOnly` atomically locks the built-in workspace UI. +- [ ] `WireInspector` supports catalog Configure fields and current Style + controls. +- [ ] Deferred option input literals are absent from declarations until their + implementations ship. +- [ ] `WireOptionPanel` remains compatible. +- [ ] Option parse errors and invalid pending values are local, accessible, and + non-mutating. +- [ ] Debounced option commits cannot dispatch after selection, inspected item, + read-only, disabled, unmount, or parse-invalid changes. +- [ ] Edge inspection emits existing `edge.click` with `intent: "inspect"`, uses + current edge fields, and edits through reducer actions. +- [ ] Edge object-like fields, endpoints, branch, and handles are read-only in + the inspector until complete accessible editors ship. +- [ ] Inspector read-only mode is non-mutating across all tabs and custom fields. +- [ ] The JSON inspector tab is read-only in Phase 3A. +- [ ] Canvas keyboard behavior is root-scoped and tested. +- [ ] Canvas keyboard ownership clears on focusout outside the canvas root. +- [ ] Canvas keyboard connect workflow is tested; keyboard reconnect remains + deferred. +- [ ] Canvas roving focus traversal and search are deterministic and tested. +- [ ] Connection rejection recovery keeps focus, supports retry/cancel, and + re-announces repeated rejection messages with visible and live-region + feedback. +- [ ] Large diagrams use roving canvas focus instead of thousands of Tab stops. +- [ ] Keyboard inspection and inspector focus transfer are tested. +- [ ] Accessibility labels and focus states are documented and tested. +- [ ] Option field labels, descriptions, validation errors, required state, and + grouping semantics are tested. +- [ ] Large-diagram baseline is measured against numeric thresholds and UX + orientation checks. +- [ ] Committed performance baseline JSON validates append-only entries, schema + version, path owner, commit SHA, CI runner identity, browser engine/version, + sample count, median, and p95. +- [ ] `npm run test:performance` is wired into local, PR CI, and release dry-run + gates, including the 2,000-node/2,400-edge fallback fixture. +- [ ] Docs and examples match implemented APIs. +- [ ] Every public API change has a runnable feature-specific example. +- [ ] Root README, package README, markdown docs, playground docs, and + machine-readable docs are aligned. +- [ ] Docs links and markdown snippets are validated. +- [ ] Docs/source-name guard rejects external inspiration source names, external + inspiration package names, URLs, and documentation paths. +- [ ] Legacy runtime fixture proves old default behavior with new props omitted. +- [ ] Persistence tests prove runtime state, including selection, viewport, + mode, dirty state, inspected node/edge ids, focus, and drag state, does not + enter `WireDiagram`. +- [ ] Historical persisted `WireDiagram` JSON fixtures render, validate, and + round-trip without migration. +- [ ] Package version, lockfile, and workspace ranges are release-ready. +- [ ] Full emitted declaration/API diff passes against the last published + package. +- [ ] Package `clean` script exists and release dry-run includes base CI gates. +- [ ] Registry version uniqueness checks only pass on a registry not-found + response. +- [ ] PR CI excludes registry and publish checks; release-candidate/publish + workflows include them and require same-SHA CI success before publish. +- [ ] `npm run test:release-notes` validates the package CSS import path, + user-facing additions, docs-only changes, and CSS migration compatibility + constraints. +- [ ] Release gates pass. + +## Verification Status + +This file was produced after seven research passes, three independent design +plans, and a three-judge selection panel. The selected plan is Plan A with +mandated grafts for adoption scope, CSS coverage, keyboard hardening, +accessibility, edge inspector support, and documentation. + +Adversarial verification completed after 27 critic rounds. Round 27 reported no +critical, high, or medium gaps across API compatibility, implementation +maintainability, production UX/accessibility, and tests/release/docs review. diff --git a/docs/REACT_PRODUCTION_PACKAGE_HANDOFF_SPEC.md b/docs/REACT_PRODUCTION_PACKAGE_HANDOFF_SPEC.md new file mode 100644 index 0000000..4552408 --- /dev/null +++ b/docs/REACT_PRODUCTION_PACKAGE_HANDOFF_SPEC.md @@ -0,0 +1,581 @@ +# React Production Package Handoff Spec + +## Objective + +Make `@aigentive/wire-react` reusable as a production diagram editor package for +product teams that need workflow, agent, automation, or systems diagrams without +forking the canvas or importing a separate graph editor directly. + +The target package should preserve Wire's core contract: + +- durable state is canonical `WireDiagram` JSON plus reducer actions; +- React owns composition, editor chrome, runtime controls, and view state; +- consumers can start with a batteries-included editor shell and peel down to + primitives when building a custom product experience. + +This handoff is informed by mature production diagram editor patterns. The +useful lessons are not to copy another implementation, but to match the maturity +pattern: a compact canvas component, controlled-state helpers, provider-scoped +hooks, typed registries, built-in add-ons, documented styling imports, +accessibility contracts, and many runnable examples. + +## Patterns Applied + +- A compact canvas component with optional mini-map, controls, and background + add-ons. +- Controlled-state helpers for examples and app-owned editor state. +- Provider-scoped hooks for custom panels and toolbars. +- Typed registries for nodes, edges, handles, and option fields. +- A stable CSS import and theme variable contract for package consumers. +- Accessibility and keyboard behavior as documented public API. + +## Current Local Surface + +The package already has a useful base. + +| Area | Current files | Current capability | +|---|---|---| +| Shell | `packages/wire-react/src/components/WireWorkspace.tsx` | Composes provider, sidebar, canvas, option panel, validation panel. | +| Canvas | `packages/wire-react/src/canvas/WireCanvas.tsx` | Renders diagrams, pan/zoom, fit view, drag, connect, selection, minimap, controls, custom card/group/edge render callbacks. | +| State | `packages/wire-react/src/provider/WireProvider.tsx` | Controlled or uncontrolled diagram, validation, selection, viewport, mode, dirty, undo/redo. | +| Hooks | `packages/wire-react/src/hooks.ts` | Context hooks for diagram, validation, selection, viewport, actions, history, mode, events. | +| Options | `packages/wire-react/src/options.ts` and `components/WireOptionPanel.tsx` | Per-kind option catalogs with text, textarea, number, boolean, select inputs. | +| Docs | `docs/REACT_COMPONENTS.md`, `docs/REACT_EDITOR_ARCHITECTURE.md`, `docs/WIRE_OPTIONS_CATALOG_SPEC.md` | Component reference, editor architecture, initial option catalog spec. | + +The main production gap is not the canvas baseline. The gap is the lack of a +stable product integration layer around options, controlled editor state, +registries, theme/styling, accessibility, examples, and package exports. + +## Design Direction + +### 1. Keep Wire Domain First + +Wire should not become a generic graph runtime. Generic graph editors often +center `nodes` and `edges`; Wire should center `WireDiagram`, `WireAction`, +validation, and serializable diagram semantics. + +Good API shape: + +```tsx + +``` + +Avoid exposing graph-canvas internals as the primary contract. Consumers should +only need lower-level canvas details when writing custom renderers or advanced +plugins. + +### 2. Offer Progressive Entry Points + +Mirror mature diagram-editor package patterns with Wire-native naming: + +| Level | Proposed entry point | Purpose | +|---|---|---| +| Full shell | `WireWorkspace` | Product-ready editor with panels, canvas, validation, options, style controls. | +| Canvas primitive | `WireCanvas` | Custom product shells using provider hooks. | +| Provider | `WireProvider` | Shared editor state and action/event dispatch. | +| Hooks | `useWireDiagramState`, `useWireInstance`, `useWireViewport`, `useWireSelection` | Controlled-state helpers and scoped state access. | +| Registries | `nodeTypes`, `edgeTypes`, `handleTypes`, `fieldTypes` | Typed replacement points for product-specific nodes, edges, handles, and option fields. | +| Add-ons | `WireMiniMap`, `WireControls`, `WireBackground`, `WirePanel` | Reusable canvas children with explicit placement and ARIA labels. | + +### 3. Make Options A Real Form Schema + +The current option spec is good for demos, but production editors need the same +`WireOptionSpec` and `WireOptionCatalog` names to grow into a form schema with +grouping, visibility, validation, permissions, commit behavior, and custom field +rendering. + +This must be an in-place package update. Do not introduce versioned option +types, parallel catalog names, or a second React package surface for the same +features. + +Recommended in-place additions: + +```ts +export type WireOptionInputType = + | "text" + | "textarea" + | "number" + | "boolean" + | "select" + | "multiSelect" + | "json" + | "code" + | "color" + | "date" + | "url" + | "secret" + | "array" + | "object" + | "custom"; + +export interface WireOptionSpec { + key: string; + label?: string; + type?: WireOptionInputType; + storage?: WireOptionStorage; + description?: string; + placeholder?: string; + defaultValue?: unknown; + group?: string; + section?: string; + order?: number; + width?: "full" | "half" | "third"; + required?: boolean; + readOnly?: boolean | WireOptionPredicate; + disabled?: boolean | WireOptionPredicate; + hidden?: boolean | WireOptionPredicate; + visibleWhen?: WireOptionCondition; + options?: WireOptionChoice[] | WireOptionChoiceLoader; + min?: number; + max?: number; + step?: number; + pattern?: string; + validate?: WireOptionValidator; + parse?: (input: unknown, context: WireOptionContext) => unknown; + format?: (value: unknown, context: WireOptionContext) => unknown; + commitMode?: "change" | "blur" | "submit"; + debounceMs?: number; + undoGroup?: string; + fieldType?: string; + fields?: WireOptionSpec[]; +} +``` + +Catalog shape stays on the current export name: + +```ts +export type WireOptionCatalog = Partial>; +``` + +Any helper needed to resolve groups, predicates, validators, or field renderers +should be internal to `@aigentive/wire-react` or passed as additive props on the +current components. Do not create a versioned catalog discriminator. + +### 4. Merge Options Into A Production Inspector + +`WireInspector` and `WireOptionPanel` should become part of one cohesive +inspector system. Product users expect tabs/sections such as Configure, Style, +Validation, Data, and Events, not a separate one-off panel. + +Proposed inspector API: + +```tsx + +``` + +Required behavior: + +- follows current single-node selection by default; +- supports explicit `nodeId`; +- shows grouped option fields with stable order; +- displays field-level validation errors; +- supports read-only and disabled states; +- batches text typing into sensible undo entries; +- supports custom fields without consumers replacing the full inspector; +- emits option-specific events for analytics and autosave. + +### 5. Add Controlled State Helpers + +Controlled-state helper patterns are valuable because they make examples and +controlled usage obvious. Wire should provide helpers around canonical diagrams, +not graph nodes. + +Proposed helpers: + +```ts +const [diagram, setDiagram, onWireChange] = useWireDiagramState(initialDiagram); +const [selection, setSelection, onSelectionChange] = useWireSelectionState(); +const [viewport, setViewport, onViewportChange] = useWireViewportState(); +``` + +Provider props should support fully controlled editor state: + +```ts +export interface WireProviderProps { + diagram?: WireDiagram; + defaultDiagram?: WireDiagram; + onChange?: (diagram: WireDiagram, event: WireChangeEvent) => void; + + selection?: WireSelection; + defaultSelection?: WireSelection; + onSelectionChange?: (selection: WireSelection, event: WireEvent) => void; + + viewport?: WireViewport; + defaultViewport?: WireViewport; + onViewportChange?: (viewport: WireViewport, event: WireEvent) => void; + + mode?: WireMode; + defaultMode?: WireMode; + onModeChange?: (mode: WireMode, event: WireEvent) => void; + + dirty?: boolean; + onDirtyChange?: (dirty: boolean) => void; +} +``` + +The provider can stay internally managed by default. Production hosts need the +option to wire selection, viewport, mode, dirty state, autosave, and URL state +into their own app shell. + +### 6. Add An Instance Hook + +Instance hooks are useful for imperative operations that should not re-render +subscribers. Wire needs the same idea with Wire semantics. + +```ts +export interface WireInstance { + getDiagram(): WireDiagram; + setDiagram(diagram: WireDiagram): void; + dispatch(action: WireAction): ApplyWireActionResult; + dispatchMany(actions: WireAction[]): ApplyWireActionResult; + validate(): WireValidationResult; + getNode(id: string): WireNode | undefined; + updateNode(id: string, patch: Partial): ApplyWireActionResult; + getSelection(): WireSelection; + setSelection(selection: WireSelection): void; + getViewport(): WireViewport; + setViewport(viewport: WireViewport): void; + fitView(options?: WireFitViewOptions): void; + zoomIn(options?: WireViewportCommandOptions): void; + zoomOut(options?: WireViewportCommandOptions): void; + screenToWorld(point: WirePoint): WirePoint; + worldToScreen(point: WirePoint): WirePoint; + serialize(): WireDiagram; +} + +export function useWireInstance(): WireInstance; +``` + +Also add `onInit?: (instance: WireInstance) => void` to `WireCanvas` and +`WireWorkspace` for integrations that want a stable handle. + +### 7. Expose A Selector Hook Carefully + +Some mature diagram editors expose store hooks while recommending dedicated +hooks for common use cases. Wire should follow that stance. + +Proposed API: + +```ts +export function useWireStore( + selector: (state: WireStoreState) => T, + equalityFn?: (a: T, b: T) => boolean +): T; +``` + +Guidelines: + +- prefer `useWireDiagram`, `useWireSelection`, `useWireViewport`, and + `useWireInstance`; +- use selector hooks for custom panels, performance-sensitive summaries, and + large-editor integrations; +- document selector stability and equality functions. + +### 8. Add Typed Registries + +Current render callbacks work, but production teams need named registries with +consistent typing, stable memoization, and fallback behavior. + +Proposed API: + +```ts +export type WireNodeTypes = Record; +export type WireEdgeTypes = Record; +export type WireHandleTypes = Record; +export type WireFieldTypes = Record; + + +``` + +Rules: + +- encourage defining registry objects outside React render functions; +- warn when a node/edge/field references an unknown type and fall back safely; +- make custom node render context include handles, option specs, validation + issues, selected/hover/focus state, and connection state. + +### 9. First-Class Handles And Ports + +Handle-based connection APIs are a key production feature. Wire already routes +edges, but it does not expose a component-level handle/port contract. + +Recommended Wire model: + +```ts +export interface WirePortSpec { + id: string; + type: "source" | "target" | "both"; + side?: Side; + label?: string; + accepts?: string[]; + maxConnections?: number; +} +``` + +Recommended canvas components: + +```tsx + + +``` + +Edge data should support `sourcePortId` and `targetPortId`. Connection +validation should be configurable through `isValidConnection`. + +### 10. Styling And Theme Contract + +The current package depends heavily on Tailwind-generated classes and docs tell +consumers to add `@source`. That is fragile for a reusable npm package. + +Ship an explicit CSS export: + +```json +{ + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./styles.css": "./dist/styles.css" + } +} +``` + +Consumer usage: + +```ts +import "@aigentive/wire-react/styles.css"; +``` + +CSS contract: + +- `data-wire-theme="light|dark|system"` or a `colorMode` prop; +- CSS variables for surface, text, border, focus, selection, grid, edge, + minimap, controls, node tones; +- an `unstyled` mode for design-system ownership; +- `classNames` slot map for product teams that need class-level control; +- no required Tailwind scan for npm consumers. + +### 11. Accessibility Contract + +Production diagrams need keyboard and screen-reader support as a documented +feature, not incidental DOM behavior. + +Minimum requirements: + +- nodes and edges are focusable when enabled; +- `Enter` or `Space` selects focused node or edge; +- `Escape` clears selection or exits connection mode; +- arrow keys move selected nodes in edit mode; +- delete/backspace removes selected elements when editable; +- focus panning keeps focused nodes visible; +- ARIA labels are configurable and localizable; +- handles expose labels and connection state; +- controls and minimap have explicit ARIA labels; +- reduced-motion mode avoids animated transitions; +- tests cover keyboard movement, selection, deletion, and ARIA attributes. + +Proposed props: + +```ts +nodesFocusable?: boolean; +edgesFocusable?: boolean; +keyboardA11y?: boolean; +autoPanOnNodeFocus?: boolean; +ariaLabelConfig?: WireAriaLabelConfig; +``` + +### 12. Package Add-Ons And Examples + +Mature diagram packages succeed partly because the docs and examples show every +common workflow. Wire should add examples before adding too many low-level +features. + +Needed examples: + +- controlled diagram editor; +- custom product shell with `WireProvider` and `WireCanvas`; +- production inspector with grouped options; +- custom node types and custom field types; +- port-based branching node; +- edge labels and edge toolbar; +- save/restore with autosave and dirty state; +- read-only viewer; +- keyboard accessibility; +- theme override and unstyled mode; +- large diagram performance baseline. + +## Phased Implementation Plan + +### Phase A - Package Styling And Export Foundation + +Work: + +- build `packages/wire-react/src/styles.css`; +- emit `dist/styles.css` during package build; +- add `./styles.css` export and include CSS in package files; +- convert current Tailwind-dependent component classes into package-owned CSS + classes or keep Tailwind only inside demos; +- add `unstyled`, `classNames`, and `colorMode` design notes in docs. + +Acceptance: + +- a new Vite app can import `@aigentive/wire-react` and + `@aigentive/wire-react/styles.css` with no Tailwind config; +- dark/light mode works through documented variables; +- existing playground still renders correctly; +- package build and React tests pass. + +### Phase B - Controlled State And Instance API + +Work: + +- add controlled provider props for selection, viewport, mode, and dirty; +- add `useWireDiagramState`, `useWireSelectionState`, `useWireViewportState`; +- add `useWireInstance` and `onInit`; +- document controlled and uncontrolled patterns. + +Acceptance: + +- host apps can persist diagram, selection, viewport, and mode externally; +- `useWireInstance().fitView()` and viewport commands work from custom panels; +- tests cover controlled/uncontrolled transitions and callbacks. + +### Phase C - Options And Production Inspector + +Work: + +- extend the current `WireOptionSpec` and `WireOptionCatalog` exports in place; +- add grouped/ordered sections, field-level validation, hidden/disabled/read-only + predicates, commit modes, and undo grouping; +- merge `WireOptionPanel` behavior into a richer `WireInspector`; +- keep `WireOptionPanel` as a smaller primitive or compatibility wrapper; +- add custom field renderer registry. + +Acceptance: + +- current `WireOptionCatalog` continues to work; +- current catalogs can show groups, sections, errors, hidden fields, and custom + fields; +- typing text does not create one undo entry per keystroke unless requested; +- inspector can be reused in a custom shell without `WireWorkspace`. + +### Phase D - Registries, Handles, And Edge Extensions + +Work: + +- add `nodeTypes`, `edgeTypes`, `handleTypes`, and fallback warnings; +- add `WireHandle` and `WirePortSpec`; +- add `sourcePortId` and `targetPortId` support where the core schema allows it, + or define the required core schema change first; +- add edge labels, edge toolbar slots, and connection validation callbacks. + +Acceptance: + +- consumers can build branch nodes with multiple named outputs; +- edge connection can be validated before reducer dispatch; +- unknown custom types fail soft with useful warnings; +- tests cover multiple handles and port-specific edges. + +### Phase E - Accessibility And Large Diagram Readiness + +Work: + +- implement keyboard focus, selection, movement, deletion, and focus panning; +- add configurable ARIA labels; +- add roving-focus or stable tab-order strategy; +- profile rerenders and add selector hook where needed; +- add optional viewport culling or memoization strategy for large diagrams. + +Acceptance: + +- keyboard-only node selection, movement, deletion, and viewport focus are tested; +- ARIA labels can be localized by host apps; +- a large fixture has a documented render/update budget; +- no common panel re-renders on every pointer move unless it subscribes to that + state. + +### Phase F - Production Docs, Examples, And Release Discipline + +Work: + +- add examples listed above to the playground or docs app; +- add API reference tables for provider, canvas, inspector, option specs, and + hooks; +- add visual or browser smoke tests for the main examples; +- consider changesets for package releases and changelog generation; +- document the additive option-field changes on the existing React package API. + +Acceptance: + +- each public API has at least one runnable example; +- package consumers can follow a docs path from read-only viewer to custom + production editor; +- changelog entries explain breaking and additive changes per package; +- CI covers build, typecheck, unit tests, and browser smoke tests for the React + package. + +## Proposed Priority Order + +1. **CSS export and theme contract**: this removes the biggest npm reuse + friction. +2. **Controlled state and instance hook**: this lets product shells integrate + autosave, URL state, and custom toolbars. +3. **Options and inspector**: this turns the current demo-level option panel + into a real production form system. +4. **Handles/ports and registries**: this unlocks advanced workflow diagrams. +5. **Accessibility and performance**: this hardens the editor for enterprise + usage. +6. **Examples and release docs**: this makes the package adoptable without + internal support. + +## Non-Goals + +- Do not replace the Wire schema with generic graph-editor-style node/edge + objects. +- Do not make consumer option keys canonical Wire schema fields. +- Do not require Tailwind from npm consumers. +- Do not expose internal canvas geometry as the main public API. +- Do not make custom React component instances serializable or persistent. + +## Handoff Tasks + +| Task | Owner | Notes | +|---|---|---| +| Decide if CSS output should be handcrafted CSS or compiled from Tailwind | React package owner | Handcrafted CSS gives the cleanest package contract. | +| Define exact `WireOptionSpec` predicate/validator signatures | React package owner + product integrator | Keep predicates serializable only if catalogs will cross network boundaries. | +| Confirm whether core schema needs port fields | Core + React package owners | Avoid adding React-only port metadata if edges must persist it. | +| Choose example app structure | Docs owner | Playground routes are already established and can host examples. | +| Decide release workflow for React package | Maintainer | Changesets are worth considering once packages diverge in maturity. | + +## Review Alignment Checklist + +- The spec keeps Wire's canonical diagram contract intact. +- The spec treats external diagram-editor packages as product-surface + inspiration, not runtime dependency requirements. +- The implementation plan is phased and testable. +- The highest-risk user-facing gaps are covered: options, inspector, controlled + state, styling, handles, accessibility, and examples. +- The package can become production reusable without forcing consumers to adopt + the playground's app shell. diff --git a/docs/WIRE_OPTIONS_CATALOG_SPEC.md b/docs/WIRE_OPTIONS_CATALOG_SPEC.md index 570238a..778c719 100644 --- a/docs/WIRE_OPTIONS_CATALOG_SPEC.md +++ b/docs/WIRE_OPTIONS_CATALOG_SPEC.md @@ -14,9 +14,9 @@ This spec covers: - `packages/wire-react/src/options.ts` — `WireOptionSpec`, `WireOptionCatalog`, helpers (`readWireOption`, `patchWireOption`, `wireNodeOptions`, `wireOptionSpecsForNode`, `inferOptionType`). - `packages/wire-react/src/components/WireOptionPanel.tsx` — renderer for a list of option specs. -- `WireProvider` accepts a diagram and dispatches actions. It does **not** accept `optionCatalog` today. -- `WireWorkspace` and `WireCanvas` accept `optionCatalog` as the implemented catalog entry points; `WireWorkspace` passes it to `WireCanvas` and `WireOptionPanel`. -- `WireInspector` (the right rail in the demo apps) currently shows Title / Description / Appearance only. **It does not render `WireOptionPanel`** — this is the primary gap. +- `WireProvider` accepts a diagram and dispatches actions. It intentionally does **not** accept `optionCatalog`. +- `WireWorkspace`, `WireCanvas`, `WireInspector`, and `WireOptionPanel` are the implemented catalog entry points. +- `WireInspector` renders Configure, Style, Validation, JSON, and Edge tabs. Configure uses `WireOptionCatalog`; Style preserves title, description, and appearance controls. ## Out of scope @@ -26,20 +26,24 @@ This spec covers: ## Work items -### 1. Library — split `WireInspector` into Configure / Style tabs +### 1. Library — tabbed `WireInspector` **File**: `packages/wire-react/src/components/WireInspector.tsx` -Restructure the inspector into a tabbed surface. Title and Description stay above the tabs (they apply regardless of tab). Below the title block, a 2-segment control switches between: +The inspector is a tabbed surface. The implemented tabs are: -- **Configure** *(default)* — kind-specific options driven by the `WireOptionCatalog` prop supplied to `WireWorkspace` or `WireCanvas`. Renders specs from `wireOptionSpecsForNode(optionCatalog, node)` using `WireOptionPanel` (or inline equivalent reusing the existing recessed input style). Each row patches via `patchWireOption(node, spec, value)` and dispatches `node.patch`. When the catalog has zero specs for the active kind, render an empty state hint (`"No configuration for ${kind} nodes yet — pass an optionCatalog to enable them."`). -- **Style** — everything currently in the Appearance section: Card style preset (Neutral / Success / …), Fill / Border / Text colors, Border width, Radius, Shadow, Reset. +- **Configure** — kind-specific options driven by the `WireOptionCatalog` prop supplied to `WireWorkspace`, `WireInspector`, or `WireOptionPanel`. Each row patches via `patchWireOption(node, spec, value)` and dispatches `node.patch`. +- **Style** — title, description, and appearance controls. +- **Validation** — target-specific validation issues. +- **JSON** — read-only node or edge JSON. +- **Edge** — explicit persisted edge fields (`label`, `tone`, `routing`) plus read-only connection facts. -Tab segment styling matches the existing wire-design segmented controls (slate-900 active fill, white text). Tab state is component-local (`useState`); selection-change resets to "Configure" when switching nodes. +The implemented catalog path is prop-based. Do not document or add +``; the provider remains catalog-free. -The implemented catalog path is prop-based: consumers pass `optionCatalog` to `WireWorkspace` or `WireCanvas`. A future provider-context API can be added later, but this spec must not document `` as current behavior until that prop exists. - -**Acceptance**: passing an `optionCatalog` to `` or `` is enough to make the Configure surface populate. The Style tab keeps every existing control, just relocated. +**Acceptance**: passing an `optionCatalog` to ``, ``, +or `` is enough to make Configure fields populate. The Style +tab keeps every existing appearance control. ### 2. Demo — example catalogs @@ -151,5 +155,5 @@ The page is fully static (renders the specimen with the existing canvas) — no ## Risks / decisions deferred - **Catalog layering** (project-defined options + global defaults): not implementing now. If consumers ask, the right shape is `optionCatalog: WireOptionCatalog | WireOptionCatalog[]` and merge per-kind on read. -- **Validation**: option specs don't carry validators today (no `pattern`, no `required`). If a consumer wants validation, they do it on save, not in the spec. Adding validators is a future spec. -- **Conditional fields** (e.g. show `schedule` only when `source === "cron"`): not in this pass. The current inspector shows everything in the catalog. Adding `visibleWhen` is a clean extension if needed. +- **Validation**: `required`, `validate`, `parse`, and `format` are supported as runtime catalog functions. Persisted diagrams must not serialize those functions. +- **Conditional fields**: `hidden`, `disabled`, and `readOnly` predicates are supported as runtime catalog functions. Persisted diagrams must not serialize those functions. diff --git a/docs/llm/README.md b/docs/llm/README.md index b261c3a..5210c9f 100644 --- a/docs/llm/README.md +++ b/docs/llm/README.md @@ -30,7 +30,7 @@ Use `@aigentive/wire-react` for React UI: - `WireToolbar`, `WirePalette`, `WireInspector`, and `WireValidationPanel` for standard controls. -Persist `WireDiagram` from `onChange`. Do not persist React Flow nodes as the +Persist `WireDiagram` from `onChange`. Do not persist adapter nodes as the application contract. ## Hosted Chat Key @@ -60,7 +60,7 @@ Cloud API key used by MCP clients. ## Banned Agent Outputs - Mermaid as primary state. -- React Flow JSON as primary state. +- Adapter JSON as primary state. - Node fields named `connectsTo`, `source`, `target`, `next`, or uppercase `kind`. - `from: null`; omit `from` when no source exists. diff --git a/docs/releases/wire-react-1.1.0.md b/docs/releases/wire-react-1.1.0.md new file mode 100644 index 0000000..6d1f0a4 --- /dev/null +++ b/docs/releases/wire-react-1.1.0.md @@ -0,0 +1,9 @@ +# @aigentive/wire-react 1.1.0 + +This minor release promotes the React package for production diagram reuse. + +- Adds first-class package CSS via `@aigentive/wire-react/styles.css`. +- Extends current `WireWorkspace`, `WireCanvas`, `WireInspector`, panels, toolbar, palette, list, card, and group components with styling and theming controls. +- Adds canvas fit selection, large-diagram mode, simplified minimap behavior, and skip-to-inspector focus support. +- Moves wire-react performance validation to a browser performance harness using a production React bundle. +- Keeps `WireDiagram` and reducer actions as the durable state contract. diff --git a/package-lock.json b/package-lock.json index b85d898..07cabfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aigentive/wire-monorepo", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aigentive/wire-monorepo", - "version": "1.0.4", + "version": "1.1.0", "license": "Apache-2.0", "workspaces": [ "packages/*", @@ -31,7 +31,7 @@ "dependencies": { "@aigentive/wire-core": "^1.0.4", "@aigentive/wire-mcp": "^1.0.7", - "@aigentive/wire-react": "^1.0.4", + "@aigentive/wire-react": "^1.1.0", "@aigentive/wire-renderers": "^1.0.2", "@libsql/client": "^0.17.3", "@resvg/resvg-js": "^2.6.2", @@ -6135,7 +6135,7 @@ }, "packages/wire-react": { "name": "@aigentive/wire-react", - "version": "1.0.4", + "version": "1.1.0", "license": "Apache-2.0", "dependencies": { "@aigentive/wire-core": "^1.0.4" diff --git a/package.json b/package.json index 08b43d5..1446b9c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aigentive/wire-monorepo", - "version": "1.0.4", + "version": "1.1.0", "private": true, "type": "module", "description": "Wire — LLM-first diagram library with MCP. Agent-operable diagrams.", @@ -16,10 +16,27 @@ "scripts": { "build": "npm run build --workspace @aigentive/wire-core && npm run build --workspace @aigentive/wire-renderers && npm run build --workspace @aigentive/wire-react && npm run build --workspace @aigentive/wire-mcp && npm run build --workspace @aigentive/wire-cli", "build:core": "npm run build --workspace @aigentive/wire-core", + "build:playground": "npm run build --workspace @aigentive/wire-playground", "typecheck": "npm run typecheck --workspaces --if-present", "test": "npm run build && vitest run", "test:react": "npm run build:core && npm run test --workspace @aigentive/wire-react", "test:react:coverage": "npm run build:core && npm run test:coverage --workspace @aigentive/wire-react", + "test:docs-source-names": "node scripts/check-docs-source-names.mjs", + "test:docs-links": "node scripts/check-wire-react-release.mjs docs-links", + "test:docs-snippets": "node scripts/check-wire-react-release.mjs docs-snippets", + "test:consumer-pack": "node scripts/check-wire-react-release.mjs consumer-pack", + "test:legacy-utility-consumer": "node scripts/check-wire-react-release.mjs legacy-utility-consumer", + "test:api-compat": "node scripts/check-wire-react-release.mjs api-compat", + "test:persistence": "npm run build:core && node scripts/check-wire-react-release.mjs persistence", + "test:performance": "npm run build --workspace @aigentive/wire-core && npm run build --workspace @aigentive/wire-react && node scripts/check-wire-react-performance.mjs", + "test:release-notes": "node scripts/check-wire-react-release.mjs release-notes", + "check:semver": "node scripts/check-wire-react-release.mjs semver", + "check:registry-version": "node scripts/check-wire-react-release.mjs registry-version", + "check:workspace-ranges": "node scripts/check-wire-react-release.mjs workspace-ranges", + "check:lockfile": "node scripts/check-wire-react-release.mjs lockfile", + "check:api-diff": "node scripts/check-wire-react-release.mjs api-diff", + "test:react:release": "npm run build --workspace @aigentive/wire-react && npm run test --workspace @aigentive/wire-react && npm run test:docs-source-names && npm run test:docs-links && npm run test:docs-snippets && npm run build:playground && npm run test:persistence && npm run test:performance && npm run test:api-compat && npm run test:legacy-utility-consumer && npm run test:consumer-pack && npm run test:release-notes && npm run check:workspace-ranges && npm run check:lockfile && npm run check:api-diff && npm run check:semver && npm pack --workspace @aigentive/wire-react --dry-run", + "release:wire-react:dry-run": "node scripts/check-wire-react-release.mjs release-dry-run", "test:e2e": "playwright test", "test:watch": "npm run build:core && vitest", "dev:mcp": "npm run dev --workspace @aigentive/wire-mcp", diff --git a/packages/wire-core/src/schema.ts b/packages/wire-core/src/schema.ts index 1dbcb29..109eeb4 100644 --- a/packages/wire-core/src/schema.ts +++ b/packages/wire-core/src/schema.ts @@ -58,7 +58,7 @@ export type EdgeRouting = z.infer; /** * Per-node visual overrides. All fields optional — when omitted, falls * back to tone-derived defaults. Renderers and adapters honor these - * uniformly so the same diagram looks identical in SVG and React Flow. + * uniformly so the same diagram looks identical across render targets. */ export const NodeStyleSchema = z.object({ fill: z.string().optional(), diff --git a/packages/wire-core/src/svg.ts b/packages/wire-core/src/svg.ts index 59dfa89..4162b68 100644 --- a/packages/wire-core/src/svg.ts +++ b/packages/wire-core/src/svg.ts @@ -306,7 +306,7 @@ function handleNormal(side: Side): { x: number; y: number } { } /** - * xyflow-style control offset: half the along-axis distance when the target + * Graph-canvas-style control offset: half the along-axis distance when the target * is "ahead" of the source on the handle's direction; small fallback curve * when the target is "behind" (the handle would have to U-turn). */ @@ -419,7 +419,7 @@ function edgePath( const isBackward = (distSrc < 0 || distTgt < 0) && sourceVertical === targetVertical; // Backward edge (target sits "behind" the handle direction): a vanilla - // xyflow-style bezier produces control points that extend OUTWARD past + // A graph-canvas-style bezier produces control points that extend OUTWARD past // the source/target, wrapping the curve off-canvas. Instead we sweep // perpendicularly — out from the handle, over (or under) the diagram, and // back into the target's handle direction. diff --git a/packages/wire-mcp/src/docs-shape.ts b/packages/wire-mcp/src/docs-shape.ts index ae5b3bc..150023c 100644 --- a/packages/wire-mcp/src/docs-shape.ts +++ b/packages/wire-mcp/src/docs-shape.ts @@ -89,7 +89,7 @@ note, group. Use node.from for ordinary connections. Use "conditionId.branch" for condition branches. Do not invent connectsTo, next, source, target, type, label-only -Mermaid, or React Flow JSON as the primary contract. +Mermaid, or adapter JSON as the primary contract. ## Cards and Visual Content @@ -138,7 +138,7 @@ update_edge to patch explicit edges. Use @aigentive/wire-react. Prefer WireWorkspace for full editors and WireProvider + WireCanvas for custom shells. Persist the WireDiagram emitted -from onChange. Do not store React Flow nodes as application state. +from onChange. Do not store adapter nodes as application state. ## Cloud Path @@ -214,7 +214,7 @@ export const LLM_DOCS_SHAPES: Record = { ], avoid: [ "Do not output Mermaid as the canonical artifact.", - "Do not output React Flow JSON as the canonical artifact.", + "Do not output adapter JSON as the canonical artifact.", "Do not invent node fields such as connectsTo, source, target, next, or type." ], routes: [ @@ -300,8 +300,8 @@ export const LLM_DOCS_SHAPES: Record = { "Use WireToolbar, WirePalette, WireInspector, and WireValidationPanel instead of reimplementing standard controls." ], avoid: [ - "Do not import React Flow as the app-level contract.", - "Do not store canvas state as React Flow nodes and edges.", + "Do not import a canvas adapter as the app-level contract.", + "Do not store canvas state as adapter nodes and edges.", "Do not bypass WireProvider when edit state, validation, history, or events matter." ], components: [ @@ -576,8 +576,8 @@ export const LLM_DOCS_RECIPES = { " setDiagram(next)} />" ], avoid: [ - "Do not import React Flow directly for app state.", - "Do not persist React Flow nodes.", + "Do not import a canvas adapter directly for app state.", + "Do not persist adapter nodes.", "Do not invent connectsTo or next fields." ] }, diff --git a/packages/wire-react/README.md b/packages/wire-react/README.md index ece1718..50600d6 100644 --- a/packages/wire-react/README.md +++ b/packages/wire-react/README.md @@ -8,7 +8,13 @@ JSX facade for Wire diagrams. Author diagrams as React components, compile to ca npm install @aigentive/wire-react react react-dom ``` -`` renders as inline SVG by default. `` provides the native interactive canvas; no separate canvas-engine package is required. +Import the package stylesheet once in your app entry: + +```tsx +import "@aigentive/wire-react/styles.css"; +``` + +`` renders as inline SVG by default. `` provides the native interactive canvas; no separate canvas-engine package is required and no utility-class source scan is required. ## Use @@ -79,6 +85,7 @@ For interactive canvases, compile JSX to a Wire diagram and render it through th ```tsx "use client"; +import "@aigentive/wire-react/styles.css"; import { Flow, TriggerNode, @@ -107,14 +114,15 @@ export function MyDiagram() { ## LLM-friendly editor extensions -Most apps should extend the built-in canvas with Wire-level props instead of -importing a third-party graph canvas directly: +Most apps should extend the built-in canvas with Wire-level props and keep +`WireDiagram` plus reducer actions as the app contract: ```tsx import { WireWorkspace, type WireOptionCatalog } from "@aigentive/wire-react"; +import "@aigentive/wire-react/styles.css"; const options: WireOptionCatalog = { ai: [ @@ -139,9 +147,68 @@ export function AgentEditor({ diagram, onChange }) { Option values are serializable Wire data. Runtime render callbacks are React-only and are never stored in canonical JSON. -Cards, node lists, and canvas clicks emit Wire events such as `node.inspect`; -the option panel can follow selection or receive a controlled `nodeId`. This -keeps card rendering decoupled from the sidebar. +Cards, node lists, and canvas clicks emit Wire events such as `node.inspect` and +`edge.click`. `WireInspector` can follow selection or receive explicit `nodeId` +and `edgeId` values, and `WireOptionPanel` can follow selection or receive a +controlled `nodeId`. This keeps card rendering decoupled from sidebars. + +## Production component patterns + +Controlled editor with the packaged shell: + +```tsx +import "@aigentive/wire-react/styles.css"; +import { useState } from "react"; +import { WireWorkspace, type WireDiagram } from "@aigentive/wire-react"; + +export function ProductEditor({ initial }: { initial: WireDiagram }) { + const [diagram, setDiagram] = useState(initial); + return ; +} +``` + +Custom shell with current components: + +```tsx +import "@aigentive/wire-react/styles.css"; +import { + WireCanvas, + WireInspector, + WirePalette, + WireProvider, + WireToolbar, + WireValidationPanel +} from "@aigentive/wire-react"; + +export function CustomEditor({ diagram, onChange }) { + return ( + + + + + + + + ); +} +``` + +Read-only viewer: + +```tsx +import "@aigentive/wire-react/styles.css"; +import { WireViewer } from "@aigentive/wire-react"; + +export function Preview({ diagram }) { + return ; +} +``` + +Theming and design-system integration use current props: `colorMode`, +`unstyled`, `className`, `classNames`, `style`, and CSS variables. Keyboard +navigation, search, connection picking, fit selection, and large-diagram mode are +owned by `WireCanvas`; disable package keyboard handling only with +`keyboardA11y={false}` when your host shell fully replaces it. See the root docs for the full component prop surface: [`docs/REACT_COMPONENTS.md`](../../docs/REACT_COMPONENTS.md). The playground diff --git a/packages/wire-react/package.json b/packages/wire-react/package.json index 89803fd..9ec037a 100644 --- a/packages/wire-react/package.json +++ b/packages/wire-react/package.json @@ -1,6 +1,6 @@ { "name": "@aigentive/wire-react", - "version": "1.0.4", + "version": "1.1.0", "description": "Wire — JSX facade. Author diagrams as React components; compile to canonical Wire JSON.", "license": "Apache-2.0", "repository": { @@ -19,14 +19,20 @@ "./compile": { "types": "./dist/compile.d.ts", "default": "./dist/compile.js" - } + }, + "./styles.css": "./dist/styles.css" }, + "sideEffects": [ + "./src/styles.css", + "./dist/styles.css" + ], "files": [ "dist", "README.md" ], "scripts": { - "build": "tsc -p tsconfig.json", + "clean": "node scripts/build.mjs --clean", + "build": "node scripts/build.mjs", "test": "vitest run --root ../.. packages/wire-react/src", "test:coverage": "vitest run --root ../.. packages/wire-react/src --coverage --coverage.include='packages/wire-react/src/**/*.{ts,tsx}' --coverage.exclude='packages/wire-react/src/**/*.test.{ts,tsx}' --coverage.exclude='packages/wire-react/src/index.ts' --coverage.exclude='packages/wire-react/src/primitives/index.ts' --coverage.exclude='packages/wire-react/src/provider/types.ts' --coverage.thresholds.lines=80 --coverage.thresholds.functions=80 --coverage.thresholds.branches=80 --coverage.thresholds.statements=80", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/packages/wire-react/scripts/build.mjs b/packages/wire-react/scripts/build.mjs new file mode 100644 index 0000000..8913055 --- /dev/null +++ b/packages/wire-react/scripts/build.mjs @@ -0,0 +1,48 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const distDir = resolve(packageRoot, "dist"); +const sourceCss = resolve(packageRoot, "src/styles.css"); +const distCss = resolve(distDir, "styles.css"); + +async function clean() { + await rm(distDir, { recursive: true, force: true }); +} + +function runTsc() { + const tscPath = require.resolve("typescript/bin/tsc"); + return new Promise((resolveRun, rejectRun) => { + execFile( + process.execPath, + [tscPath, "-p", "tsconfig.json"], + { cwd: packageRoot }, + (error, stdout, stderr) => { + if (stdout) process.stdout.write(stdout); + if (stderr) process.stderr.write(stderr); + if (error) { + rejectRun(error); + return; + } + resolveRun(); + } + ); + }); +} + +async function copyCss() { + await mkdir(distDir, { recursive: true }); + await copyFile(sourceCss, distCss); +} + +if (process.argv.includes("--clean")) { + await clean(); +} else { + await clean(); + await runTsc(); + await copyCss(); +} diff --git a/packages/wire-react/src/canvas/WireCanvas.test.ts b/packages/wire-react/src/canvas/WireCanvas.test.ts index 2ee8c63..ce008c5 100644 --- a/packages/wire-react/src/canvas/WireCanvas.test.ts +++ b/packages/wire-react/src/canvas/WireCanvas.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + canvasInteractionModeForWireMode, miniMapViewportRect, resolveWireCanvasInteraction, wireActionsFromCanvasDragCommit @@ -59,6 +60,13 @@ describe("resolveWireCanvasInteraction", () => { elementsSelectable: true }); }); + + it("maps current non-view provider modes to editable canvas interaction", () => { + expect(canvasInteractionModeForWireMode("view")).toBe("view"); + expect(canvasInteractionModeForWireMode("edit")).toBe("edit"); + expect(canvasInteractionModeForWireMode("connect")).toBe("edit"); + expect(canvasInteractionModeForWireMode("comment")).toBe("edit"); + }); }); describe("miniMapViewportRect", () => { diff --git a/packages/wire-react/src/canvas/WireCanvas.tsx b/packages/wire-react/src/canvas/WireCanvas.tsx index 856d4ab..b1eef7b 100644 --- a/packages/wire-react/src/canvas/WireCanvas.tsx +++ b/packages/wire-react/src/canvas/WireCanvas.tsx @@ -3,15 +3,19 @@ import { useCallback, useEffect, + useId, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, + type FocusEvent as ReactFocusEvent, + type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactElement, - type ReactNode + type ReactNode, + type RefObject } from "react"; import type { EdgeLabelStyle, @@ -21,10 +25,12 @@ import type { LayoutDirection, Side, WireAction, + WireDiagram, WireNode } from "@aigentive/wire-core"; import { useWireActions, useWireContext } from "../hooks.js"; -import type { WireMode, WireSelection, WireViewport } from "../provider/types.js"; +import type { WireMode, WireSelection, WireViewport, WireViewportActions } from "../provider/types.js"; +import { normalizeWireSelection, sameWireSelection } from "../provider/runtimeState.js"; import { wireActionsFromSelectionDelete } from "./changeActions.js"; import { buildWireCanvasModel, @@ -36,7 +42,8 @@ import { type Point, type WireCanvasBounds, type WireCanvasEdgeGeometry, - type WireCanvasFrame + type WireCanvasFrame, + type WireCanvasModel } from "./geometry.js"; import { createWireNodeRenderContext, @@ -44,6 +51,8 @@ import { type WireNodeRenderer } from "./nodeTypes.js"; import type { WireOptionCatalog } from "../options.js"; +import { cx, themeClass, type WireColorMode } from "../components/classes.js"; +import { dispatchWireInspectorFocusRequest } from "../components/workspaceFocusEvents.js"; export interface WireEdgeRenderContext { edge: WireCanvasEdgeGeometry["edge"]; @@ -66,6 +75,7 @@ export interface WireCanvasProps { selectOnNodeClick?: boolean; selectOnEdgeClick?: boolean; inspectOnNodeClick?: boolean; + inspectOnEdgeClick?: boolean; clearSelectionOnPaneClick?: boolean; fitView?: boolean; fitViewPadding?: number; @@ -77,12 +87,56 @@ export interface WireCanvasProps { showBackground?: boolean; showControls?: boolean; showMiniMap?: boolean; + readOnly?: boolean; + colorMode?: WireColorMode; + unstyled?: boolean; + classNames?: { + root?: string; + viewport?: string; + background?: string; + node?: string; + group?: string; + edge?: string; + handle?: string; + controls?: string; + minimap?: string; + status?: string; + search?: string; + connectionPicker?: string; + }; + keyboardA11y?: boolean; + nodesFocusable?: boolean; + edgesFocusable?: boolean; + autoPanOnNodeFocus?: boolean; optionCatalog?: WireOptionCatalog; renderNodeCard?: WireNodeRenderer; renderGroup?: WireNodeRenderer; renderEdge?: WireEdgeRenderer; edgeStyle?: EdgeStyle; edgeRouting?: EdgeRouting; + ariaLabelConfig?: { + canvas?: string; + node?: (node: WireNode) => string; + edge?: (edge: WireEdgeRenderContext["edge"]) => string; + handle?: (context: { node: WireNode; side: Side; role: "source" | "target" }) => string; + minimap?: string; + validationStatus?: string; + search?: string; + connectionTarget?: string; + controls?: { + zoomIn?: string; + zoomOut?: string; + fitView?: string; + fitSelection?: string; + }; + }; + isValidConnection?: (context: { + sourceNode: WireNode; + targetNode: WireNode; + sourceSide: Side; + targetSide: Side; + diagram: WireDiagram; + }) => boolean | string; className?: string; style?: CSSProperties; } @@ -90,6 +144,10 @@ export interface WireCanvasProps { const DEFAULT_MIN_ZOOM = 0.15; const DEFAULT_MAX_ZOOM = 4; const DEFAULT_ZOOM_STEP = 1.1; +const DEFAULT_FIT_VIEW_PADDING = 0.2; +const LARGE_DIAGRAM_NODE_THRESHOLD = 1000; +const LARGE_DIAGRAM_EDGE_THRESHOLD = 1200; +const MAX_COMBOBOX_VISIBLE_OPTIONS = 60; const WHEEL_ZOOM_DELTA = 120; const GRID_SIZE = 24; const HANDLE_SIZE = 9; @@ -141,6 +199,53 @@ interface MiniMapRect { height: number; } +interface WireCanvasFocusItem { + type: "node" | "edge"; + id: string; +} + +interface WireCanvasStatus { + message: string; + key: number; +} + +interface WireCanvasSearchState { + query: string; + activeIndex: number; + previousItem: WireCanvasFocusItem | null; +} + +interface WireCanvasSearchResult extends WireCanvasFocusItem { + label: string; + searchText: string; +} + +interface WireCanvasConnectionPickerState { + sourceId: string; + sourceSide: Side; + targetSide: Side; + query: string; + activeIndex: number; + message: string | null; + messageKey: number; +} + +interface WireCanvasConnectionTarget { + nodeId: string; + label: string; + searchText: string; + side: Side; + distance: number; + diagramIndex: number; +} + +interface PendingConnectionFocus { + sourceId: string; + targetId: string; + sourceSide: Side; + targetSide: Side; +} + export function WireCanvas(props: WireCanvasProps): ReactElement { return ; } @@ -150,9 +255,10 @@ function WireCanvasInner({ selectOnNodeClick, selectOnEdgeClick, inspectOnNodeClick = true, + inspectOnEdgeClick = true, clearSelectionOnPaneClick, fitView = true, - fitViewPadding = 0.08, + fitViewPadding = DEFAULT_FIT_VIEW_PADDING, panOnDrag = true, zoomOnScroll = true, zoomStep = DEFAULT_ZOOM_STEP, @@ -161,17 +267,28 @@ function WireCanvasInner({ showBackground = true, showControls = true, showMiniMap = false, + readOnly = false, + colorMode, + unstyled = false, + classNames, + keyboardA11y, + nodesFocusable, + edgesFocusable, + autoPanOnNodeFocus = true, optionCatalog, renderNodeCard, renderGroup, renderEdge, edgeStyle, edgeRouting, + ariaLabelConfig, + isValidConnection, className, style }: WireCanvasProps): ReactElement { const ctx = useWireContext(); const actions = useWireActions(); + const reactId = useId(); const containerRef = useRef(null); const viewportRef = useRef(ctx.viewport); const selectionRef = useRef(ctx.selection); @@ -183,24 +300,44 @@ function WireCanvasInner({ const dragPositionsRef = useRef | undefined>(undefined); const viewportRafRef = useRef(null); const pendingViewportRef = useRef(null); + const pendingViewportEventRef = useRef[1]>(); const dragRafRef = useRef(null); const pendingDragPositionsRef = useRef | null>(null); + const searchInputRef = useRef(null); + const connectionPickerInputRef = useRef(null); + const pendingConnectionFocusRef = useRef(null); const modelBoundsRef = useRef(null); const initialFitDoneRef = useRef(!fitView); + const largeDiagramAnnouncementRef = useRef<{ active: boolean; key: string | null }>({ active: false, key: null }); const [dragPositions, setDragPositions] = useState | undefined>(); const [connection, setConnection] = useState(null); + const [search, setSearch] = useState(null); + const [connectionPicker, setConnectionPicker] = useState(null); + const [slowRender, setSlowRender] = useState(false); const [fitReady, setFitReady] = useState(!fitView); const [canvasSize, setCanvasSize] = useState(); const [measuredSizes, setMeasuredSizes] = useState | undefined>(); - - const effectiveMode = mode ?? ctx.mode; + const [activeItem, setActiveItem] = useState(null); + const [status, setStatus] = useState(null); + const [connectionFeedback, setConnectionFeedback] = useState(null); + + const effectiveMode = mode ?? canvasInteractionModeForWireMode(ctx.mode); + const keyboardEnabled = keyboardA11y ?? true; + const nodeFocusEnabled = keyboardEnabled && (nodesFocusable ?? true); + const edgeFocusEnabled = keyboardEnabled && (edgesFocusable ?? true); + const statusId = `wire-canvas-status-${reactId.replace(/:/g, "")}`; + const searchInputId = `wire-canvas-search-${reactId.replace(/:/g, "")}`; + const searchListId = `wire-canvas-search-results-${reactId.replace(/:/g, "")}`; + const connectionPickerInputId = `wire-canvas-connection-target-${reactId.replace(/:/g, "")}`; + const connectionPickerListId = `wire-canvas-connection-results-${reactId.replace(/:/g, "")}`; + const connectionFeedbackId = `wire-canvas-connection-feedback-${reactId.replace(/:/g, "")}`; const interaction = resolveWireCanvasInteraction({ mode: effectiveMode, selectOnNodeClick, selectOnEdgeClick, clearSelectionOnPaneClick }); - const editable = interaction.editable; + const editable = interaction.editable && !readOnly; const canPan = panOnDrag; const canZoom = zoomOnScroll; @@ -209,6 +346,153 @@ function WireCanvasInner({ [ctx.diagram, dragPositions, edgeRouting, edgeStyle, measuredSizes] ); modelBoundsRef.current = model.bounds; + const largeDiagram = model.frames.length > LARGE_DIAGRAM_NODE_THRESHOLD || model.edges.length > LARGE_DIAGRAM_EDGE_THRESHOLD; + const focusItems = useMemo( + () => canvasFocusItems(model, nodeFocusEnabled, edgeFocusEnabled), + [edgeFocusEnabled, model, nodeFocusEnabled] + ); + const searchIndex = useMemo( + () => search ? canvasSearchIndex(focusItems, model) : [], + [focusItems, model, Boolean(search)] + ); + const searchResults = useMemo( + () => search ? filterCanvasSearchResults(searchIndex, search.query) : [], + [searchIndex, search?.query] + ); + const activeSearchResult = searchResults.length > 0 + ? searchResults[Math.min(search?.activeIndex ?? 0, searchResults.length - 1)] ?? null + : null; + const connectionTargetIndex = useMemo( + () => connectionPicker ? canvasConnectionTargetIndex(model, connectionPicker) : [], + [connectionPicker?.sourceId, connectionPicker?.sourceSide, connectionPicker?.targetSide, model] + ); + const connectionTargets = useMemo( + () => connectionPicker ? filterCanvasConnectionTargets(connectionTargetIndex, connectionPicker.query) : [], + [connectionPicker?.query, connectionTargetIndex] + ); + const activeConnectionTarget = connectionTargets.length > 0 + ? connectionTargets[Math.min(connectionPicker?.activeIndex ?? 0, connectionTargets.length - 1)] ?? null + : null; + const selectedItemCount = ctx.selection.nodeIds.length + ctx.selection.edgeIds.length; + const hasSelection = selectedItemCount > 0; + + const announce = useCallback((message: string) => { + setStatus((current) => ({ message, key: (current?.key ?? 0) + 1 })); + }, []); + + const announceConnectionFeedback = useCallback((message: string) => { + announce(message); + setConnectionFeedback((current) => ({ message, key: (current?.key ?? 0) + 1 })); + }, [announce]); + + const focusCanvasItem = useCallback((item: WireCanvasFocusItem | null) => { + setActiveItem(item); + if (!item) return; + requestAnimationFrameOrTimeout(() => { + focusElementForCanvasItem(containerRef.current, item); + }); + }, []); + + useEffect(() => { + const key = `${ctx.diagram.id ?? "diagram"}:${model.frames.length}:${model.edges.length}`; + const previous = largeDiagramAnnouncementRef.current; + if (largeDiagram) { + if (!previous.active || previous.key !== key) { + announce(`Large diagram mode enabled for ${model.frames.length} nodes and ${model.edges.length} edges.`); + } + largeDiagramAnnouncementRef.current = { active: true, key }; + return; + } + if (previous.active) { + announce("Large diagram mode disabled."); + } + largeDiagramAnnouncementRef.current = { active: false, key }; + }, [announce, ctx.diagram.id, largeDiagram, model.edges.length, model.frames.length]); + + useEffect(() => { + if (!largeDiagram) { + setSlowRender(false); + return undefined; + } + + let complete = false; + const timeout = setTimeout(() => { + if (complete) return; + setSlowRender(true); + announce("Rendering large diagram."); + }, 250); + const finish = () => { + complete = true; + clearTimeout(timeout); + setSlowRender(false); + }; + if (typeof requestAnimationFrame === "undefined") { + const handle = setTimeout(finish, 0); + return () => { + complete = true; + clearTimeout(timeout); + clearTimeout(handle); + }; + } + const frame = requestAnimationFrame(finish); + return () => { + complete = true; + clearTimeout(timeout); + cancelAnimationFrame(frame); + }; + }, [announce, largeDiagram, model.edges.length, model.frames.length]); + + useEffect(() => { + if (!keyboardEnabled) { + setActiveItem(null); + return; + } + if (focusItems.length === 0) { + setActiveItem(null); + return; + } + setActiveItem((current) => current && focusItems.some((item) => sameFocusItem(item, current)) ? current : focusItems[0]!); + }, [focusItems, keyboardEnabled]); + + useEffect(() => { + if (!search) return; + setSearch((current) => current ? { + ...current, + activeIndex: clampIndex(current.activeIndex, searchResults.length) + } : current); + }, [search?.query, searchResults.length]); + + useEffect(() => { + if (!connectionPicker) return; + setConnectionPicker((current) => current ? { + ...current, + activeIndex: clampIndex(current.activeIndex, connectionTargets.length) + } : current); + }, [connectionPicker?.query, connectionPicker?.sourceSide, connectionPicker?.targetSide, connectionTargets.length]); + + useEffect(() => { + if (!search) return; + requestAnimationFrameOrTimeout(() => searchInputRef.current?.focus()); + }, [Boolean(search)]); + + useEffect(() => { + if (!connectionPicker) return; + requestAnimationFrameOrTimeout(() => connectionPickerInputRef.current?.focus()); + }, [Boolean(connectionPicker)]); + + useEffect(() => { + const pending = pendingConnectionFocusRef.current; + if (!pending || !edgeFocusEnabled) return; + const edge = model.edges.find((candidate) => + candidate.edge.from === pending.sourceId + && candidate.edge.to === pending.targetId + && (candidate.edge.fromHandle ?? candidate.sourceSide) === pending.sourceSide + && (candidate.edge.toHandle ?? candidate.targetSide) === pending.targetSide + ); + if (!edge) return; + pendingConnectionFocusRef.current = null; + focusCanvasItem({ type: "edge", id: edge.edge.id }); + }, [edgeFocusEnabled, focusCanvasItem, model.edges]); const handleSlotsByFrame = useMemo(() => { const map = new Map>(); @@ -254,23 +538,27 @@ function WireCanvasInner({ const measure = () => { measureCanvas(); const next = new Map(); - element.querySelectorAll("[data-wire-node]").forEach((nodeElement) => { - const id = nodeElement.dataset.wireNodeId; - if (!id) return; - const width = Math.ceil(nodeElement.offsetWidth); - const height = Math.ceil(nodeElement.offsetHeight); - if (width > 0 && height > 0) next.set(id, { width, height }); - }); - setMeasuredSizes((current) => (sameMeasuredSizes(current, next) ? current : next)); + if (!largeDiagram) { + element.querySelectorAll("[data-wire-node]").forEach((nodeElement) => { + const id = nodeElement.dataset.wireNodeId; + if (!id) return; + const width = Math.ceil(nodeElement.offsetWidth); + const height = Math.ceil(nodeElement.offsetHeight); + if (width > 0 && height > 0) next.set(id, { width, height }); + }); + } + setMeasuredSizes((current) => (sameMeasuredSizes(current, next) ? current : next.size > 0 ? next : undefined)); }; measure(); if (typeof ResizeObserver === "undefined") return undefined; const observer = new ResizeObserver(measure); observer.observe(element); - element.querySelectorAll("[data-wire-node]").forEach((nodeElement) => observer.observe(nodeElement)); + if (!largeDiagram) { + element.querySelectorAll("[data-wire-node]").forEach((nodeElement) => observer.observe(nodeElement)); + } return () => observer.disconnect(); - }, [ctx.diagram, model.frames]); + }, [ctx.diagram, largeDiagram, model.frames]); const dispatchMany = useCallback( (wireActions: WireAction[]) => { @@ -281,7 +569,7 @@ function WireCanvasInner({ ); const setWireViewport = useCallback( - (viewport: WireViewport) => { + (viewport: WireViewport, event?: Parameters[1]) => { const next: WireViewport = { x: viewport.x, y: viewport.y, @@ -289,16 +577,19 @@ function WireCanvasInner({ }; viewportRef.current = next; pendingViewportRef.current = next; + pendingViewportEventRef.current = event; if (viewportRafRef.current !== null) return; if (typeof requestAnimationFrame === "undefined") { - ctx.viewportActions.setViewport(next); + ctx.viewportActions.setViewport(next, event); return; } viewportRafRef.current = requestAnimationFrame(() => { viewportRafRef.current = null; const queued = pendingViewportRef.current; + const queuedEvent = pendingViewportEventRef.current; pendingViewportRef.current = null; - if (queued) ctx.viewportActions.setViewport(queued); + pendingViewportEventRef.current = undefined; + if (queued) ctx.viewportActions.setViewport(queued, queuedEvent); }); }, [ctx.viewportActions.setViewport, maxZoom, minZoom] @@ -318,16 +609,19 @@ function WireCanvasInner({ }, []); const setWireSelection = useCallback( - (selection: WireSelection, source: "canvas" | "api" = "canvas") => { - selectionRef.current = selection; - ctx.selectionActions.setSelection(selection); - ctx.eventActions.emit({ type: "selection.change", source, selection }); + (selection: WireSelection, source: "canvas" | "api" = "canvas", cause: "node" | "edge" | "pane" | "keyboard" | "api" = "api") => { + const previousSelection = selectionRef.current; + const nextSelection = normalizeWireSelection(selection); + if (sameWireSelection(previousSelection, nextSelection)) return; + selectionRef.current = nextSelection; + ctx.selectionActions.setSelection(nextSelection, { source, previousSelection, cause }); + ctx.eventActions.emit({ type: "selection.change", source, selection: nextSelection, previousSelection, cause }); }, [ctx.eventActions.emit, ctx.selectionActions.setSelection] ); - const clearWireSelection = useCallback(() => { - setWireSelection({ nodeIds: [], edgeIds: [] }); + const clearWireSelection = useCallback((cause: "pane" | "keyboard" | "api" = "api") => { + setWireSelection({ nodeIds: [], edgeIds: [] }, "canvas", cause); }, [setWireSelection]); const clearDragPreview = useCallback(() => { @@ -347,11 +641,50 @@ function WireCanvasInner({ if (rect.width <= 0 || rect.height <= 0) return false; const bounds = modelBoundsRef.current; if (!bounds) return false; - setWireViewport(fitViewportForBounds(bounds, rect.width, rect.height, fitViewPadding, minZoom, maxZoom)); + setWireViewport( + fitViewportForBounds(bounds, rect.width, rect.height, fitViewPadding, minZoom, maxZoom), + { source: "canvas", cause: "fit-view", intent: "fit-view" } + ); setFitReady(true); return true; }, [fitViewPadding, maxZoom, minZoom, setWireViewport]); + const fitSelectionToView = useCallback((): boolean => { + const element = containerRef.current; + if (!element) return false; + const rect = element.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return false; + const selectionBounds = boundsForSelection(ctx.selection, model); + if (!selectionBounds) { + announce("No selected items to fit."); + return false; + } + setWireViewport( + fitViewportForBounds(selectionBounds.bounds, rect.width, rect.height, fitViewPadding, minZoom, maxZoom), + { source: "canvas", cause: "fit-view", intent: "fit-selection" } + ); + setFitReady(true); + const selectedFocusItems = selectedFocusItemsForSelection(ctx.selection, model, nodeFocusEnabled, edgeFocusEnabled); + const nextFocus = activeItem && selectedFocusItems.some((item) => sameFocusItem(item, activeItem)) + ? activeItem + : selectedFocusItems[0] ?? null; + if (nextFocus) focusCanvasItem(nextFocus); + announce(`Fitted ${selectionBounds.count} selected ${selectionBounds.count === 1 ? "item" : "items"}.`); + return true; + }, [ + activeItem, + announce, + ctx.selection, + edgeFocusEnabled, + fitViewPadding, + focusCanvasItem, + maxZoom, + minZoom, + model, + nodeFocusEnabled, + setWireViewport + ]); + useIsomorphicLayoutEffect(() => { if (!fitView) { initialFitDoneRef.current = false; @@ -386,25 +719,290 @@ function WireCanvasInner({ return () => observer.disconnect(); }, [fitToView, fitView]); - useEffect(() => { - if (!editable) return undefined; + const closeSearch = useCallback((restoreFocus: boolean) => { + setSearch((current) => { + if (restoreFocus && current?.previousItem) { + focusCanvasItem(current.previousItem); + } + return null; + }); + }, [focusCanvasItem]); + + const openSearch = useCallback((previousItem: WireCanvasFocusItem | null) => { + setConnectionPicker(null); + setSearch({ query: "", activeIndex: 0, previousItem }); + announce("Search opened."); + }, [announce]); + + const closeConnectionPicker = useCallback((restoreSourceFocus: boolean) => { + setConnectionPicker((current) => { + if (restoreSourceFocus && current) focusCanvasItem({ type: "node", id: current.sourceId }); + return null; + }); + }, [focusCanvasItem]); + + const openConnectionPicker = useCallback((item: WireCanvasFocusItem) => { + if (item.type !== "node") return; + const frame = model.framesById.get(item.id); + if (!frame) return; + const sourceSide = sourceSidesForNode(frame.node, model.direction)[0] ?? "right"; + const targetSide = firstTargetSideForConnection(model, item.id) ?? "left"; + setSearch(null); + setConnectionFeedback(null); + setConnectionPicker({ + sourceId: item.id, + sourceSide, + targetSide, + query: "", + activeIndex: 0, + message: null, + messageKey: 0 + }); + announce(`Choose a connection target for ${frame.node.title || frame.id}.`); + }, [announce, model]); + + const commitConnectionPicker = useCallback(() => { + if (!connectionPicker) return; + const target = activeConnectionTarget; + const reject = (message: string) => { + announceConnectionFeedback(message); + setConnectionPicker((current) => current ? { + ...current, + message, + messageKey: current.messageKey + 1 + } : current); + }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; - if (event.key !== "Backspace" && event.key !== "Delete") return; - if (isTextEntryTarget(event.target)) return; + if (!target) { + reject("No valid target is selected."); + return; + } - const nextActions = wireActionsFromSelectionDelete(selectionRef.current, model.edgeById, model.explicitEdgeIds); - if (nextActions.length === 0) return; + const sourceNode = model.nodeById.get(connectionPicker.sourceId); + const targetNode = model.nodeById.get(target.nodeId); + if (!sourceNode || !targetNode) { + reject("No valid target is selected."); + return; + } - event.preventDefault(); - dispatchMany(nextActions); - clearWireSelection(); + const validationResult = isValidConnection?.({ + sourceNode, + targetNode, + sourceSide: connectionPicker.sourceSide, + targetSide: target.side, + diagram: ctx.diagram + }) ?? true; + + if (validationResult !== true) { + reject(typeof validationResult === "string" ? validationResult : "Connection not allowed."); + return; + } + + actions.dispatch({ + type: "edge.connect", + edge: { + from: connectionPicker.sourceId, + to: target.nodeId, + fromHandle: connectionPicker.sourceSide, + toHandle: target.side + } + }); + pendingConnectionFocusRef.current = { + sourceId: connectionPicker.sourceId, + targetId: target.nodeId, + sourceSide: connectionPicker.sourceSide, + targetSide: target.side }; + setConnectionPicker(null); + setConnectionFeedback(null); + announce(`Connected ${sourceNode.title || sourceNode.id} to ${targetNode.title || targetNode.id}.`); + focusCanvasItem({ type: "node", id: connectionPicker.sourceId }); + }, [actions, activeConnectionTarget, announce, announceConnectionFeedback, connectionPicker, ctx.diagram, focusCanvasItem, isValidConnection, model.nodeById]); + + const handleSearchKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (!search) return; + if (event.key === "Escape") { + event.preventDefault(); + closeSearch(true); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + setSearch((current) => current ? { + ...current, + activeIndex: nextCompositeIndex(current.activeIndex, searchResults.length, direction) + } : current); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + setSearch((current) => current ? { + ...current, + activeIndex: event.key === "Home" ? 0 : Math.max(0, searchResults.length - 1) + } : current); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + if (!activeSearchResult) { + announce("No diagram item is selected."); + return; + } + setSearch(null); + focusCanvasItem(activeSearchResult); + announce(`${activeSearchResult.label} focused.`); + } + }, [activeSearchResult, announce, closeSearch, focusCanvasItem, search, searchResults.length]); - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [clearWireSelection, dispatchMany, editable, model.edgeById, model.explicitEdgeIds]); + const handleConnectionPickerKeyDown = useCallback((event: ReactKeyboardEvent) => { + if (!connectionPicker) return; + if (event.key === "Escape") { + event.preventDefault(); + closeConnectionPicker(true); + return; + } + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const direction = event.key === "ArrowDown" ? 1 : -1; + setConnectionPicker((current) => current ? { + ...current, + activeIndex: nextCompositeIndex(current.activeIndex, connectionTargets.length, direction), + message: null + } : current); + return; + } + if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + setConnectionPicker((current) => current ? { + ...current, + activeIndex: event.key === "Home" ? 0 : Math.max(0, connectionTargets.length - 1), + message: null + } : current); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + commitConnectionPicker(); + } + }, [closeConnectionPicker, commitConnectionPicker, connectionPicker, connectionTargets.length]); + + const handleCanvasKeyDown = useCallback( + (event: ReactKeyboardEvent) => { + if (!keyboardEnabled || event.defaultPrevented || shouldIgnoreCanvasKeyboardEvent(event.target, event.currentTarget)) return; + const eventItem = focusItemFromElement(event.target); + const item = eventItem ?? activeItem; + + if (event.key === "Escape") { + if (connectionPicker) { + event.preventDefault(); + closeConnectionPicker(true); + return; + } + if (selectionRef.current.nodeIds.length > 0 || selectionRef.current.edgeIds.length > 0) { + event.preventDefault(); + clearWireSelection("keyboard"); + announce("Selection cleared."); + } + return; + } + + if (event.key === "/" && !event.altKey && !event.ctrlKey && !event.metaKey) { + event.preventDefault(); + openSearch(item); + return; + } + + if ((event.key === "Backspace" || event.key === "Delete") && editable) { + const keyboardSelection = selectionForKeyboardCommand(selectionRef.current, item); + const nextActions = wireActionsFromSelectionDelete(keyboardSelection, model.edgeById, model.explicitEdgeIds); + if (nextActions.length === 0) return; + event.preventDefault(); + dispatchMany(nextActions); + clearWireSelection("keyboard"); + announce("Selection deleted."); + return; + } + + if (event.key.toLowerCase() === "c" && !event.altKey && !event.ctrlKey && !event.metaKey && editable && item?.type === "node") { + event.preventDefault(); + openConnectionPicker(item); + return; + } + + if (event.key === "Enter" && event.shiftKey && !event.altKey && item) { + event.preventDefault(); + if (item.type === "node") { + setWireSelection({ nodeIds: [item.id], edgeIds: [] }, "canvas", "keyboard"); + ctx.eventActions.emit({ type: "node.inspect", source: "canvas", nodeId: item.id, input: "keyboard" }); + } else { + setWireSelection({ nodeIds: [], edgeIds: [item.id] }, "canvas", "keyboard"); + ctx.eventActions.emit({ type: "edge.click", source: "canvas", edgeId: item.id, input: "keyboard", intent: inspectOnEdgeClick ? "inspect" : "select" }); + } + dispatchWireInspectorFocusRequest(event.currentTarget, { item }); + return; + } + + if ((event.key === "Enter" || event.key === " ") && item) { + event.preventDefault(); + if (item.type === "node") { + setWireSelection({ nodeIds: [item.id], edgeIds: [] }, "canvas", "keyboard"); + ctx.eventActions.emit({ type: "node.click", source: "canvas", nodeId: item.id, input: "keyboard" }); + if (inspectOnNodeClick) ctx.eventActions.emit({ type: "node.inspect", source: "canvas", nodeId: item.id, input: "keyboard" }); + } else { + setWireSelection({ nodeIds: [], edgeIds: [item.id] }, "canvas", "keyboard"); + ctx.eventActions.emit({ type: "edge.click", source: "canvas", edgeId: item.id, input: "keyboard", intent: inspectOnEdgeClick ? "inspect" : "select" }); + } + return; + } + + if (event.key.startsWith("Arrow") && editable && item?.type === "node") { + const delta = keyboardNudgeDelta(event); + if (!delta) return; + event.preventDefault(); + const keyboardSelection = selectionForKeyboardCommand(selectionRef.current, item); + if (keyboardSelection.nodeIds.length === 0) return; + if (selectionRef.current.nodeIds.length === 0) { + setWireSelection({ nodeIds: [item.id], edgeIds: [] }, "canvas", "keyboard"); + } + const nextActions: WireAction[] = []; + for (const id of keyboardSelection.nodeIds) { + const frame = model.framesById.get(id); + if (!frame) continue; + nextActions.push({ type: "node.move", id, position: { x: frame.x + delta.x, y: frame.y + delta.y } }); + } + dispatchMany(nextActions); + return; + } + + const nextFocus = nextFocusItemForKey(event, focusItems, item); + if (nextFocus) { + event.preventDefault(); + focusCanvasItem(nextFocus); + } + }, + [ + activeItem, + announce, + clearWireSelection, + closeConnectionPicker, + connectionPicker, + ctx.eventActions, + dispatchMany, + editable, + focusCanvasItem, + focusItems, + inspectOnEdgeClick, + inspectOnNodeClick, + keyboardEnabled, + model.edgeById, + model.explicitEdgeIds, + model.framesById, + openConnectionPicker, + openSearch, + setWireSelection + ] + ); const handleWheelEvent = useCallback( (event: WheelEvent) => { @@ -428,7 +1026,7 @@ function WireCanvasInner({ x: event.clientX - rect.left - world.x * zoom, y: event.clientY - rect.top - world.y * zoom, zoom - }); + }, { source: "canvas", cause: "zoom" }); }, [canZoom, maxZoom, minZoom, setWireViewport, zoomStep] ); @@ -442,6 +1040,7 @@ function WireCanvasInner({ const handlePanePointerDown = useCallback( (event: ReactPointerEvent) => { + if (keyboardEnabled) event.currentTarget.focus(); if (!canPan || event.button !== 0) return; if (isInteractiveTarget(event.target)) return; panStateRef.current = { @@ -452,7 +1051,7 @@ function WireCanvasInner({ }; event.currentTarget.setPointerCapture(event.pointerId); }, - [canPan] + [canPan, keyboardEnabled] ); const updatePan = useCallback( @@ -466,7 +1065,7 @@ function WireCanvasInner({ x: pan.startViewport.x + dx, y: pan.startViewport.y + dy, zoom: pan.startViewport.zoom - }); + }, { source: "canvas", cause: "pan" }); }, [setWireViewport] ); @@ -502,7 +1101,7 @@ function WireCanvasInner({ } if (isInteractiveTarget(event.target)) return; ctx.eventActions.emit({ type: "pane.click", source: "canvas" }); - if (interaction.clearSelectionOnPaneClick) clearWireSelection(); + if (interaction.clearSelectionOnPaneClick) clearWireSelection("pane"); }, [clearWireSelection, ctx.eventActions, interaction.clearSelectionOnPaneClick] ); @@ -512,6 +1111,10 @@ function WireCanvasInner({ if (event.button !== 0) return; if (isHandleTarget(event.target)) return; event.stopPropagation(); + if (nodeFocusEnabled) { + setActiveItem({ type: "node", id: frame.id }); + event.currentTarget.focus(); + } if (!editable) { if (!canPan) return; panStateRef.current = { @@ -524,7 +1127,7 @@ function WireCanvasInner({ return; } if (interaction.selectOnNodeClick && !selectionRef.current.nodeIds.includes(frame.id)) { - setWireSelection({ nodeIds: [frame.id], edgeIds: [] }); + setWireSelection({ nodeIds: [frame.id], edgeIds: [] }, "canvas", "node"); } if (inspectOnNodeClick) { ctx.eventActions.emit({ type: "node.inspect", source: "canvas", nodeId: frame.id }); @@ -547,7 +1150,7 @@ function WireCanvasInner({ }; event.currentTarget.setPointerCapture(event.pointerId); }, - [canPan, ctx.diagram.nodes, ctx.eventActions, editable, inspectOnNodeClick, interaction.selectOnNodeClick, model.frames, model.framesById, setWireSelection] + [canPan, ctx.diagram.nodes, ctx.eventActions, editable, inspectOnNodeClick, interaction.selectOnNodeClick, model.frames, model.framesById, nodeFocusEnabled, setWireSelection] ); const handleNodePointerMove = useCallback((event: ReactPointerEvent) => { @@ -666,6 +1269,7 @@ function WireCanvasInner({ }; connectionStateRef.current = next; setConnection(next); + setConnectionFeedback(null); event.currentTarget.setPointerCapture(event.pointerId); }, [editable] @@ -713,6 +1317,22 @@ function WireCanvasInner({ model.direction ); if (!target || target.nodeId === current.sourceId) return; + const sourceNode = model.nodeById.get(current.sourceId); + const targetNode = model.nodeById.get(target.nodeId); + if (!sourceNode || !targetNode) return; + if (isValidConnection) { + const validationResult = isValidConnection({ + sourceNode, + targetNode, + sourceSide: current.sourceSide, + targetSide: target.side, + diagram: ctx.diagram + }); + if (validationResult !== true) { + announceConnectionFeedback(typeof validationResult === "string" ? validationResult : "Connection not allowed."); + return; + } + } actions.dispatch({ type: "edge.connect", edge: { @@ -722,8 +1342,10 @@ function WireCanvasInner({ toHandle: target.side } }); + setConnectionFeedback(null); + announce(`Connected ${sourceNode.title} to ${targetNode.title}.`); }, - [actions, model.direction, model.framesById] + [actions, announce, announceConnectionFeedback, ctx.diagram, isValidConnection, model.direction, model.framesById, model.nodeById] ); const handleNodeClick = useCallback( @@ -738,7 +1360,7 @@ function WireCanvasInner({ ctx.eventActions.emit({ type: "node.inspect", source: "canvas", nodeId: node.id }); } if (!interaction.selectOnNodeClick) return; - setWireSelection({ nodeIds: [node.id], edgeIds: [] }); + setWireSelection({ nodeIds: [node.id], edgeIds: [] }, "canvas", "node"); }, [ctx.eventActions, inspectOnNodeClick, interaction.selectOnNodeClick, setWireSelection] ); @@ -746,11 +1368,15 @@ function WireCanvasInner({ const handleEdgeClick = useCallback( (event: ReactMouseEvent, edgeId: string) => { event.stopPropagation(); - ctx.eventActions.emit({ type: "edge.click", source: "canvas", edgeId }); + if (edgeFocusEnabled) { + setActiveItem({ type: "edge", id: edgeId }); + focusElementForCanvasItem(containerRef.current, { type: "edge", id: edgeId }); + } + ctx.eventActions.emit({ type: "edge.click", source: "canvas", edgeId, intent: inspectOnEdgeClick ? "inspect" : "select" }); if (!interaction.selectOnEdgeClick) return; - setWireSelection({ nodeIds: [], edgeIds: [edgeId] }); + setWireSelection({ nodeIds: [], edgeIds: [edgeId] }, "canvas", "edge"); }, - [ctx.eventActions, interaction.selectOnEdgeClick, setWireSelection] + [ctx.eventActions, edgeFocusEnabled, inspectOnEdgeClick, interaction.selectOnEdgeClick, setWireSelection] ); const selectedNodeIds = new Set(ctx.selection.nodeIds); @@ -766,26 +1392,90 @@ function WireCanvasInner({ touchAction: "none", userSelect: dragPositions || connection ? "none" : undefined, cursor: panStateRef.current ? "grabbing" : canPan ? "grab" : "default", - ...gridBackground(showBackground), + ...gridBackground(showBackground && !unstyled), ...style, visibility: fitReady ? style?.visibility : "hidden" }), - [canPan, connection, ctx.viewport, dragPositions, fitReady, showBackground, style] + [canPan, connection, ctx.viewport, dragPositions, fitReady, showBackground, style, unstyled] ); return (
    { + if (event.target === event.currentTarget && !activeItem && focusItems[0]) { + setActiveItem(focusItems[0]); + } + }} + onBlur={(event) => { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + if (!search && !connectionPicker) setActiveItem(null); + }} onPointerDown={handlePanePointerDown} onPointerMove={handlePanePointerMove} onPointerUp={handlePanePointerUp} onPointerCancel={handlePanePointerCancel} onClick={handlePaneClick} > + {keyboardEnabled ? ( + + ) : null} + {showBackground ? ( +
    + ) : null}
    setActiveItem({ type: "edge", id: edge.edge.id })} onClick={handleEdgeClick} /> ))} @@ -837,6 +1534,13 @@ function WireCanvasInner({ renderNodeCard, renderGroup }); + const rendererContext = { + ...renderContext, + unstyled, + classNames: { + root: frame.node.kind === "group" ? classNames?.group : classNames?.node + } + }; return (
    { + setActiveItem({ type: "node", id: frame.id }); + if (autoPanOnNodeFocus) ensureFrameVisible(frame, viewportRef.current, canvasSize, setWireViewport); + }} onPointerDown={(event) => handleNodePointerDown(event, frame)} onPointerMove={handleNodePointerMove} onPointerUp={handleNodePointerUp} @@ -866,7 +1580,7 @@ function WireCanvasInner({ ...(frame.node.kind === "group" ? { height: frame.height } : null) }} > - {renderer(renderContext)} + {renderer(rendererContext)}
    {isConnectionCandidate ? (
    - {showMiniMap ? : null} + {search ? ( + setSearch((current) => current ? { ...current, query, activeIndex: 0 } : current)} + onKeyDown={handleSearchKeyDown} + onBlur={(event) => { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + closeSearch(false); + }} + onResultPointerDown={(result) => { + setSearch(null); + focusCanvasItem(result); + announce(`${result.label} focused.`); + }} + /> + ) : null} + + {connectionPicker ? ( + setConnectionPicker((current) => current ? { ...current, query, activeIndex: 0, message: null } : current)} + onSourceSideChange={(sourceSide) => setConnectionPicker((current) => current ? { ...current, sourceSide, activeIndex: 0, message: null } : current)} + onTargetSideChange={(targetSide) => setConnectionPicker((current) => current ? { ...current, targetSide, activeIndex: 0, message: null } : current)} + onActiveIndexChange={(activeIndex) => setConnectionPicker((current) => current ? { ...current, activeIndex, message: null } : current)} + onKeyDown={handleConnectionPickerKeyDown} + onBlur={(event) => { + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + closeConnectionPicker(false); + }} + /> + ) : null} + + {connectionFeedback ? ( +
    + {connectionFeedback.message} +
    + ) : null} + +
    + {status?.message ?? ""} +
    + + {showMiniMap ? ( + + ) : null} {showControls ? ( setWireViewport(zoomViewport(ctx.viewport, zoomStep, minZoom, maxZoom))} - onZoomOut={() => setWireViewport(zoomViewport(ctx.viewport, 1 / zoomStep, minZoom, maxZoom))} + onFitSelection={hasSelection ? fitSelectionToView : undefined} + onZoomIn={() => setWireViewport(zoomViewport(ctx.viewport, zoomStep, minZoom, maxZoom), { source: "canvas", cause: "zoom" })} + onZoomOut={() => setWireViewport(zoomViewport(ctx.viewport, 1 / zoomStep, minZoom, maxZoom), { source: "canvas", cause: "zoom" })} /> ) : null}
    @@ -913,12 +1749,26 @@ function WireCanvasInner({ function WireEdge({ geometry, selected, + focused, + focusable, + statusId, + ariaLabel, + className, + unstyled, renderEdge, + onFocus, onClick }: { geometry: WireCanvasEdgeGeometry; selected: boolean; + focused: boolean; + focusable: boolean; + statusId: string; + ariaLabel: string; + className?: string; + unstyled: boolean; renderEdge?: WireEdgeRenderer; + onFocus: () => void; onClick: (event: ReactMouseEvent, edgeId: string) => void; }): ReactElement { const stroke = selected ? "#2563eb" : geometry.style.stroke; @@ -944,7 +1794,18 @@ function WireEdge({ }; return ( - + {renderEdge ? renderEdge(context) : ( | undefined; connecting: boolean; connectionSourceSide: Side | null; @@ -1049,6 +1914,7 @@ function WireHandles({ data-wire-side={side} data-wire-source-handle={isSource ? "true" : undefined} data-wire-target-handle={isTarget ? "true" : undefined} + className={cx("wire-handle", !unstyled && "wire-handle--styled", className)} disabled={!editable || !isSource} onPointerDown={isSource ? (event) => onSourcePointerDown(event, frame, side) : undefined} onPointerMove={isSource ? onSourcePointerMove : undefined} @@ -1058,20 +1924,22 @@ function WireHandles({ ...handleSlotStyle(side, slotIndex, slotCount, frame.width, frame.height, highlight), width: highlight ? HANDLE_SIZE + 4 : HANDLE_SIZE, height: highlight ? HANDLE_SIZE + 4 : HANDLE_SIZE, - borderRadius: 999, - border: highlight ? "2px solid #2563eb" : "1.5px solid #94a3b8", - background: highlight - ? "#ffffff" - : isSource && isTarget - ? "#2563eb" - : "#ffffff", - boxShadow: highlight ? "0 0 0 3px rgba(37,99,235,0.18)" : "none", padding: 0, opacity: editable ? 1 : 0.68, cursor: editable && isSource ? "crosshair" : "default", pointerEvents: editable || isTarget ? "auto" : "none", - transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease", - zIndex: highlight ? 4 : connecting ? 2 : 1 + zIndex: highlight ? 4 : connecting ? 2 : 1, + ...(!unstyled ? { + borderRadius: 999, + border: highlight ? "2px solid #2563eb" : "1.5px solid #94a3b8", + background: highlight + ? "#ffffff" + : isSource && isTarget + ? "#2563eb" + : "#ffffff", + boxShadow: highlight ? "0 0 0 3px rgba(37,99,235,0.18)" : "none", + transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease" + } : null) }} /> )); @@ -1081,55 +1949,74 @@ function WireHandles({ } function WireControls({ + className, + unstyled, + labels, onFit, + onFitSelection, onZoomIn, onZoomOut }: { + className?: string; + unstyled: boolean; + labels: { zoomIn: string; zoomOut: string; fitView: string; fitSelection: string }; onFit: () => void; + onFitSelection?: () => void; onZoomIn: () => void; onZoomOut: () => void; }): ReactElement { return (
    - + - + - + - Fit + Fit + {onFitSelection ? ( + + + Selection + + ) : null}
    ); } function ControlButton({ label, + unstyled, onClick, divider = false, wide = false, children }: { label: string; + unstyled: boolean; onClick: () => void; divider?: boolean; wide?: boolean; @@ -1150,11 +2037,13 @@ function ControlButton({ display: "inline-flex", alignItems: "center", justifyContent: "center", - border: 0, - borderLeft: divider ? "1px solid var(--wire-canvas-control-divider, rgba(15,23,42,0.08))" : "0", - background: "transparent", - color: "var(--wire-fg-secondary)", - cursor: "pointer" + cursor: "pointer", + ...(!unstyled ? { + border: 0, + borderLeft: divider ? "1px solid var(--wire-canvas-control-divider, rgba(15,23,42,0.08))" : "0", + background: "transparent", + color: "var(--wire-fg-secondary)" + } : null) }} > {children} @@ -1162,14 +2051,312 @@ function ControlButton({ ); } +function WireCanvasSearch({ + inputRef, + inputId, + listId, + label, + className, + unstyled, + query, + results, + activeResult, + onQueryChange, + onKeyDown, + onBlur, + onResultPointerDown +}: { + inputRef: RefObject; + inputId: string; + listId: string; + label: string; + className?: string; + unstyled: boolean; + query: string; + results: WireCanvasSearchResult[]; + activeResult: WireCanvasSearchResult | null; + onQueryChange: (query: string) => void; + onKeyDown: (event: ReactKeyboardEvent) => void; + onBlur: (event: ReactFocusEvent) => void; + onResultPointerDown: (result: WireCanvasSearchResult) => void; +}): ReactElement { + const visibleResults = visibleComboboxOptions( + results, + activeResult, + (left, right) => left.type === right.type && left.id === right.id + ); + return ( +
    + + onQueryChange(event.currentTarget.value)} + onKeyDown={onKeyDown} + style={{ + width: "100%", + minHeight: 32, + boxSizing: "border-box", + border: "1px solid var(--wire-border)", + borderRadius: 6, + background: "var(--wire-bg-surface)", + color: "var(--wire-fg-primary)", + font: "inherit", + fontSize: 12.5, + padding: "4px 8px" + }} + /> +
    + {results.length > 0 ? visibleResults.map(({ item: result, index }) => { + const active = activeResult?.type === result.type && activeResult.id === result.id; + return ( +
    { + event.preventDefault(); + onResultPointerDown(result); + }} + style={{ + borderRadius: 6, + background: active ? "var(--wire-bg-sunken)" : "transparent", + color: "var(--wire-fg-primary)", + cursor: "pointer", + fontSize: 12, + padding: "6px 8px" + }} + > + {result.label} +
    + ); + }) : ( +
    + No results +
    + )} +
    +
    + {results.length === 0 ? "No results" : `${results.length} result${results.length === 1 ? "" : "s"}${activeResult ? `, ${activeResult.label}` : ""}`} +
    +
    + ); +} + +function WireCanvasConnectionPicker({ + inputRef, + inputId, + listId, + feedbackId, + label, + className, + unstyled, + query, + sourceSides, + sourceSide, + targetSides, + targetSide, + results, + activeResult, + message, + messageKey, + onQueryChange, + onSourceSideChange, + onTargetSideChange, + onActiveIndexChange, + onKeyDown, + onBlur +}: { + inputRef: RefObject; + inputId: string; + listId: string; + feedbackId: string; + label: string; + className?: string; + unstyled: boolean; + query: string; + sourceSides: Side[]; + sourceSide: Side; + targetSides: Side[]; + targetSide: Side; + results: WireCanvasConnectionTarget[]; + activeResult: WireCanvasConnectionTarget | null; + message: string | null; + messageKey: number; + onQueryChange: (query: string) => void; + onSourceSideChange: (side: Side) => void; + onTargetSideChange: (side: Side) => void; + onActiveIndexChange: (index: number) => void; + onKeyDown: (event: ReactKeyboardEvent) => void; + onBlur: (event: ReactFocusEvent) => void; +}): ReactElement { + const visibleResults = visibleComboboxOptions( + results, + activeResult, + (left, right) => left.nodeId === right.nodeId + ); + return ( +
    + + onQueryChange(event.currentTarget.value)} + onKeyDown={onKeyDown} + style={{ + width: "100%", + minHeight: 32, + boxSizing: "border-box", + border: "1px solid var(--wire-border)", + borderRadius: 6, + background: "var(--wire-bg-surface)", + color: "var(--wire-fg-primary)", + font: "inherit", + fontSize: 12.5, + padding: "4px 8px" + }} + /> +
    + + +
    +
    + {results.length > 0 ? visibleResults.map(({ item: result, index }) => { + const active = activeResult?.nodeId === result.nodeId; + return ( +
    { + event.preventDefault(); + onActiveIndexChange(index); + inputRef.current?.focus(); + }} + style={{ + borderRadius: 6, + background: active ? "var(--wire-bg-sunken)" : "transparent", + color: "var(--wire-fg-primary)", + cursor: "pointer", + fontSize: 12, + padding: "6px 8px" + }} + > + {result.label} +
    + ); + }) : ( +
    + No valid targets +
    + )} +
    +
    + {message ?? (results.length === 0 ? "No valid targets" : `${results.length} target${results.length === 1 ? "" : "s"}${activeResult ? `, ${activeResult.label}` : ""}`)} +
    +
    + ); +} + function WireMiniMap({ model, viewport, - canvasSize + selection, + canvasSize, + largeDiagram, + ariaLabel, + className, + unstyled }: { model: ReturnType; viewport: WireViewport; + selection: WireSelection; canvasSize?: CanvasSize; + largeDiagram: boolean; + ariaLabel: string; + className?: string; + unstyled: boolean; }): ReactElement { const width = 184; const height = 104; @@ -1187,50 +2374,94 @@ function WireMiniMap({ pad, scale }); + const contentRect: MiniMapRect = { x: pad, y: pad, width: width - pad * 2, height: height - pad * 2 }; + const selectionBounds = largeDiagram ? boundsForSelection(selection, model)?.bounds : null; + const selectionRect = selectionBounds + ? clipMiniMapRect({ + x: toX(selectionBounds.minX), + y: toY(selectionBounds.minY), + width: Math.max(2, selectionBounds.width * scale), + height: Math.max(2, selectionBounds.height * scale) + }, contentRect) + : null; return ( - {model.edges.map((edge) => ( - - ))} - {model.frames.map((frame) => ( + ) : ( + <> + {model.edges.map((edge) => ( + + ))} + {model.frames.map((frame) => ( + + ))} + + )} + {selectionRect ? ( - ))} + ) : null} {viewportRect ? ( @@ -1620,6 +2855,330 @@ function isTextEntryTarget(target: EventTarget | null): boolean { return tagName === "input" || tagName === "textarea" || tagName === "select"; } +function canvasFocusItems(model: WireCanvasModel, nodesFocusable: boolean, edgesFocusable: boolean): WireCanvasFocusItem[] { + return [ + ...(nodesFocusable ? model.frames.filter((frame) => frame.node.kind !== "group").map((frame) => ({ type: "node" as const, id: frame.id })) : []), + ...(edgesFocusable ? model.edges.map((edge) => ({ type: "edge" as const, id: edge.edge.id })) : []) + ]; +} + +function selectedFocusItemsForSelection( + selection: WireSelection, + model: WireCanvasModel, + nodesFocusable: boolean, + edgesFocusable: boolean +): WireCanvasFocusItem[] { + return [ + ...(nodesFocusable + ? selection.nodeIds + .map((id) => model.framesById.get(id)) + .filter((frame): frame is WireCanvasFrame => Boolean(frame && frame.node.kind !== "group")) + .map((frame) => ({ type: "node" as const, id: frame.id })) + : []), + ...(edgesFocusable + ? selection.edgeIds + .filter((id) => model.edgeById.has(id)) + .map((id) => ({ type: "edge" as const, id })) + : []) + ]; +} + +function boundsForSelection( + selection: WireSelection, + model: WireCanvasModel +): { bounds: WireCanvasBounds; count: number } | null { + const frames = new Map(); + let count = 0; + + for (const id of selection.nodeIds) { + const frame = model.framesById.get(id); + if (!frame) continue; + frames.set(frame.id, frame); + count += 1; + } + + for (const id of selection.edgeIds) { + const edge = model.edges.find((candidate) => candidate.edge.id === id); + if (!edge) continue; + count += 1; + const sourceFrame = model.framesById.get(edge.sourceNode.id); + const targetFrame = model.framesById.get(edge.targetNode.id); + if (sourceFrame) frames.set(sourceFrame.id, sourceFrame); + if (targetFrame) frames.set(targetFrame.id, targetFrame); + } + + const bounds = boundsForFrames([...frames.values()]); + return bounds && count > 0 ? { bounds, count } : null; +} + +function boundsForFrames(frames: WireCanvasFrame[]): WireCanvasBounds | null { + if (frames.length === 0) return null; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const frame of frames) { + minX = Math.min(minX, frame.x); + minY = Math.min(minY, frame.y); + maxX = Math.max(maxX, frame.x + frame.width); + maxY = Math.max(maxY, frame.y + frame.height); + } + return { + minX, + minY, + maxX, + maxY, + width: Math.max(1, maxX - minX), + height: Math.max(1, maxY - minY) + }; +} + +function canvasSearchIndex( + items: WireCanvasFocusItem[], + model: WireCanvasModel +): WireCanvasSearchResult[] { + const edgeGeometryById = new Map(model.edges.map((edge) => [edge.edge.id, edge] as const)); + return items + .map((item) => { + const label = canvasFocusItemLabel(item, model, edgeGeometryById); + return { + ...item, + label, + searchText: normalizeSearchText(`${label} ${item.id}`) + }; + }); +} + +function filterCanvasSearchResults( + items: WireCanvasSearchResult[], + query: string +): WireCanvasSearchResult[] { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return items; + return items.filter((item) => item.searchText.includes(normalizedQuery)); +} + +function canvasFocusItemLabel( + item: WireCanvasFocusItem, + model: WireCanvasModel, + edgeGeometryById: ReadonlyMap +): string { + if (item.type === "node") { + const node = model.nodeById.get(item.id); + return node ? `${node.title || node.id} ${node.kind} node` : `${item.id} node`; + } + const geometry = edgeGeometryById.get(item.id); + if (!geometry) return `${item.id} edge`; + return geometry.edge.label + ? `${geometry.edge.label} edge` + : `Edge from ${geometry.edge.from} to ${geometry.edge.to}`; +} + +function canvasConnectionTargetIndex( + model: WireCanvasModel, + picker: WireCanvasConnectionPickerState +): WireCanvasConnectionTarget[] { + const sourceFrame = model.framesById.get(picker.sourceId); + if (!sourceFrame) return []; + const sourcePoint = handlePoint(sourceFrame, picker.sourceSide); + return model.frames + .map((frame, diagramIndex): WireCanvasConnectionTarget | null => { + if (frame.id === picker.sourceId || frame.node.kind === "group") return null; + const targetSides = targetSidesForNode(frame.node, model.direction); + if (targetSides.length === 0) return null; + const side = targetSides.includes(picker.targetSide) ? picker.targetSide : targetSides[0]!; + const targetPoint = handlePoint(frame, side); + const label = frame.node.title || frame.id; + const distance = squaredDistance(sourcePoint, targetPoint); + return { + nodeId: frame.id, + label, + searchText: normalizeSearchText(`${label} ${frame.id}`), + side, + distance, + diagramIndex + }; + }) + .filter((target): target is WireCanvasConnectionTarget => Boolean(target)) + .sort((left, right) => left.distance - right.distance || left.diagramIndex - right.diagramIndex); +} + +function filterCanvasConnectionTargets( + targets: WireCanvasConnectionTarget[], + query: string +): WireCanvasConnectionTarget[] { + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) return targets; + return targets.filter((target) => target.searchText.includes(normalizedQuery)); +} + +function visibleComboboxOptions( + items: T[], + activeItem: T | null, + sameItem: (left: T, right: T) => boolean +): Array<{ item: T; index: number }> { + if (items.length <= MAX_COMBOBOX_VISIBLE_OPTIONS) { + return items.map((item, index) => ({ item, index })); + } + const activeIndex = activeItem ? items.findIndex((item) => sameItem(item, activeItem)) : -1; + if (activeIndex < MAX_COMBOBOX_VISIBLE_OPTIONS || activeIndex < 0) { + return items.slice(0, MAX_COMBOBOX_VISIBLE_OPTIONS).map((item, index) => ({ item, index })); + } + const halfWindow = Math.floor(MAX_COMBOBOX_VISIBLE_OPTIONS / 2); + const start = Math.max( + 0, + Math.min(activeIndex - halfWindow, items.length - MAX_COMBOBOX_VISIBLE_OPTIONS) + ); + return items + .slice(start, start + MAX_COMBOBOX_VISIBLE_OPTIONS) + .map((item, offset) => ({ item, index: start + offset })); +} + +function connectionSourceSides(model: WireCanvasModel, picker: WireCanvasConnectionPickerState): Side[] { + const frame = model.framesById.get(picker.sourceId); + return frame ? sourceSidesForNode(frame.node, model.direction) : [picker.sourceSide]; +} + +function connectionTargetSides(model: WireCanvasModel, activeTarget: WireCanvasConnectionTarget | null): Side[] { + const frame = activeTarget ? model.framesById.get(activeTarget.nodeId) : undefined; + return frame ? targetSidesForNode(frame.node, model.direction) : ["left", "right", "top", "bottom"]; +} + +function firstTargetSideForConnection(model: WireCanvasModel, sourceId: string): Side | undefined { + for (const frame of model.frames) { + if (frame.id === sourceId || frame.node.kind === "group") continue; + const sides = targetSidesForNode(frame.node, model.direction); + if (sides[0]) return sides[0]; + } + return undefined; +} + +function normalizeSearchText(value: string): string { + return value.trim().toLowerCase(); +} + +function squaredDistance(left: Point, right: Point): number { + const dx = left.x - right.x; + const dy = left.y - right.y; + return dx * dx + dy * dy; +} + +function sameFocusItem(left: WireCanvasFocusItem, right: WireCanvasFocusItem): boolean { + return left.type === right.type && left.id === right.id; +} + +function focusElementForCanvasItem(root: HTMLElement | null, item: WireCanvasFocusItem): void { + const elements = root?.querySelectorAll( + item.type === "node" ? "[data-wire-node-id]" : "[data-wire-edge-id]" + ); + const element = [...(elements ?? [])].find((candidate) => + item.type === "node" + ? candidate.dataset.wireNodeId === item.id + : candidate.dataset.wireEdgeId === item.id + ); + element?.focus(); +} + +function focusItemFromElement(target: EventTarget): WireCanvasFocusItem | null { + if (!(target instanceof Element)) return null; + const edge = target.closest("[data-wire-edge-id]"); + if (edge?.dataset.wireEdgeId) return { type: "edge", id: edge.dataset.wireEdgeId }; + const node = target.closest("[data-wire-node-id]"); + if (node?.dataset.wireNodeId) return { type: "node", id: node.dataset.wireNodeId }; + return null; +} + +function shouldIgnoreCanvasKeyboardEvent(target: EventTarget, root: HTMLElement): boolean { + if (target === root) return false; + if (isTextEntryTarget(target)) return true; + if (!(target instanceof Element)) return false; + if (target.closest("[data-wire-keyboard='ignore']")) return true; + const managedItem = target.closest("[data-wire-node-id], [data-wire-edge-id]"); + if (managedItem) return false; + return Boolean(target.closest("button,a[href],summary,[contenteditable='true'],[role='button'],[role='link'],[role='menuitem'],[role='checkbox'],[role='radio'],[role='switch'],[role='slider'],[role='spinbutton'],[role='combobox'],[role='listbox'],[role='textbox'],[role='searchbox'],[role='tab'],[role='option'],[role='treeitem'],[role='gridcell'],[role='menu'],[role='menubar'],[role='tablist'],[role='grid'],[role='tree'],[role='dialog']")); +} + +function keyboardNudgeDelta(event: ReactKeyboardEvent): Point | null { + const amount = event.altKey ? 1 : event.shiftKey ? 32 : 8; + if (event.key === "ArrowLeft") return { x: -amount, y: 0 }; + if (event.key === "ArrowRight") return { x: amount, y: 0 }; + if (event.key === "ArrowUp") return { x: 0, y: -amount }; + if (event.key === "ArrowDown") return { x: 0, y: amount }; + return null; +} + +function selectionForKeyboardCommand(selection: WireSelection, item: WireCanvasFocusItem | null): WireSelection { + if (selection.nodeIds.length > 0 || selection.edgeIds.length > 0 || !item) return selection; + return item.type === "node" + ? { nodeIds: [item.id], edgeIds: [] } + : { nodeIds: [], edgeIds: [item.id] }; +} + +function nextFocusItemForKey( + event: ReactKeyboardEvent, + items: WireCanvasFocusItem[], + current: WireCanvasFocusItem | null +): WireCanvasFocusItem | null { + if (items.length === 0) return null; + const currentIndex = Math.max(0, current ? items.findIndex((item) => sameFocusItem(item, current)) : 0); + if (event.key === "Home") return items[0]!; + if (event.key === "End") return items[items.length - 1]!; + if (event.key === "n") return nextItemOfType(items, currentIndex, "node", 1); + if (event.key === "p") return nextItemOfType(items, currentIndex, "node", -1); + if (event.key === "e") return nextItemOfType(items, currentIndex, "edge", event.shiftKey ? -1 : 1); + return null; +} + +function nextItemOfType(items: WireCanvasFocusItem[], currentIndex: number, type: WireCanvasFocusItem["type"], direction: 1 | -1): WireCanvasFocusItem | null { + if (!items.some((item) => item.type === type)) return null; + for (let offset = 1; offset <= items.length; offset += 1) { + const index = (currentIndex + offset * direction + items.length) % items.length; + const item = items[index]!; + if (item.type === type) return item; + } + return null; +} + +function ariaLabelForNode(node: WireNode, config: WireCanvasProps["ariaLabelConfig"]): string { + const override = config?.node?.(node)?.trim(); + if (override) return override; + return `${node.title || node.id} ${node.kind} node`; +} + +function ariaLabelForEdge(edge: WireEdgeRenderContext["edge"], config: WireCanvasProps["ariaLabelConfig"]): string { + const override = config?.edge?.(edge)?.trim(); + if (override) return override; + return edge.label ? `${edge.label} edge` : `Edge from ${edge.from} to ${edge.to}`; +} + +function nonEmptyString(value: string | undefined, fallback: string): string { + const trimmed = value?.trim(); + return trimmed ? trimmed : fallback; +} + +function ensureFrameVisible( + frame: WireCanvasFrame, + viewport: WireViewport, + canvasSize: CanvasSize | undefined, + setViewport: (viewport: WireViewport, event?: Parameters[1]) => void +): void { + if (!canvasSize) return; + const margin = 24; + const left = frame.x * viewport.zoom + viewport.x; + const right = (frame.x + frame.width) * viewport.zoom + viewport.x; + const top = frame.y * viewport.zoom + viewport.y; + const bottom = (frame.y + frame.height) * viewport.zoom + viewport.y; + let dx = 0; + let dy = 0; + if (left < margin) dx = margin - left; + else if (right > canvasSize.width - margin) dx = canvasSize.width - margin - right; + if (top < margin) dy = margin - top; + else if (bottom > canvasSize.height - margin) dy = canvasSize.height - margin - bottom; + if (dx !== 0 || dy !== 0) { + setViewport({ ...viewport, x: viewport.x + dx, y: viewport.y + dy }, { source: "canvas", cause: "keyboard" }); + } +} + function normalizeWheelDelta(event: { deltaY: number; deltaMode: number }): number { if (event.deltaMode === 1) return event.deltaY * 16; if (event.deltaMode === 2) return event.deltaY * 360; @@ -1629,3 +3188,35 @@ function normalizeWheelDelta(event: { deltaY: number; deltaMode: number }): numb function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } + +function clampIndex(index: number, length: number): number { + if (length <= 0) return 0; + return clamp(index, 0, length - 1); +} + +function nextCompositeIndex(index: number, length: number, direction: 1 | -1): number { + if (length <= 0) return 0; + return (index + direction + length) % length; +} + +function requestAnimationFrameOrTimeout(callback: () => void): void { + if (typeof requestAnimationFrame === "undefined") { + setTimeout(callback, 0); + return; + } + requestAnimationFrame(callback); +} + +function sideSelectStyle(): CSSProperties { + return { + width: "100%", + minHeight: 30, + border: "1px solid var(--wire-border)", + borderRadius: 6, + background: "var(--wire-bg-surface)", + color: "var(--wire-fg-primary)", + font: "inherit", + fontSize: 12, + padding: "3px 6px" + }; +} diff --git a/packages/wire-react/src/components.test.tsx b/packages/wire-react/src/components.test.tsx index a7c9c0e..bb1c916 100644 --- a/packages/wire-react/src/components.test.tsx +++ b/packages/wire-react/src/components.test.tsx @@ -23,6 +23,8 @@ describe("shared editor components", () => { expect(markup).toContain("Memory"); expect(markup).toContain("Guardrail"); expect(markup).toContain("Group"); + expect(markup).toContain("wire-palette wire-palette--styled"); + expect(markup).toContain("wire-palette__item"); expect(markup).toContain("rounded-md"); expect(markup).toContain("bg-wire-kind-trigger-bg"); }); @@ -35,6 +37,8 @@ describe("shared editor components", () => { ); expect(markup).toContain("aria-label=\"Undo\""); + expect(markup).toContain("wire-toolbar wire-toolbar--styled"); + expect(markup).toContain("wire-toolbar__button"); expect(markup).toContain("cursor:not-allowed"); expect(markup).toContain("View"); }); @@ -48,6 +52,7 @@ describe("shared editor components", () => { expect(markup).toContain("Valid"); expect(markup).toContain("Validation"); + expect(markup).toContain("wire-validation-panel wire-validation-panel--styled"); expect(markup).toContain("text-wire-status-valid"); }); @@ -80,6 +85,8 @@ describe("shared editor components", () => { ); expect(markup).toContain("Model"); + expect(markup).toContain("wire-option-panel wire-option-panel--styled"); + expect(markup).toContain("wire-option-field"); expect(markup).toContain("gpt-4.1-mini"); expect(markup).toContain("Temperature"); expect(markup).toContain("value=\"0.2\""); @@ -102,6 +109,8 @@ describe("shared editor components", () => { expect(markup).toContain("Start"); expect(markup).not.toContain("Stage"); + expect(markup).toContain("wire-node-list wire-node-list--styled"); + expect(markup).toContain("wire-node-list__item"); expect(markup).toContain("rounded-lg"); }); diff --git a/packages/wire-react/src/components/WireEditor.tsx b/packages/wire-react/src/components/WireEditor.tsx index 961f976..91fd8c4 100644 --- a/packages/wire-react/src/components/WireEditor.tsx +++ b/packages/wire-react/src/components/WireEditor.tsx @@ -1,27 +1,92 @@ import type { ReactElement } from "react"; -import type { WireDiagram } from "@aigentive/wire-core"; +import type { ApplyWireActionResult, WireAction, WireDiagram } from "@aigentive/wire-core"; import { WireProvider } from "../provider/WireProvider.js"; import { WireCanvas, type WireCanvasProps } from "../canvas/WireCanvas.js"; +import type { WireChangeEvent, WireEvent, WireEventSource, WireMode, WireSelection, WireViewport } from "../provider/types.js"; export interface WireEditorProps extends Omit { diagram?: WireDiagram; defaultDiagram?: WireDiagram; - onChange?: (diagram: WireDiagram) => void; + onChange?: (diagram: WireDiagram, event: WireChangeEvent) => void; + onAction?: (action: WireAction, result: ApplyWireActionResult) => void; + onEvent?: (event: WireEvent) => void; + validateOnChange?: boolean; + history?: boolean; + selection?: WireSelection; + defaultSelection?: WireSelection; + onSelectionChange?: (selection: WireSelection, event: Extract) => void; + viewport?: WireViewport; + defaultViewport?: WireViewport; + onViewportChange?: (viewport: WireViewport, event: { + source: WireEventSource; + viewport: WireViewport; + previousViewport?: WireViewport; + cause?: "pan" | "zoom" | "fit-view" | "keyboard" | "api"; + intent?: "fit-view" | "fit-selection"; + }) => void; + mode?: WireMode; + defaultMode?: WireMode; + onModeChange?: (mode: WireMode, event: { + source: WireEventSource; + mode: WireMode; + previousMode?: WireMode; + cause?: "toolbar" | "keyboard" | "api"; + }) => void; + dirty?: boolean; + defaultDirty?: boolean; + onDirtyChange?: (dirty: boolean, event: { + source: WireEventSource; + dirty: boolean; + previousDirty?: boolean; + cause?: "edit" | "undo" | "redo" | "reset" | "api"; + }) => void; } export function WireEditor({ diagram, defaultDiagram, onChange, + onAction, + onEvent, + validateOnChange, + history, + selection, + defaultSelection, + onSelectionChange, + viewport, + defaultViewport, + onViewportChange, + mode, + defaultMode, + onModeChange, + dirty, + defaultDirty, + onDirtyChange, ...canvasProps }: WireEditorProps): ReactElement { return ( onChange?.(next)} + onChange={onChange} + onAction={onAction} + onEvent={onEvent} + validateOnChange={validateOnChange} + history={history} + selection={selection} + defaultSelection={defaultSelection} + onSelectionChange={onSelectionChange} + viewport={viewport} + defaultViewport={defaultViewport} + onViewportChange={onViewportChange} + mode={mode} + defaultMode={defaultMode} + onModeChange={onModeChange} + dirty={dirty} + defaultDirty={defaultDirty} + onDirtyChange={onDirtyChange} > - + ); } diff --git a/packages/wire-react/src/components/WireInspector.tsx b/packages/wire-react/src/components/WireInspector.tsx index 9ae7ab9..2c84bfd 100644 --- a/packages/wire-react/src/components/WireInspector.tsx +++ b/packages/wire-react/src/components/WireInspector.tsx @@ -1,22 +1,66 @@ -import type { CSSProperties, ReactElement } from "react"; -import type { NodeStyle, Tone, WireNode } from "@aigentive/wire-core"; -import { useWireActions, useWireDiagram, useWireSelection } from "../hooks.js"; +import { + useEffect, + useMemo, + useState, + type CSSProperties, + type KeyboardEvent, + type ReactElement +} from "react"; +import type { EdgeRouting, NodeStyle, Tone, WireEdge, WireNode } from "@aigentive/wire-core"; +import { useWireActions, useWireDiagram, useWireSelection, useWireValidation } from "../hooks.js"; +import { wireOptionSpecsForNode, type WireOptionCatalog } from "../options.js"; import { Eyebrow } from "../primitives/Eyebrow.js"; import { StatusPill } from "../primitives/StatusPill.js"; -import { cx } from "./classes.js"; +import { cx, themeClass, type WireColorMode } from "./classes.js"; +import { resolveWireInspectionTarget, type WireInspectedTarget } from "./inspectionState.js"; +import { WireOptionFieldList, type WireOptionFieldListProps } from "./optionFields.js"; + +type WireInspectorTab = "configure" | "style" | "validation" | "json" | "edge"; export interface WireInspectorProps { + nodeId?: string; + edgeId?: string; + optionCatalog?: WireOptionCatalog; + tabs?: WireInspectorTab[]; + defaultTab?: WireInspectorTab; + readOnly?: boolean; + renderField?: WireOptionFieldListProps["renderField"]; + renderSection?: WireOptionFieldListProps["renderSection"]; + onOptionCommit?: (context: { + node: NonNullable; + option: Parameters[1]; + value: unknown; + action: Parameters[0]; + }) => void; + ariaLabelConfig?: { + tab?: (tab: WireInspectorTab) => string; + optionField?: (option: Parameters>[0]["option"]) => string; + section?: (section: string) => string; + }; + colorMode?: WireColorMode; + unstyled?: boolean; + classNames?: { + root?: string; + tabs?: string; + tab?: string; + panel?: string; + field?: string; + section?: string; + validation?: string; + json?: string; + edge?: string; + }; className?: string; style?: CSSProperties; } const FIELD_LABEL = "text-[11.5px] font-medium text-wire-secondary mb-[3px]"; const TEXT_INPUT = - "w-full rounded-md border border-wire bg-wire-page px-2.5 py-[6px] text-[12.5px] text-wire-primary outline-none transition-colors placeholder:text-wire-muted hover:border-wire-strong focus:border-wire-focus focus:bg-wire-surface focus:shadow-[0_0_0_2px_rgba(37,99,235,0.12)]"; + "w-full rounded-md border border-wire bg-wire-page px-2.5 py-[6px] text-[12.5px] text-wire-primary outline-none transition-colors placeholder:text-wire-muted hover:border-wire-strong focus:border-wire-focus focus:bg-wire-surface focus:shadow-[0_0_0_2px_rgba(37,99,235,0.12)] disabled:cursor-not-allowed disabled:bg-wire-sunken disabled:text-wire-muted"; const INLINE_INPUT = - "w-full rounded-md border border-wire bg-wire-page px-2.5 py-1.5 text-[12px] text-wire-primary outline-none transition-colors placeholder:text-wire-muted hover:border-wire-strong focus:border-wire-focus focus:bg-wire-surface focus:shadow-[0_0_0_2px_rgba(37,99,235,0.12)]"; + "w-full rounded-md border border-wire bg-wire-page px-2.5 py-1.5 text-[12px] text-wire-primary outline-none transition-colors placeholder:text-wire-muted hover:border-wire-strong focus:border-wire-focus focus:bg-wire-surface focus:shadow-[0_0_0_2px_rgba(37,99,235,0.12)] disabled:cursor-not-allowed disabled:bg-wire-sunken disabled:text-wire-muted"; const CLEAR_BUTTON = - "shrink-0 rounded-md border border-wire bg-wire-surface px-2 py-1 text-[11px] font-medium text-wire-tertiary transition-colors hover:border-wire-strong hover:text-wire-primary"; + "shrink-0 rounded-md border border-wire bg-wire-surface px-2 py-1 text-[11px] font-medium text-wire-tertiary transition-colors hover:border-wire-strong hover:text-wire-primary disabled:cursor-not-allowed disabled:text-wire-muted"; type CardStyleMode = Tone | "custom"; @@ -39,66 +83,220 @@ const TONE_OPTIONS: Array<{ value: CardStyleMode; label: string }> = [ { value: "custom", label: "Custom" } ]; -export function WireInspector({ className, style }: WireInspectorProps): ReactElement { +const EDGE_ROUTING_OPTIONS: Array<{ value: EdgeRouting; label: string }> = [ + { value: "bezier", label: "Bezier" }, + { value: "smoothstep", label: "Smoothstep" }, + { value: "step", label: "Step" }, + { value: "straight", label: "Straight" } +]; + +export function WireInspector({ + nodeId, + edgeId, + optionCatalog, + tabs, + defaultTab, + readOnly = false, + renderField, + renderSection, + onOptionCommit, + ariaLabelConfig, + colorMode, + unstyled = false, + classNames, + className, + style +}: WireInspectorProps): ReactElement { const diagram = useWireDiagram(); const actions = useWireActions(); const [selection] = useWireSelection(); - const node = selection.nodeIds.length === 1 - ? diagram.nodes.find((candidate) => candidate.id === selection.nodeIds[0]) - : undefined; + const validation = useWireValidation(); + const target = useMemo( + () => resolveWireInspectionTarget(diagram, { nodeId, edgeId, selection }), + [diagram, edgeId, nodeId, selection] + ); + const targetKey = inspectedTargetKey(target); + const applicableTabs = useMemo( + () => applicableInspectorTabs(target, tabs, optionCatalog, defaultTab), + [defaultTab, optionCatalog, tabs, target] + ); + const [activeTab, setActiveTab] = useState(() => applicableTabs[0] ?? "style"); - if (!node) { + useEffect(() => { + setActiveTab(applicableTabs[0] ?? "style"); + }, [applicableTabs, targetKey]); + + const rootClass = cx( + "wire-inspector", + !unstyled && "wire-inspector--styled grid gap-3 rounded-md bg-wire-surface p-3", + themeClass(colorMode), + classNames?.root, + className + ); + + if (target.type === "empty" || target.type === "mixed" || applicableTabs.length === 0) { return ( -

    No node selected

    +

    + {target.type === "mixed" ? "Select one node or edge" : "No node selected"} +

    ); } + const activePanel = applicableTabs.includes(activeTab) ? activeTab : applicableTabs[0]!; + + return ( + + ); +} + +function ConfigurePanel({ + diagram, + node, + optionCatalog, + readOnly, + renderField, + renderSection, + onOptionCommit, + classNames +}: { + diagram: ReturnType; + node: WireNode; + optionCatalog?: WireOptionCatalog; + readOnly: boolean; + renderField?: WireOptionFieldListProps["renderField"]; + renderSection?: WireOptionFieldListProps["renderSection"]; + onOptionCommit?: WireInspectorProps["onOptionCommit"]; + classNames?: WireInspectorProps["classNames"]; +}): ReactElement { + const actions = useWireActions(); + const specs = wireOptionSpecsForNode(optionCatalog, node); + if (specs.length === 0) { + return

    No options

    ; + } + return ( + { + actions.dispatch(action); + onOptionCommit?.({ node, option, value, action }); + }} + /> + ); +} + +function StylePanel({ + node, + readOnly, + onPatch +}: { + node: WireNode; + readOnly: boolean; + onPatch(patch: Record): void; +}): ReactElement { const appearance = cardAppearanceForNode(node); + const patchNode = (patch: Record): void => { + if (!readOnly) onPatch(patch); + }; const dispatchStylePatch = (patch: Partial>): void => { const stripsTone = "fill" in patch || "stroke" in patch || "textColor" in patch; - actions.dispatch({ - type: "node.patch", - id: node.id, - patch: stripsTone - ? { tone: null, ...patchNodeStyle(node, patch) } - : patchNodeStyle(node, patch) - }); + patchNode(stripsTone + ? { tone: null, ...patchNodeStyle(node, patch) } + : patchNodeStyle(node, patch)); }; return ( -