AI Elements examples

Three round trips, end to end. The server halves use @elirobinson/ai-patterns/server — AI SDK Core with the house voice and the house stream defaults already applied — and the client halves use the vendored components.

Every file below is a real file in this repository, shown by reading it at build time. They are compiled by the repo's own tsc --noEmit, so an example that stops matching the published types is a red build rather than a page that quietly teaches the wrong API. The 'use client' line is stripped from the client files here; put it back at the top when you copy one.

A chat, end to end

The route is six lines because the house voice, forwarded reasoning, forwarded sources and a safe error shape are all inside streamHouseText and toHouseUIMessageResponse. The one thing it cannot supply is the model: the provider and the model id are yours, so the example declares the shape rather than picking one. Pass anthropic('…'), openai('…'), or whatever you configured.

packages/ai-patterns/docs/examples/chat-route.ts
/**
 * A chat route handler, whole. Drop it at `app/api/chat/route.ts`.
 *
 * The house voice, forwarded reasoning, forwarded sources and a safe error shape are all
 * in here — none of them written down. `model` is the one thing this file cannot supply:
 * the provider and the model id are the consumer's choice, so the real route imports
 * `anthropic('…')`, `openai('…')` or whatever it configured, and this example declares the
 * shape instead of picking one.
 *
 * Typechecked by the repo's own `tsc --noEmit` (tsconfig.typecheck.json includes
 * `packages/**`), so the six lines below cannot drift from the export map.
 */

import { convertToModelMessages, type LanguageModel, type UIMessage } from 'ai';

import { streamHouseText, toHouseUIMessageResponse } from '@elirobinson/ai-patterns/server';

declare const model: Exclude<LanguageModel, string>;

export async function POST(request: Request): Promise<Response> {
  const { messages }: { messages: UIMessage[] } = await request.json();

  return toHouseUIMessageResponse(
    streamHouseText({ model, messages: await convertToModelMessages(messages) }),
  );
}

The client is useChat and three vendored components. Conversation keeps the log pinned to the bottom while tokens arrive, Message renders one turn, and PromptInput is a real form — its onSubmit hands over the typed text, so there is no controlled-input state to hold.

Nothing here imports a stylesheet, and nothing here overrides a vendored component from outside. The Tailwind utilities the components carry are compiled through @elirobinson/tokens/tailwind.css and the AI Elements bridge, so the colour is already Miltinson's and already follows all three dials. The .ds-ai-* classes are the other half — shape, density and the editorial decisions a variable bridge cannot express — and they go in through className, which is the package's public API. Three of them are decisions rather than styling: only the user's turn gets a bubble, a mono eyebrow names the speaker instead of a colour implying it, and the honesty footnote under the composer is part of the pattern.

apps/docs/src/examples/ai-elements/chat-client.tsx
/**
 * The client half of the round trip. `chat-route.ts` is the server half.
 *
 * Three vendored components and one hook, and nothing between them: `useChat`
 * holds the message list and the stream status, `Conversation` keeps the log
 * pinned to the bottom while tokens arrive, and `PromptInput` is a real form —
 * its `onSubmit` hands over the typed text, so there is no controlled-input
 * state to keep here.
 *
 * The Tailwind utilities the vendored components carry are compiled through
 * `@elirobinson/tokens/tailwind.css` and the AI Elements bridge, so the colours
 * are already Miltinson's and already follow all three dials. What the classes
 * below add is the half a variable bridge cannot express — shape, density and
 * the editorial decisions. They are `.ds-ai-*` classes from
 * `app/ai-theme/ai-core.css`, passed in through `className`, which is the
 * package's public API. Nothing here overrides a vendored component from
 * outside, and no vendored component is recreated.
 *
 * Three of those decisions are load-bearing and are the reason the markup below
 * is not the smallest thing that renders:
 *
 *  - ONLY THE USER'S TURN GETS A BUBBLE. The assistant's turn is flat prose.
 *    `Message` sets `.is-user` / `.is-assistant` on its own wrapper and the
 *    stylesheet keys off that, so this file passes one class to both and the
 *    asymmetry comes from the layer.
 *  - A MONO EYEBROW NAMES THE SPEAKER, rather than a coloured bubble implying
 *    it. A colour is not a name, and a screen reader cannot read one out.
 *  - THE FOOTNOTE IS PART OF THE PATTERN. An honesty line under the composer is
 *    not decoration, and removing it is a product decision, not a tidy-up.
 */

import { useChat } from '@ai-sdk/react';

import {
  Conversation,
  ConversationContent,
  ConversationScrollButton,
} from '@elirobinson/ai-elements/components/conversation';
import {
  Message,
  MessageContent,
  MessageResponse,
} from '@elirobinson/ai-elements/components/message';
import {
  PromptInput,
  PromptInputBody,
  PromptInputFooter,
  PromptInputSubmit,
  PromptInputTextarea,
} from '@elirobinson/ai-elements/components/prompt-input';

const SPEAKER: Record<string, string> = { user: 'You', assistant: 'Assistant' };

export function Chat() {
  const { messages, sendMessage, status, stop } = useChat();

  return (
    <div className="flex h-full flex-col">
      <Conversation className="ds-ai-conversation">
        <ConversationContent className="ds-ai-conversation__content">
          {messages.length === 0 ? (
            <div className="ds-ai-conversation__empty">
              <h3>Ask about anything in the catalogue</h3>
              <p>Answers cite their sources. Tool calls are shown as they run.</p>
            </div>
          ) : null}

          {messages.map((message) => (
            <Message className="ds-ai-message" from={message.role} key={message.id}>
              {/* Not inside MessageContent: the eyebrow sits above the bubble,
                  not in it, and on a user turn the layer aligns it to the end
                  edge to match. */}
              <p className="ds-ai-message__speaker">{SPEAKER[message.role] ?? message.role}</p>
              <MessageContent className="ds-ai-message__content">
                {message.parts.map((part, index) =>
                  part.type === 'text' ? (
                    /* Parts have no id of their own, and their order within a
                       message is stable, so the index is the key. */
                    <MessageResponse className="ds-ai-response" key={index}>
                      {part.text}
                    </MessageResponse>
                  ) : null,
                )}
              </MessageContent>
            </Message>
          ))}
        </ConversationContent>
        <ConversationScrollButton />
      </Conversation>

      <PromptInput
        className="ds-ai-composer"
        onSubmit={(message) => {
          if (message.text.trim() === '') {
            return;
          }
          sendMessage({ text: message.text });
        }}
      >
        <PromptInputBody>
          <PromptInputTextarea className="ds-ai-composer__field" />
        </PromptInputBody>
        <PromptInputFooter className="ds-ai-composer__toolbar">
          <div className="ds-ai-composer__tools" />
          {/* `status` drives the button between send, stop and disabled — the
              same value `useChat` reports, so the control cannot disagree with
              the stream. The submit is the one amber control in the
              conversation, and it is 44px; everything else in the toolbar is
              the 24px dense tier. */}
          <PromptInputSubmit className="ds-ai-composer__submit" onStop={stop} status={status} />
        </PromptInputFooter>
      </PromptInput>
      <p className="ds-ai-composer__footnote">
        Answers are generated and can be wrong. Check anything that matters.
      </p>
    </div>
  );
}

A tool call a person can read

tool() carries a description written for the model and a schema, and nothing else. The name the stream carries is whatever key the tool set was declared under, so a tool panel has nothing human to lead with and shows searchCatalogue to a reader. That is a gap in the SDK rather than in the UI — the UI renders what the stream contains.

withToolDisplay attaches the label beside the tool, where the person who named it is already looking, and toolDisplayManifest turns the whole set into plain JSON: no symbols, no schemas, no functions.

packages/ai-patterns/docs/examples/tool-route.ts
/**
 * A chat route with one tool, and the words a person should see for it.
 *
 * `tool()` has room for a description written for the *model* and a schema, and
 * that is all — the name the stream carries is whatever key the tool set was
 * declared under. So a tool panel rendering that stream has nothing human to
 * lead with, and shows `searchCatalogue` to a reader. `withToolDisplay` attaches
 * the label beside the tool, where the person who named it is already looking,
 * and returns a new object rather than mutating the definition.
 *
 * `toolDisplayManifest` turns the set into plain JSON — no symbols, no schemas,
 * no functions — which is what a client bundle can hold. A tool with no declared
 * display still gets a record, marked `source: 'fallback'` and labelled from its
 * function name, so a panel never has to branch on absence.
 *
 * Typechecked by the repo's own `tsc --noEmit`, like the two routes beside it.
 */

import { tool, type LanguageModel, type ModelMessage } from 'ai';
import { z } from 'zod';

import { streamHouseText, toHouseUIMessageResponse } from '@elirobinson/ai-patterns/server';
import { toolDisplayManifest, withToolDisplay } from '@elirobinson/ai-patterns/server/tools';

declare const model: Exclude<LanguageModel, string>;
declare function searchCatalogue(query: string, limit: number): Promise<string[]>;

/* Not exported. The tool set holds the schemas and the `execute` functions, so
   the only thing that should leave this module is the manifest below. (It also
   keeps `tsc` quiet: the SDK's inferred tool type is not nameable from outside
   its own package, so exporting this would demand a hand-written annotation
   that throws away the very inference `tool()` exists to give.) */
const tools = {
  searchCatalogue: withToolDisplay(
    tool({
      description: 'Search the product catalogue and return matching product names.',
      inputSchema: z.object({
        query: z.string().describe('What to search for.'),
        limit: z.number().int().min(1).max(20).default(5),
      }),
      execute: ({ query, limit }) => searchCatalogue(query, limit),
    }),
    {
      label: 'Search the catalogue',
      description: 'Looks products up by name.',
      runningLabel: 'Searching the catalogue',
    },
  ),
};

/** The half that crosses to the client. Serialisable by construction. */
export const toolDisplay = toolDisplayManifest(tools);

export async function POST(request: Request): Promise<Response> {
  const { messages }: { messages: ModelMessage[] } = await request.json();

  return toHouseUIMessageResponse(streamHouseText({ model, messages, tools }));
}

On the client, ToolHeader takes a title. toolDisplayName is the function that never returns an identifier — it reads the manifest, and falls back to a humanised form of the name for a tool nobody labelled, so the panel does not have to branch on absence.

apps/docs/src/examples/ai-elements/tool-panel.tsx
/**
 * The other end of the display metadata: a tool panel that leads with a phrase.
 *
 * `ToolHeader` takes a `title`, and without one it renders the tool's `type` —
 * `tool-searchCatalogue`. `toolDisplayName` is the function that never returns
 * an identifier: it reads the manifest the route built, and falls back to a
 * humanised form of the name for a tool nobody labelled.
 *
 * `getToolName` is the SDK's, and it is the piece that makes the two agree: the
 * manifest is keyed by the name the tool set was declared under, and a UI part's
 * `type` is that name with a `tool-` prefix.
 *
 * The manifest arrives as a prop rather than as an import from the route
 * module. That is the whole reason `toolDisplayManifest` returns plain JSON:
 * importing the route here would pull the tools' schemas and their `execute`
 * functions into the client bundle. Pass it down from a server component, or
 * serve it from a route of its own — either way what crosses is data.
 *
 * `isStaticToolUIPart` rather than `isToolUIPart`: this panel renders the tools
 * the route declared. A dynamic tool — one an MCP server supplied at run time —
 * carries its name in a `toolName` field instead, and is not in the manifest, so
 * `toolDisplayName` would label it from its name. Handle those in a second
 * branch when you have them.
 *
 * The `.ds-ai-tool*` classes come from `app/ai-theme/ai-agent.css` and are
 * passed through `className`, which is the vendored package's public API.
 *
 * TWO NOTES ON STATUS, BOTH DELIBERATE:
 *
 *  - `data-status` carries the AI SDK's own state string, not a translated one.
 *    The layer's selectors accept both spellings — `[data-status='running']`
 *    and `[data-status='input-available']` — so the part's state goes on the
 *    element unchanged and there is no mapping table here to fall out of date.
 *  - The status badge itself renders three channels already, upstream: a lucide
 *    glyph, the word from `statusLabels` ("Running", "Completed", "Error"), and
 *    the colour. That satisfies the accessibility contract without anything
 *    here, which is why this file does not add a fourth. What it cannot do is
 *    wear `.ds-ai-tool__status` — `ToolHeader` builds the badge internally and
 *    exposes no className for it — so the badge keeps its `secondary` variant,
 *    which the bridge points at `--bg-muted` / `--fg` at 19.5:1.
 */

import { getToolName, isStaticToolUIPart, type UIMessage } from 'ai';

import {
  Tool,
  ToolContent,
  ToolHeader,
  ToolInput,
  ToolOutput,
} from '@elirobinson/ai-elements/components/tool';
import { toolDisplayName, type ToolDisplayManifest } from '@elirobinson/ai-patterns/server/tools';

export function MessageTools({
  message,
  display,
}: {
  message: UIMessage;
  display: ToolDisplayManifest;
}) {
  return (
    <>
      {message.parts.filter(isStaticToolUIPart).map((part) => (
        <Tool className="ds-ai-tool" data-status={part.state} key={part.toolCallId}>
          <ToolHeader
            className="ds-ai-tool__header"
            state={part.state}
            title={toolDisplayName(display, getToolName(part))}
            type={part.type}
          />
          <ToolContent className="ds-ai-tool__body">
            <ToolInput className="ds-ai-tool__section" input={part.input} />
            <ToolOutput
              className="ds-ai-tool__section"
              errorText={part.errorText}
              output={part.output}
            />
          </ToolContent>
        </Tool>
      ))}
    </>
  );
}

A structured surface

Not everything an assistant returns is prose. A surface is a Zod schema and the renderer for it, shipped from one subpath, so the model's output is the props of a component with no mapping step on either side of the wire.

packages/ai-patterns/docs/examples/decision-route.ts
/**
 * The structured half: a route that returns one of the surfaces this system owns.
 *
 * `rendered.props` is the props object `<DecisionCard>` takes. There is no mapping step on
 * either side of the wire — that is the whole claim, and it is why the schema and the
 * renderer ship from the same subpath.
 */

import type { LanguageModel } from 'ai';

import { generateHouseSurface } from '@elirobinson/ai-patterns/server';
import { decisionCardSurface } from '@elirobinson/ai-patterns/server/surfaces/decision-card';

declare const model: Exclude<LanguageModel, string>;

export async function POST(request: Request): Promise<Response> {
  const { question }: { question: string } = await request.json();

  const { rendered } = await generateHouseSurface({
    surface: decisionCardSurface,
    model,
    prompt: question,
  });

  return Response.json(rendered);
}

The client renders rendered.props straight into the component. Note where that component comes from: a structured surface is one the system owns, from @elirobinson/react, with the keyboard contract and the stylesheet that implies. Elements is the conversational chrome around it, not a replacement for it.

apps/docs/src/examples/ai-elements/decision-client.tsx
/**
 * The client end of a structured surface. `decision-route.ts` is the server end.
 *
 * The route returns `rendered`, which is `{ kind, component, props }`. `props`
 * is the props object `<DecisionCard>` takes, so there is no mapping step here
 * and none on the server — that is the claim the surface makes, and it is why
 * the schema and the renderer ship from the same subpath.
 *
 * `kind` is the dispatch key, kept apart from the props on purpose: a page that
 * can render more than one surface switches on it, and never has to guess a
 * component from the shape of an object.
 *
 * Note where this one comes from. A structured surface is a component the system
 * owns, from `@elirobinson/react` — it carries the keyboard contract and the
 * stylesheet, and it is not vendored. AI Elements is the conversational chrome
 * around it.
 */

import { useState } from 'react';

import type { RenderedDecisionCard } from '@elirobinson/ai-patterns/server/surfaces/decision-card';
import { DecisionCard } from '@elirobinson/react/components/molecules/DecisionCard';

export function DecisionAnswer({ endpoint }: { endpoint: string }) {
  const [rendered, setRendered] = useState<RenderedDecisionCard | null>(null);

  async function ask(question: string) {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ question }),
    });
    setRendered((await response.json()) as RenderedDecisionCard);
  }

  if (rendered === null) {
    return (
      <button onClick={() => ask('Should we move the launch to March?')} type="button">
        Ask
      </button>
    );
  }

  return <DecisionCard {...rendered.props} />;
}

kind is the dispatch key, deliberately kept apart from the props: a page that can render more than one surface switches on it, and never has to infer a component from the shape of an object. Every surface lives under the @elirobinson/ai-patterns/server/surfaces/ subpath and exports its schema and its renderer; the register is that package's exports map, which your editor completes from the installed version. This page does not list them — a roster typed out here is one a consumer would have to re-read our prose to keep current, and a bumped version already tells them.

What is not on this page

The house system prompt. It is read from contracts.json at run time by houseSystemPrompt(), never copied into a file, and streamHouseText applies it for you — so there is nothing here to paste and nothing to keep in step. Pass system to add your product's own instructions after it; it is additive, and you can say more but never less.

A workflow canvas, with the keyboard path it requires

A node graph is the one pattern here whose primary interaction is dragging, and the canvas stylesheet will not be installed without the alternative. nodesFocusable puts every node in the tab order and keeps xyflow's arrow-key handler live, so a selected node moves from the keyboard and xyflow announces each move in its own live region. Every port is focusable too, and focusing one opens the node's toolbar with a button per other node — "Connect to …" where no edge exists, "Disconnect from …" where one does. That is create and delete with no drag anywhere in the path, and it is the only reason a 12px port is allowed to stay 12px.

Run state renders on three channels: the data-state attribute paints the node's rule and its label colour, and the glyph and the word sit beside it in the markup.

Inspector
Tab to a step and press Enter to select it. Arrow keys move it.
Tab to a step, Enter to select, arrow keys to move. Tab again for a port, then its connect and disconnect actions.
apps/docs/src/examples/ai-elements/workflow-canvas.tsx
/**
 * A workflow canvas, and the keyboard path `ai-canvas.css` refuses to be
 * installed without.
 *
 * The stylesheet's own header states the bargain: a node graph's primary
 * interaction is dragging — moving a node, pulling an edge from one port to
 * another — and SC 2.5.7 wants a single-pointer alternative for both while
 * SC 2.1.1 wants the whole thing operable from a keyboard. A 12px port clears
 * SC 2.5.8 only through that standard's equivalent-alternative exemption. Take
 * the alternative away and the port is an accessibility failure, not a small
 * one. So this file ships the two things the CSS assumes, and the two are the
 * reason the ports may stay at 12px:
 *
 *  1. ARROW-KEY NUDGING. `nodesFocusable` puts every node in the tab order and
 *     `nodesDraggable` keeps xyflow's own arrow-key handler live: Tab to a
 *     node, Enter or Space to select it, then the arrow keys move it — and
 *     xyflow announces each move in its own live region, which is why
 *     `disableKeyboardA11y` is left at its default of false rather than being
 *     passed here to say so. Both props are spread onto `ReactFlow` by
 *     `Canvas`, which spreads `{...props}` last.
 *
 *  2. A MENU PATH TO CREATE AND DELETE A CONNECTION. Every port is focusable,
 *     and focusing one opens the node's `Toolbar` with one button per other
 *     node in the graph — "Connect to …" where no edge exists, "Disconnect
 *     from …" where one does. That is create and delete, from the keyboard,
 *     with no drag anywhere in the path. The toolbar is xyflow's own
 *     `NodeToolbar`, so it positions itself and this file contains no
 *     placement arithmetic.
 *
 * The classes are the ones `ai-canvas.css` publishes — `.ds-ai-canvas`,
 * `.ds-ai-node` and its elements, `.ds-ai-canvas-toolbar`,
 * `.ds-ai-canvas-controls`, `.ds-ai-canvas-panel`. Nothing here invents a
 * class name, and nothing here carries a colour: every value is in the layer.
 *
 * Run state renders on three channels, per the accessibility contract — the
 * `data-state` attribute paints the node's inline-start rule and the state
 * label's colour, and `STATE` below supplies the glyph and the word that go
 * beside it in the markup. Colour alone is SC 1.4.1, and "amber" is not a
 * status a screen reader can read out.
 */

import { useCallback, useEffect, useState } from 'react';
import {
  Handle,
  Position,
  useEdges,
  useEdgesState,
  useNodes,
  useNodesState,
  type Edge,
  type Node,
  type NodeProps,
} from '@xyflow/react';

import { Canvas } from '@elirobinson/ai-elements/components/canvas';
import { Controls } from '@elirobinson/ai-elements/components/controls';
import { Panel } from '@elirobinson/ai-elements/components/panel';
import { Toolbar } from '@elirobinson/ai-elements/components/toolbar';

type RunState = 'idle' | 'running' | 'complete' | 'blocked' | 'error';

/* The three channels, in one place so no caller can render two of them.
   `glyph` is decorative and is hidden from assistive technology; `word` is the
   channel a screen reader actually gets; the colour is the stylesheet's, keyed
   off `data-state`. A state added here without both fields fails to compile. */
const STATE: Record<RunState, { glyph: string; word: string }> = {
  idle: { glyph: '○', word: 'Idle' },
  running: { glyph: '◐', word: 'Running' },
  complete: { glyph: '●', word: 'Complete' },
  blocked: { glyph: '◑', word: 'Blocked' },
  error: { glyph: '✕', word: 'Failed' },
};

type StepData = {
  kind: string;
  title: string;
  meta: string;
  state: RunState;
};

type StepNode = Node<StepData, 'step'>;

/* One step in the graph.

   `handles={{ target: false, source: false }}` is not available here — this is
   not AI Elements' `Node`, which hardcodes its two `Handle`s with no className
   of their own and so cannot carry `.ds-ai-node__port`. The handles are
   rendered directly instead, as children of the positioned `.ds-ai-node`, so
   xyflow measures their bounds exactly as it would upstream. */
/* Every text slot below is a `div` or a `span`, never an `h3` or a `p`, and
   that matches AI Elements' own primitives — `NodeTitle` renders shadcn's
   `CardTitle`, which is a div. It also matters here specifically: this demo is
   mounted inside the docs site's `.prose` article, whose `h3` and `p` rules are
   (0,1,1) and would outrank the theme layer's (0,1,0) class selectors and
   restyle the node from the page's chrome. Elements `.prose` does not claim
   cannot lose that fight, so there is nothing to fix. */
function StepNodeView({ id, data, selected }: NodeProps<StepNode>) {
  const [portFocused, setPortFocused] = useState<'target' | 'source' | null>(null);
  const channel = STATE[data.state];

  return (
    <div className="ds-ai-node" data-selected={selected || undefined} data-state={data.state}>
      {/* The toolbar is the menu path. It is shown while a port has focus, so
          reaching a port from the keyboard reaches the connection actions —
          and it is also shown on selection, which is the pointer path to the
          same buttons. `NodeToolbar` places itself. */}
      <Toolbar
        className="ds-ai-canvas-toolbar"
        isVisible={portFocused !== null || selected}
        // Keep the toolbar reachable: moving focus from the port into a button
        // inside it must not be read as the port losing focus.
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as globalThis.Node | null)) {
            setPortFocused(null);
          }
        }}
      >
        <ConnectionActions nodeId={id} />
      </Toolbar>

      <Handle
        aria-label={`Input port of ${data.title}. Focus for connection actions.`}
        className="ds-ai-node__port"
        onBlur={() => setPortFocused((side) => (side === 'target' ? null : side))}
        onFocus={() => setPortFocused('target')}
        position={Position.Left}
        tabIndex={0}
        type="target"
      />

      <div className="ds-ai-node__kind">{data.kind}</div>
      <div className="ds-ai-node__title">{data.title}</div>
      <div className="ds-ai-node__meta">{data.meta}</div>
      <div className="ds-ai-node__state">
        <span aria-hidden="true">{channel.glyph}</span>
        {channel.word}
      </div>

      <Handle
        aria-label={`Output port of ${data.title}. Focus for connection actions.`}
        className="ds-ai-node__port"
        onBlur={() => setPortFocused((side) => (side === 'source' ? null : side))}
        onFocus={() => setPortFocused('source')}
        position={Position.Right}
        tabIndex={0}
        type="source"
      />
    </div>
  );
}

/* The buttons themselves, split out so the node body stays a shape and this
   stays a behaviour. One per other node: create where there is no edge,
   delete where there is. */
function ConnectionActions({ nodeId }: { nodeId: string }) {
  const nodes = useNodes<StepNode>();
  const edges = useEdges();

  return (
    <>
      {nodes
        .filter((node) => node.id !== nodeId)
        .map((node) => {
          const existing = edges.find(
            (edge) =>
              (edge.source === nodeId && edge.target === node.id) ||
              (edge.source === node.id && edge.target === nodeId),
          );
          const title = node.data.title;

          return (
            <button
              className="ds-ai-canvas-toolbar__btn"
              data-variant={existing ? 'danger' : undefined}
              key={node.id}
              onClick={() => {
                const detail = existing
                  ? { type: 'disconnect' as const, edgeId: existing.id }
                  : { type: 'connect' as const, source: nodeId, target: node.id };
                globalThis.dispatchEvent(new CustomEvent('ds-ai-canvas-edge', { detail }));
              }}
              type="button"
            >
              <span aria-hidden="true">{existing ? '⊘' : '⊕'}</span>
              {existing ? `Disconnect from ${title}` : `Connect to ${title}`}
            </button>
          );
        })}
    </>
  );
}

/* The inspector. A child of `Canvas`, so it is inside the flow's store and can
   read the selection without a provider of its own. */
function Inspector() {
  const nodes = useNodes<StepNode>();
  const selected = nodes.find((node) => node.selected);

  return (
    <Panel className="ds-ai-canvas-panel" position="bottom-right">
      <header className="ds-ai-canvas-panel__header">
        <div className="ds-ai-canvas-panel__title">Inspector</div>
      </header>
      <div className="ds-ai-canvas-panel__body">
        {selected ? (
          <>
            <div className="ds-ai-canvas-panel__row">
              <span className="ds-ai-canvas-panel__label">Step</span>
              <span className="ds-ai-canvas-panel__value">{selected.data.title}</span>
            </div>
            <div className="ds-ai-canvas-panel__row">
              <span className="ds-ai-canvas-panel__label">State</span>
              <span className="ds-ai-canvas-panel__value">
                <span aria-hidden="true">{STATE[selected.data.state].glyph} </span>
                {STATE[selected.data.state].word}
              </span>
            </div>
            <div className="ds-ai-canvas-panel__row">
              <span className="ds-ai-canvas-panel__label">Id</span>
              <span className="ds-ai-canvas-panel__value">
                <code>{selected.id}</code>
              </span>
            </div>
          </>
        ) : (
          <div className="ds-ai-canvas-panel__value">
            Tab to a step and press Enter to select it. Arrow keys move it.
          </div>
        )}
      </div>
    </Panel>
  );
}

/* Held as the flow's own `Node`, not as `StepNode[]`. `Canvas` is
   `ReactFlowProps` with no generic of its own, so it takes `OnNodesChange<Node>`
   and an `onNodesChange` narrowed to a node subtype does not fit it. The typed
   view of the data is taken where it is read — `useNodes<StepNode>()` — rather
   than asserted here. */
const INITIAL_NODES: Node[] = [
  {
    id: 'retrieve',
    type: 'step',
    position: { x: 0, y: 0 },
    data: { kind: 'retrieval', title: 'Retrieve', meta: '12 documents', state: 'complete' },
  },
  {
    id: 'draft',
    type: 'step',
    position: { x: 270, y: 0 },
    data: { kind: 'generation', title: 'Draft', meta: 'streaming', state: 'running' },
  },
  {
    id: 'review',
    type: 'step',
    position: { x: 0, y: 250 },
    data: { kind: 'evaluation', title: 'Review', meta: 'waiting on Draft', state: 'blocked' },
  },
];

const INITIAL_EDGES: Edge[] = [{ id: 'retrieve-draft', source: 'retrieve', target: 'draft' }];

/* Declared once, at module scope. xyflow warns and re-mounts every node when
   this object's identity changes between renders. */
const NODE_TYPES = { step: StepNodeView };

const FIT_VIEW = { padding: 0.14 };

export function WorkflowCanvas() {
  const [nodes, , onNodesChange] = useNodesState(INITIAL_NODES);
  const [edges, setEdges, onEdgesChange] = useEdgesState(INITIAL_EDGES);

  /* The toolbar buttons live inside a node, which xyflow renders outside this
     component's tree. An event carries their intent back rather than a
     callback threaded through `data` — putting a function in node data makes
     every node's data non-serialisable, which is the thing xyflow asks you not
     to do. */
  const applyEdgeIntent = useCallback(
    (event: Event) => {
      const detail = (event as CustomEvent).detail as
        | { type: 'connect'; source: string; target: string }
        | { type: 'disconnect'; edgeId: string };

      setEdges((current) =>
        detail.type === 'disconnect'
          ? current.filter((edge) => edge.id !== detail.edgeId)
          : [
              ...current,
              {
                id: `${detail.source}-${detail.target}`,
                source: detail.source,
                target: detail.target,
              },
            ],
      );
    },
    [setEdges],
  );

  useEffect(() => {
    globalThis.addEventListener('ds-ai-canvas-edge', applyEdgeIntent);
    return () => globalThis.removeEventListener('ds-ai-canvas-edge', applyEdgeIntent);
  }, [applyEdgeIntent]);

  return (
    <Canvas
      className="ds-ai-canvas"
      edges={edges}
      /* `Canvas` turns `fitView` on for us, and its default padding lets the
         graph fill the box edge to edge — which puts the nodes underneath the
         Controls and the Inspector, both of which float over the same surface.
         The padding is what keeps the two apart, and it is a proportion of the
         viewport rather than a pixel inset, so it holds at any stage size. */
      fitViewOptions={FIT_VIEW}
      /* The two props the stylesheet's header names. `nodesFocusable` puts
         nodes in the tab order; `nodesDraggable` is what keeps xyflow's
         arrow-key handler live for a selected node — it gates the keyboard
         move as well as the pointer one. */
      nodeTypes={NODE_TYPES}
      nodes={nodes}
      nodesDraggable
      nodesFocusable
      onEdgesChange={onEdgesChange}
      onNodesChange={onNodesChange}
    >
      <Controls className="ds-ai-canvas-controls" />
      <Inspector />
    </Canvas>
  );
}