# Workflow builder

> An automation editor: a canvas of nodes and curved connectors, a palette to add them, a test run and node settings.

The whole screen of a workflow app. Two triggers, an AI agent, a transformation and a database write sit on a dotted canvas; open the palette and pick a tool, a piece of logic or an action, and it lands after the node you came from, already connected. Pan, zoom, drag nodes, draw connectors, run a test and watch each step come in, or open a node to set it up.

A React component by Bruno Delorence: https://brunodelorence.com/components/workflow-builder. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Dashboards.

## Anatomy

1. **Sidebar**: The workspace’s navigation, folded here to its rail of icons by a clip. Below 520 px it becomes a drawer.
2. **Header**: The organisation, project and branch as switchers with their badges, then search (⌘K), info, notifications and the account.
3. **Palette**: Every node kind, grouped as Tools, Logic and Action, with a tooltip on each. A pick lands after the node whose plus opened it, already connected.
4. **Node**: A button with an icon, a title and a line under it, 66 px tall and as wide as its words. Drag to move it, click for its settings.
5. **Connector**: Leaves an output to the right, turns through two rounded corners halfway and comes into the next input. Drag from an output port to draw one.
6. **Add handle**: The plus after a node with nothing after it. It opens the palette, set to add there, with an “Add node” tooltip.
7. **Toolbar**: The palette’s plus, Pan and lasso, fit, zoom out and in, and Run a test, in a floating bar the arrow keys move along.
8. **Minimap**: The workflow drawn as bars, with the zoom in its name. Clicking it fits the workflow in the view again.

## When to use

- An automation product where people build a flow of steps themselves: triggers, tools, logic and actions joined in order.
- Flows that branch or join, where seeing every connector at once matters more than reading a list.
- An editor that has to show a test run in place: which step ran, which is running, how long each took.
- A desktop screen of 1000 px or more. It folds down to a phone, but the canvas is at its best with room.

## When not to use

- A fixed sequence of three or four steps. A numbered form or a stepper reads faster.
- Showing a flow nobody edits, such as a process in a report. Use a static diagram, like the flowchart.
- Flows of hundreds of nodes: every node is a live element, with no culling or virtualisation.
- A first, guided setup. Templates and a short wizard are friendlier than an empty canvas.

## Installation

Install it with the shadcn CLI, into components/workflow-builder of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/workflow-builder.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { WorkflowBuilder } from './workflow-builder';

export function Editor() {
  return (
    <WorkflowBuilder
      autoplay={false}
      defaultSidebarOpen={false}
      workflowName="Invoice intake"
      onWorkflowChange={({ nodes, edges }) => console.log(nodes.length, edges.length)}
    />
  );
}
```

## States

- **Opening**: As it opens on a wide screen: sidebar and palette open, the whole workflow fitted in the canvas they leave free, and the schedule in red until it is set up.
- **Sidebar folded**: The sidebar clipped to its rail of icons: the palette moves left, and the workflow glides after it and grows into the room.
- **Canvas only**: Sidebar folded to its rail and the palette closed: the workflow centres in the whole canvas.
- **Adding after a node**: The upload’s plus opened the palette; whatever is picked lands to its right.
- **Node added**: An HTTP request after the upload, connected and selected. New connectors draw themselves in.
- **Test running**: The report has risen and the view moved up by half of it; the step in progress is lit. Held here: a real run moves on every 300 ms.
- **Test finished**: Every step has its check, its time and its cost. The live dash on the connectors has stopped.
- **Node settings**: The upload’s settings slide in from the right and the canvas dims behind them.
- **Schedule set**: With a repeat chosen in its settings, the schedule loses its red tone and says how often it fires.

## How it works

- The canvas is a world of absolutely placed nodes and one SVG of connectors, moved by a single transform for pan and zoom. Nodes have a fixed height and a width of their own, so every port and every connector comes from the data, without measuring the page.
- A connector leaves an output to the right, turns through two rounded corners halfway and comes into the next input; when the target sits behind, it becomes a soft S. A new connector draws itself in with a dash offset, and the one running in a test carries a moving dash.
- Every node with nothing after it shows a plus. It opens the palette with that node as the anchor, and the kind you pick lands to its right, connected; the agent’s Model and Tools pluses do the same below it. Dragging from an output port to any node connects them too.
- Pointer events drive the canvas: a drag on the background pans (or draws a lasso that selects nodes), a drag on a node moves it and the others selected, and a press without a drag opens its settings. Ctrl or ⌘ with the wheel, or a trackpad pinch, zooms around the pointer; a plain wheel still scrolls the page.
- Run opens the report from the bottom and moves the view up by half its height (and, with the palette closed, to the middle of the screen). The steps follow the connectors from each trigger, one every 300 ms: each row slides in under a sweep of light while its node glows on the canvas, then takes its check.
- The sidebar folds to a rail of icons by a clip-path, its headings closing up and its items becoming squares, while the breadcrumb, the header’s line and the palette slide by the same distance; a fold button sits in the open sidebar’s top row and a button on its edge opens it again; panels slide and fade, and the settings’ sections come in one after another. Below 760 px the open sidebar lies over the canvas, and below 520 px the rail goes and it becomes a drawer.
- The view fits the workflow into the part of the canvas the palette and the open sidebar leave free, so on a stage with room for both they never cover a node, and glides to the new free part whenever either opens or closes. Once the reader pans or zooms, the view is theirs: a panel opening over the workflow only nudges it right, as far as the canvas allows.
- While on screen and untouched, a demo pointer goes through it: the palette’s tooltips, folding the sidebar, a test run, the upload’s plus with its tooltip, then the upload’s settings, where it drops a file type, and a second run. Any input of the reader’s stops it for good, so it never undoes their work.
- Every colour is a light-dark() token, so it follows the page’s color-scheme, or the colorScheme prop.

## API reference

- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer shows the builder off. Any input of the reader’s stops it for good.
- **defaultSidebarOpen** (`boolean`, default `true`): The sidebar starts open. Below 1000 px, where it would leave the workflow too little room, it always starts folded.
- **defaultPaletteOpen** (`boolean`, default `true`): The node palette starts open. Below 560 px it always starts closed.
- **workflowName** (`string`, default `'Customer intake'`): The workflow’s name, which labels the canvas for screen readers.
- **onWorkflowChange** (`(workflow: { nodes: Node[]; edges: Edge[] }) => void`): Called with the nodes and connectors whenever they change: added, moved, connected or deleted.
- **defaultState** (`WorkflowState`): Opens in this state: added ({ kind, after }[], nodes already added after a node’s output and connected, their ids `<kind>-<n>`), attach (the palette open to add after this node), tip (the palette’s tooltip for a kind), addTip (the Add node tooltip on a node’s plus), menu ('org' | 'project' | 'branch', a breadcrumb switcher open), selected (node ids), settings (a node’s settings open), repeat ('hourly' | 'daily' | 'weekly', the schedule’s) and run ({ at?, tab? }, the test run’s report held at step at, or finished without it). The starting nodes are schedule, upload, agent, transform and database. Turns the demo off; a run it opens holds until the next run.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s. Without it, the component follows the page’s color-scheme.
- **className** (`string`): Classes for the root, to size or place it.

## Accessibility

- The canvas is a labelled group with a hidden description of its keys. Nodes are buttons: arrows move a node (Shift for larger steps), Enter opens its settings, Delete removes a node you added.
- Every plus is a button named after what it adds and where (“Add a node after Document upload”, “Add a model to AI Agent”); its “Add node” tooltip shows on focus as well as hover.
- The palette is a labelled region of buttons, grouped under headings; each one’s description is its tooltip, tied by aria-describedby while it shows. Search in the header filters it.
- The toolbar is a role="toolbar" that arrows move along; Pan and the lasso are toggle buttons (aria-pressed). The breadcrumb’s switchers are menu buttons with radio items, opened with Down and closed with Escape.
- The run report’s tabs are a real tablist; its rows are disclosure buttons. Adding, connecting, deleting and the end of a run are announced in a polite live region.
- Opening a node’s settings moves focus to its heading, and closing them (the button or Escape) returns it to the node. Closed panels are inert. Under reduced motion nothing slides, draws or sweeps, and the demo does not play.

## Source

### workflow-builder/workflow-builder.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from 'react';
import {
  AGENT_PORTS,
  BRANCHES,
  COST,
  GROUPS,
  KINDS,
  NAV,
  NAV_OTHER,
  NODE_HEIGHT,
  ORGS,
  PROJECTS,
  ROLE,
  START_EDGES,
  START_EXTENSIONS,
  START_NODES,
  STEM,
  type Edge,
  type Node,
} from './data';
import { DemoPointer, runDemo, type Point } from './demo-pointer';
import { bounds, clamp, connector, fit, inPort, startOf, zoomAt, type View } from './geometry';
import { Icon } from './icons';
import {
  REPEATS,
  RunsPanel,
  SettingsPanel,
  type Config,
  type RunStep,
  type RunTab,
} from './panels';
import { useAutoplay } from './use-autoplay';
import styles from './workflow-builder.module.css';

/*
 * Workflow builder: the editor of an automation app. A sidebar of the workspace, a breadcrumb of
 * the organisation, project and branch, and a canvas of nodes joined by connectors: two triggers
 * (a schedule not set up yet, and a document upload), an AI agent with its model and tools
 * stems, a transformation and a database write. A palette of node kinds, grouped as tools, logic
 * and actions, adds nodes; a toolbar pans, selects, fits, zooms and runs a test; a minimap shows
 * where the view is; a node's settings slide in from the right and the test run's report from the
 * bottom.
 *
 * - The canvas is a "world" of absolutely placed nodes and one SVG of connectors, moved by a
 *   single transform (pan and zoom). Nodes have a fixed height and a width of their own, so every
 *   port, and so every connector, comes from the data: nothing is measured.
 * - Connectors leave an output to the right, turn through rounded corners halfway and come into
 *   the next input; a new one draws itself in. Drag from an output port to a node to connect it.
 * - A node without anything after it shows a plus: it opens the palette, and the kind picked
 *   lands to its right, already connected. The agent's Model and Tools pluses do the same below
 *   it. The toolbar's plus opens the palette to drop a node in the middle of the view.
 * - Drag the background to pan (or, with the lasso, to select a few nodes); drag a node to move
 *   it, with the others selected. Ctrl or ⌘ with the wheel, or a pinch on a trackpad, zooms.
 *   From the keyboard, nodes are buttons: arrows move them, Enter opens their settings, Delete
 *   removes the ones added.
 * - Run plays a test: the report rises from the bottom, the view moves up by half of it, and each
 *   step of the workflow lights up on the canvas as its row comes in.
 * - The sidebar folds to a rail of icons by clipping, and the breadcrumb and the palette slide
 *   by transforms; panels slide and fade. Below 520 px the rail goes and the sidebar becomes a
 *   drawer.
 * - The view fits the workflow in the part of the canvas the palette and the open sidebar leave
 *   free, and glides to the new free part as either opens or closes.
 * - While on screen and untouched, a demo pointer shows it off: the palette's tooltips, folding
 *   the sidebar, a test run, adding a node after the upload, and its settings. Any input of the
 *   reader's stops it for good.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

type Attach = { node: string; port: Edge['port'] } | null;
type Mode = 'hand' | 'select';
type Menu = 'org' | 'project' | 'branch' | null;
/** `at`: the step shown last (-1 before the first, steps.length once over); `settled`: it has
 * taken its check. */
type Run = {
  open: boolean;
  tab: RunTab;
  steps: RunStep[];
  at: number;
  settled: boolean;
  /** Held where defaultState put it: the steps wait until the next run. */
  held?: boolean;
};

/**
 * A state to open in, for docs and screenshots: any of it turns the demo off. Node ids are the
 * starting workflow's (schedule, upload, agent, transform, database) or `<kind>-<n>` for the ones
 * `added` puts in (numbered from 1 in order).
 */
export type WorkflowState = {
  /** Kinds already added, each after a node's output, connected. */
  added?: { kind: string; after: string }[];
  /** The palette open to add after this node. */
  attach?: string;
  /** The palette's tooltip for this kind. */
  tip?: string;
  /** The "Add node" tooltip on this node's plus. */
  addTip?: string;
  /** A breadcrumb switcher open. */
  menu?: 'org' | 'project' | 'branch';
  selected?: string[];
  /** This node's settings open. */
  settings?: string;
  /** The schedule trigger set to repeat. */
  repeat?: 'hourly' | 'daily' | 'weekly';
  /** The test run's report open: held at step `at` (running), or finished when left out. */
  run?: { at?: number; tab?: RunTab };
};
type Box = { x: number; y: number; w: number; h: number };

/** One beat of the test run: how long each step takes to come in. */
const STEP_MS = 300;
/** How long a step shows as running before its check. */
const SETTLE_MS = 150;
/** The first step comes in once the report has mostly risen. */
const FIRST_STEP_MS = 500;
/** The rail's width (the stylesheet's --rail) on a wide stage, and the open sidebar's (--side). */
const RAIL = 67;
const SIDE = 277;
/** Where the open palette sits in the canvas, and how wide it is (the stylesheet's). */
const PALETTE_LEFT = 9;
const PALETTE_W = 276;
/** Below this stage width the sidebar starts folded. */
const SIDEBAR_MIN = 1000;

const START_CONFIG: Config = {
  source: 'Integration',
  allTypes: false,
  extensions: START_EXTENSIONS,
  active: true,
  repeat: '',
  disabled: [],
  labels: {},
};

const kindOf = (id: string) => KINDS.find((kind) => kind.id === id);

/** The steps a run goes through: from each trigger with something after it, along the outputs. */
function stepsOf(nodes: Node[], edges: Edge[], disabled: string[]) {
  const triggers = nodes.filter((node) => node.role === 'Trigger');
  const live = triggers.filter((node) => edges.some((edge) => edge.from === node.id));
  const queue = (live.length ? live : triggers.slice(0, 1)).map((node) => node.id);
  const seen = new Set<string>();
  const order: RunStep[] = [];
  while (queue.length) {
    const id = queue.shift()!;
    if (seen.has(id)) continue;
    seen.add(id);
    const node = nodes.find((item) => item.id === id);
    if (!node) continue;
    if (!disabled.includes(id))
      order.push({
        id,
        title: node.title,
        role: ROLE[node.role],
        ms: COST[id]?.ms ?? 2 + (node.title.length % 3),
        value: COST[id]?.value ?? '1.00',
      });
    for (const edge of edges) if (edge.from === id) queue.push(edge.to);
  }
  return order;
}

/** A node's width from its words: about 7.6 px a letter at 15 px, past the icon and padding. */
const widthFor = (title: string, sub: string) =>
  Math.round(Math.max(168, 80 + Math.max(title.length * 8.2, sub.length * 7.2)));

const overlaps = (a: Box, nodes: Node[]) =>
  nodes.some(
    (node) =>
      a.x < node.x + node.w + 24 &&
      a.x + a.w + 24 > node.x &&
      a.y < node.y + NODE_HEIGHT + 24 &&
      a.y + NODE_HEIGHT + 24 > node.y,
  );

/** Follows a pointer from its press to its release, wherever it goes on the page. */
function track(
  event: ReactPointerEvent,
  move: (event: PointerEvent) => void,
  up: (event: PointerEvent) => void,
) {
  const id = event.pointerId;
  const onMove = (next: PointerEvent) => {
    if (next.pointerId === id) move(next);
  };
  const onUp = (next: PointerEvent) => {
    if (next.pointerId !== id) return;
    window.removeEventListener('pointermove', onMove);
    window.removeEventListener('pointerup', onUp);
    window.removeEventListener('pointercancel', onUp);
    up(next);
  };
  window.addEventListener('pointermove', onMove);
  window.addEventListener('pointerup', onUp);
  window.addEventListener('pointercancel', onUp);
}

export function WorkflowBuilder({
  autoplay = true,
  defaultSidebarOpen = true,
  defaultPaletteOpen = true,
  workflowName = 'Customer intake',
  onWorkflowChange,
  colorScheme,
  className = '',
  defaultState,
}: {
  /** While on screen and untouched, a demo pointer shows the builder off. Any input stops it. */
  autoplay?: boolean;
  /** The sidebar starts open (below 1000 px it always starts folded). */
  defaultSidebarOpen?: boolean;
  /** The node palette starts open (below 560 px it always starts closed). */
  defaultPaletteOpen?: boolean;
  /** The workflow's name, for the canvas's label and the run's report. */
  workflowName?: string;
  /** Called with the nodes and connectors whenever the reader changes them. */
  onWorkflowChange?: (workflow: { nodes: Node[]; edges: Edge[] }) => void;
  /** Light or dark colours, or the reader's ('system'); without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
  /** Opens in this state (palette, settings, a test run…), with the demo off. */
  defaultState?: WorkflowState;
}) {
  const ids = useId();
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(null);
  const canvas = useRef<HTMLDivElement>(null);
  const toolbar = useRef<HTMLDivElement>(null);
  const paletteList = useRef<HTMLDivElement>(null);
  const settingsHeading = useRef<HTMLHeadingElement>(null);
  const pointer = useRef<HTMLSpanElement>(null);
  const counter = useRef((defaultState?.added?.length ?? 0) + 1);
  // The workflow defaultState opens on: its added kinds placed as a pick from a plus would place
  // them, and its schedule's repeat. Read once, on the first render.
  const [seed] = useState(() => {
    const placed = START_NODES.map((node) => ({ ...node }));
    const wired = [...START_EDGES];
    const repeat = REPEATS.find((item) => item.value === defaultState?.repeat);
    if (repeat) {
      const schedule = placed.find((node) => node.id === 'schedule')!;
      Object.assign(schedule, { sub: repeat.label, tone: undefined, icon: 'clock' });
    }
    (defaultState?.added ?? []).forEach(({ kind: kindId, after }, index) => {
      const kind = kindOf(kindId);
      const source = placed.find((node) => node.id === after);
      if (!kind || !source) return;
      const id = `${kind.id}-${index + 1}`;
      const w = widthFor(kind.label, kind.sub);
      const at = { x: source.x + source.w + 96, y: source.y };
      while (overlaps({ ...at, w, h: NODE_HEIGHT }, placed)) at.y += 92;
      placed.push({
        id,
        kind: kind.id,
        title: kind.label,
        sub: kind.sub,
        icon: kind.icon,
        ...at,
        w,
        role: kind.group,
        added: true,
      });
      wired.push({ id: `${source.id}-${id}`, from: source.id, to: id, port: 'out', added: true });
    });
    const config: Config = { ...START_CONFIG, repeat: repeat?.value ?? '' };
    const steps = stepsOf(placed, wired, config.disabled);
    return { nodes: placed, edges: wired, config, steps };
  });

  const [ready, setReady] = useState(false);
  const [sidebarOpen, setSidebarOpen] = useState(defaultSidebarOpen);
  const [nav, setNav] = useState('dashboard');
  const [menu, setMenu] = useState<Menu>(defaultState?.menu ?? null);
  const [org, setOrg] = useState(0);
  const [project, setProject] = useState(0);
  const [branch, setBranch] = useState(0);
  const [query, setQuery] = useState('');

  const [nodes, setNodes] = useState<Node[]>(seed.nodes);
  const [edges, setEdges] = useState<Edge[]>(seed.edges);
  const [selected, setSelected] = useState<string[]>(
    defaultState?.selected ??
      (defaultState?.settings
        ? [defaultState.settings]
        : defaultState?.attach
          ? [defaultState.attach]
          : []),
  );
  const [leaving, setLeaving] = useState<string[]>([]);
  const [view, setView] = useState<View>({ x: 0, y: 0, k: 1 });
  const [smooth, setSmooth] = useState(false);
  const [size, setSize] = useState({ w: 0, h: 0, stage: 0, bar: 0 });
  const [mode, setMode] = useState<Mode>('hand');
  const [marquee, setMarquee] = useState<Box | null>(null);
  const [wire, setWire] = useState<{ from: Point; to: Point; down: boolean } | null>(null);

  const [palette, setPalette] = useState<{ open: boolean; attach: Attach }>(() =>
    defaultState?.attach
      ? { open: true, attach: { node: defaultState.attach, port: 'out' } }
      : {
          open: (defaultPaletteOpen && !defaultState?.settings) || Boolean(defaultState?.tip),
          attach: null,
        },
  );
  const [tip, setTip] = useState<string | null>(defaultState?.tip ?? null);
  const [tipTop, setTipTop] = useState(0);
  const [addTip, setAddTip] = useState<string | null>(defaultState?.addTip ?? null);
  /** The tooltip keeps its words while it fades out. */
  const tipText = useRef('');
  if (tip) tipText.current = kindOf(tip)?.description ?? '';

  const [settings, setSettings] = useState<{ id: string | null; open: boolean }>({
    id: defaultState?.settings ?? null,
    open: Boolean(defaultState?.settings),
  });
  const [config, setConfig] = useState<Config>(seed.config);
  const [run, setRun] = useState<Run>(() => {
    const opened = defaultState?.run;
    if (!opened) return { open: false, tab: 'runs', steps: [], at: 0, settled: true };
    const running = opened.at !== undefined && opened.at < seed.steps.length;
    return {
      open: true,
      tab: opened.tab ?? 'runs',
      steps: seed.steps,
      at: running ? opened.at! : seed.steps.length,
      settled: !running,
      held: true,
    };
  });
  const [expanded, setExpanded] = useState<string[]>([]);
  const [message, setMessage] = useState('');

  /** The view as the reader left it: once they pan or zoom, resizing no longer refits it. */
  const moved = useRef(false);
  const viewRef = useRef(view);
  viewRef.current = view;
  const nodesRef = useRef(nodes);
  nodesRef.current = nodes;

  const announce = (text: string) => setMessage((now) => (now === text ? `${text} ` : text));

  // The report's height, and how far it moves the view up: half of it.
  const runsHeight = Math.round(clamp(size.h * 0.42, 150, 340));
  const shift = run.open ? runsHeight / 2 : 0;

  // Small stages start folded: no sidebar below 1000 px (it would leave the workflow too little
  // room beside the palette, and up to 760 px it is a drawer over the canvas), no palette below
  // 560 px. Transitions wait for the first frame, so nothing slides in on arrival.
  useEffect(() => {
    const width = root.current?.offsetWidth ?? 1000;
    if (width < SIDEBAR_MIN) setSidebarOpen(false);
    if (width < 560) setPalette({ open: false, attach: null });
    const frame = requestAnimationFrame(() => requestAnimationFrame(() => setReady(true)));
    return () => cancelAnimationFrame(frame);
  }, [root]);

  // The canvas's size, for fitting the view and drawing the minimap, and the stage's (whose
  // breakpoints decide what lies over the canvas).
  useEffect(() => {
    const element = canvas.current;
    if (!element) return;
    const observer = new ResizeObserver(() =>
      setSize({
        w: element.offsetWidth,
        h: element.offsetHeight,
        stage: root.current?.offsetWidth ?? element.offsetWidth,
        bar: toolbar.current?.offsetWidth ?? 0,
      }),
    );
    observer.observe(element);
    if (toolbar.current) observer.observe(toolbar.current);
    return () => observer.disconnect();
  }, [root]);

  /**
   * How much of the canvas's left the open panels cover, in px: the palette, and the open
   * sidebar beside it (which lies over the canvas past the rail). The workflow fits right of it.
   * Below 760 px the sidebar is a drawer over a scrim, which covers nothing for good; and where
   * the palette would leave too little room, the workflow ignores it.
   */
  const coverFor = useCallback(
    (sidebar: boolean, paletteOpen: boolean) => {
      const side = sidebar && size.stage > 760 ? SIDE - RAIL : 0;
      if (!paletteOpen) return side;
      const width = size.stage <= 520 ? Math.min(268, size.w - 20) : PALETTE_W;
      const edge = side + PALETTE_LEFT + width;
      return size.w - edge >= 240 ? edge : side;
    },
    [size.w, size.stage],
  );
  const cover = coverFor(sidebarOpen, palette.open);
  // With the report open and nothing over the canvas, the fitted workflow slides to the middle
  // of the whole screen, rail included, the toolbar with it.
  const rail = size.stage > 520 ? RAIL : 0;
  const shiftX = run.open && !cover && !sidebarOpen && !moved.current ? -rail / 2 : 0;
  const shiftXRef = useRef(shiftX);
  shiftXRef.current = shiftX;
  // The toolbar keeps to the same free part: centred in it, clear of the panels on its left and
  // of the minimap on its right. Where there is no room for both, the minimap gives way.
  const mapEdge = size.stage <= 640 ? 0 : size.stage <= 760 ? 136 : 178;
  const ideal = shiftX ? shiftX : cover / 2;
  const barLow = cover ? cover + 12 + size.bar / 2 - size.w / 2 : -Infinity;
  const barHigh = size.w / 2 - (mapEdge ? mapEdge + 12 : 10) - size.bar / 2;
  const cramped = Boolean(mapEdge) && barLow > barHigh;
  const toolbarX = cramped
    ? barLow
    : Math.max(barLow, Math.min(ideal, mapEdge || cover ? barHigh : ideal));
  /** The reader takes the view over: the slide above becomes part of it, so nothing jumps. */
  const claimView = (): View => {
    const now = viewRef.current;
    const claimed = moved.current ? now : { ...now, x: now.x + shiftXRef.current };
    moved.current = true;
    viewRef.current = claimed;
    return claimed;
  };
  /** Fits the workflow in the canvas right of what covers it, above the toolbar. */
  const refit = useCallback(
    (animate: boolean, list = nodesRef.current, left = cover) => {
      if (!size.w || !size.h) return;
      setSmooth(animate);
      // Scaled to clear the toolbar, but centred on the whole height.
      const view = fit(list, size.w, size.h - 64, left);
      setView({ ...view, y: view.y + 32 });
    },
    [size.w, size.h, cover],
  );

  // Until the reader moves it, the view follows the canvas's size, and glides to make room as
  // the palette or the sidebar opens and closes. A view the reader has moved stays theirs, but a
  // panel opening over the workflow pushes it right, as far as the canvas allows.
  const fitted = useRef({ w: 0, h: 0, cover: 0 });
  useEffect(() => {
    const last = fitted.current;
    const resized = last.w !== size.w || last.h !== size.h;
    fitted.current = { w: size.w, h: size.h, cover };
    if (!moved.current) {
      refit(ready && !resized);
      return;
    }
    if (resized || cover <= last.cover) return;
    const now = viewRef.current;
    const box = bounds(nodesRef.current);
    const left = now.x + box.x * now.k;
    const right = now.x + (box.x + box.w) * now.k;
    const dx = Math.min(cover + 24 - left, size.w - 24 - right);
    if (dx <= 0) return;
    setSmooth(true);
    setView({ ...now, x: now.x + dx });
  }, [refit, ready, cover, size.w, size.h]);

  useEffect(() => {
    onWorkflowChange?.({ nodes, edges });
    // Only the workflow itself, not the callback's identity.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [nodes, edges]);

  // The test run: a step every beat, lit on the canvas as its row comes in.
  useEffect(() => {
    if (!run.open || run.held || run.at >= run.steps.length) return;
    const delay = !run.settled ? SETTLE_MS : run.at < 0 ? FIRST_STEP_MS : STEP_MS - SETTLE_MS;
    const timer = window.setTimeout(() => {
      setRun((now) => {
        if (!now.settled) {
          // The last step's check ends the run.
          return now.at >= now.steps.length - 1
            ? { ...now, at: now.steps.length, settled: true }
            : { ...now, settled: true };
        }
        return { ...now, at: now.at + 1, settled: false };
      });
    }, delay);
    return () => window.clearTimeout(timer);
  }, [run.open, run.held, run.at, run.settled, run.steps.length]);
  const finished = run.steps.length > 0 && run.at >= run.steps.length;
  useEffect(() => {
    if (finished) announce(`Test run finished: ${run.steps.length} steps, all passed.`);
  }, [finished, run.steps.length]);

  // The palette's tooltip follows the item it is for.
  useEffect(() => {
    const list = paletteList.current;
    if (!tip || !list) return;
    const item = list.querySelector<HTMLElement>(`[data-kind="${tip}"]`);
    if (!item) return;
    setTipTop(item.offsetTop - list.scrollTop + item.offsetHeight / 2 + list.offsetTop);
  }, [tip]);

  // Escape closes the topmost thing open.
  const onRootKey = (event: ReactKeyboardEvent) => {
    if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
      event.preventDefault();
      root.current?.querySelector<HTMLInputElement>('[data-part="search"] input')?.focus();
      return;
    }
    if (event.key !== 'Escape') return;
    if (menu) setMenu(null);
    else if (settings.open) closeSettings();
    else if (palette.open) setPalette({ open: false, attach: null });
    else if (run.open) setRun((now) => ({ ...now, open: false }));
    else if (sidebarOpen && (root.current?.offsetWidth ?? 1000) < 520) setSidebarOpen(false);
    else return;
    event.preventDefault();
  };

  // Clicks outside an open switcher close it.
  useEffect(() => {
    if (!menu) return;
    const close = (event: PointerEvent) => {
      if (!(event.target as Element).closest?.('[data-menu]')) setMenu(null);
    };
    document.addEventListener('pointerdown', close);
    return () => document.removeEventListener('pointerdown', close);
  }, [menu]);

  /* ——— Coordinates ——— */

  /** A pointer's place in the canvas's own px, whatever scale the stage is drawn at. */
  const toCanvas = (clientX: number, clientY: number): Point => {
    const element = canvas.current!;
    const rect = element.getBoundingClientRect();
    const scale = rect.width / element.offsetWidth || 1;
    return { x: (clientX - rect.left) / scale, y: (clientY - rect.top) / scale };
  };
  const toWorld = (point: Point): Point => {
    const now = viewRef.current;
    return { x: (point.x - (now.x + shiftX)) / now.k, y: (point.y - (now.y - shift)) / now.k };
  };
  const scaleOf = () => {
    const element = canvas.current!;
    return element.getBoundingClientRect().width / element.offsetWidth || 1;
  };

  /* ——— The workflow ——— */

  const outgoing = (id: string) => edges.some((edge) => edge.from === id && edge.port === 'out');

  const addNode = (kindId: string, attach: Attach = palette.attach) => {
    const kind = kindOf(kindId);
    if (!kind) return;
    const id = `${kind.id}-${counter.current++}`;
    const w = widthFor(kind.label, kind.sub);
    const placed = nodesRef.current;
    const source = attach && placed.find((node) => node.id === attach.node);
    let at: Point;
    if (source && attach?.port === 'out') {
      at = { x: source.x + source.w + 96, y: source.y };
      while (overlaps({ ...at, w, h: NODE_HEIGHT }, placed)) at.y += 92;
    } else if (source) {
      const stem = AGENT_PORTS.find((item) => item.id === attach!.port)!;
      at = { x: source.x + stem.x + 70, y: source.y + NODE_HEIGHT + STEM + 64 };
      while (overlaps({ ...at, w, h: NODE_HEIGHT }, placed)) at.x += w + 32;
    } else {
      const middle = toWorld({ x: (cover + size.w) / 2, y: (size.h - shift * 2) / 2 });
      at = { x: Math.round(middle.x - w / 2), y: Math.round(middle.y - NODE_HEIGHT / 2) };
      while (overlaps({ ...at, w, h: NODE_HEIGHT }, placed)) at.y += 92;
    }
    const node: Node = {
      id,
      kind: kind.id,
      title: kind.label,
      sub: kind.sub,
      icon: kind.icon,
      x: at.x,
      y: at.y,
      w,
      role: kind.group,
      added: true,
    };
    setNodes((now) => [...now, node]);
    if (source && attach) {
      setEdges((now) => [
        ...now,
        { id: `${source.id}-${id}`, from: source.id, to: id, port: attach.port, added: true },
      ]);
      announce(`${kind.label} added after ${source.title}, and connected.`);
      setPalette({ open: false, attach: null });
      setTip(null);
    } else announce(`${kind.label} added to the canvas.`);
    setSelected([id]);
  };

  const removeNodes = (list: string[]) => {
    if (!list.length) return;
    setLeaving((now) => [...now, ...list]);
    window.setTimeout(() => {
      setNodes((now) => now.filter((node) => !list.includes(node.id)));
      setEdges((now) => now.filter((edge) => !list.includes(edge.from) && !list.includes(edge.to)));
      setLeaving((now) => now.filter((id) => !list.includes(id)));
    }, 180);
    setSelected((now) => now.filter((id) => !list.includes(id)));
    if (settings.id && list.includes(settings.id)) closeSettings();
    announce(list.length === 1 ? 'Node deleted.' : `${list.length} nodes deleted.`);
  };

  const connect = (from: string, port: Edge['port'], to: string) => {
    if (from === to) return;
    if (edges.some((edge) => edge.from === from && edge.to === to)) return;
    setEdges((now) => [...now, { id: `${from}-${to}-${port}`, from, to, port, added: true }]);
    const a = nodes.find((node) => node.id === from)?.title;
    const b = nodes.find((node) => node.id === to)?.title;
    announce(`${a} connected to ${b}.`);
  };

  const openSettings = (id: string) => {
    setSettings({ id, open: true });
    setPalette((now) => ({ ...now, open: false }));
    setTip(null);
    requestAnimationFrame(() => settingsHeading.current?.focus({ preventScroll: true }));
  };
  const closeSettings = () => {
    const id = settings.id;
    setSettings((now) => ({ ...now, open: false }));
    if (id)
      requestAnimationFrame(() =>
        root.current
          ?.querySelector<HTMLElement>(`[data-node-id="${id}"] [data-part="node"]`)
          ?.focus({ preventScroll: true }),
      );
  };

  const onConfig = (patch: Partial<Config>) => {
    setConfig((now) => ({ ...now, ...patch }));
    if (patch.repeat !== undefined) {
      const label = REPEATS.find((item) => item.value === patch.repeat)?.label;
      setNodes((now) =>
        now.map((node) =>
          node.id === 'schedule'
            ? {
                ...node,
                sub: label ?? 'Schedule not configured',
                tone: label ? undefined : 'error',
                icon: label ? 'clock' : 'warning',
              }
            : node,
        ),
      );
    }
  };

  /** The steps a run goes through: from each trigger with something after it, along the outputs. */
  const steps = useMemo(
    () => stepsOf(nodes, edges, config.disabled),
    [nodes, edges, config.disabled],
  );

  const stepCount = useRef(steps.length);
  stepCount.current = steps.length;

  const startRun = () => {
    setSmooth(true);
    setRun({ open: true, tab: 'runs', steps, at: -1, settled: true });
    setExpanded([]);
    announce(`Test run started: ${steps.length} steps.`);
  };

  const zoomBy = (factor: number) => {
    const now = claimView();
    setSmooth(true);
    setView(zoomAt(now, factor, { x: (cover + size.w) / 2, y: (size.h - shift * 2) / 2 + shift }));
  };

  /* ——— Pointer input on the canvas ——— */

  const onCanvasDown = (event: ReactPointerEvent<HTMLDivElement>) => {
    if (event.button !== 0 || event.target !== event.currentTarget) return;
    setMenu(null);
    const start = toCanvas(event.clientX, event.clientY);
    if (mode === 'select' && event.pointerType !== 'touch') {
      // The lasso: a box from the press to the pointer; the nodes it touches are selected.
      setSelected([]);
      track(
        event,
        (move) => {
          const at = toCanvas(move.clientX, move.clientY);
          const box = {
            x: Math.min(start.x, at.x),
            y: Math.min(start.y, at.y),
            w: Math.abs(at.x - start.x),
            h: Math.abs(at.y - start.y),
          };
          setMarquee(box);
          const a = toWorld({ x: box.x, y: box.y });
          const b = toWorld({ x: box.x + box.w, y: box.y + box.h });
          setSelected(
            nodesRef.current
              .filter(
                (node) =>
                  node.x < b.x &&
                  node.x + node.w > a.x &&
                  node.y < b.y &&
                  node.y + NODE_HEIGHT > a.y,
              )
              .map((node) => node.id),
          );
        },
        () => setMarquee(null),
      );
      return;
    }
    // The hand: the background follows the pointer.
    let from = viewRef.current;
    let panned = false;
    setSmooth(false);
    track(
      event,
      (move) => {
        const at = toCanvas(move.clientX, move.clientY);
        if (!panned && Math.hypot(at.x - start.x, at.y - start.y) < 3) return;
        if (!panned) from = claimView();
        panned = true;
        setView({ ...from, x: from.x + at.x - start.x, y: from.y + at.y - start.y });
      },
      () => {
        if (!panned) setSelected([]);
      },
    );
  };

  const onNodeDown = (event: ReactPointerEvent, node: Node) => {
    if (event.button !== 0) return;
    event.stopPropagation();
    const group = selected.includes(node.id) ? selected : [node.id];
    const origin = new Map(
      nodesRef.current.filter((item) => group.includes(item.id)).map((item) => [item.id, item]),
    );
    const start = { x: event.clientX, y: event.clientY };
    let dragged = false;
    track(
      event,
      (move) => {
        const scale = scaleOf() * viewRef.current.k;
        const dx = (move.clientX - start.x) / scale;
        const dy = (move.clientY - start.y) / scale;
        if (!dragged && Math.hypot(dx, dy) * viewRef.current.k < 4) return;
        if (!dragged) setSelected(group);
        dragged = true;
        setNodes((now) =>
          now.map((item) => {
            const was = origin.get(item.id);
            return was ? { ...item, x: Math.round(was.x + dx), y: Math.round(was.y + dy) } : item;
          }),
        );
      },
      () => {
        if (dragged) return;
        setSelected([node.id]);
        openSettings(node.id);
      },
    );
  };

  const onPortDown = (event: ReactPointerEvent, node: Node, port: Edge['port']) => {
    if (event.button !== 0) return;
    event.stopPropagation();
    event.preventDefault();
    const from = startOf(node, port);
    const down = port !== 'out';
    setWire({ from, to: from, down });
    track(
      event,
      (move) => setWire({ from, to: toWorld(toCanvas(move.clientX, move.clientY)), down }),
      (up) => {
        setWire(null);
        const target = document
          .elementFromPoint(up.clientX, up.clientY)
          ?.closest<HTMLElement>('[data-node-id]')?.dataset.nodeId;
        if (target) connect(node.id, port, target);
      },
    );
  };

  const onNodeKey = (event: ReactKeyboardEvent, node: Node) => {
    const step = event.shiftKey ? 40 : 8;
    const moves: Record<string, Point> = {
      ArrowLeft: { x: -step, y: 0 },
      ArrowRight: { x: step, y: 0 },
      ArrowUp: { x: 0, y: -step },
      ArrowDown: { x: 0, y: step },
    };
    const by = moves[event.key];
    if (by) {
      event.preventDefault();
      const group = selected.includes(node.id) ? selected : [node.id];
      setSelected(group);
      setNodes((now) =>
        now.map((item) =>
          group.includes(item.id) ? { ...item, x: item.x + by.x, y: item.y + by.y } : item,
        ),
      );
      return;
    }
    if (event.key === 'Delete' || event.key === 'Backspace') {
      const group = (selected.includes(node.id) ? selected : [node.id]).filter(
        (id) => nodes.find((item) => item.id === id)?.added,
      );
      if (group.length) {
        event.preventDefault();
        removeNodes(group);
      } else announce('Only the nodes you added can be deleted.');
    }
  };

  // Ctrl or ⌘ with the wheel, and trackpad pinches, zoom around the pointer. A plain wheel
  // scrolls the page, as anywhere else.
  useEffect(() => {
    const element = canvas.current;
    if (!element) return;
    const onWheel = (event: WheelEvent) => {
      if (!event.ctrlKey && !event.metaKey) return;
      event.preventDefault();
      take();
      const now = claimView();
      setSmooth(false);
      const rect = element.getBoundingClientRect();
      const scale = rect.width / element.offsetWidth || 1;
      const at = { x: (event.clientX - rect.left) / scale, y: (event.clientY - rect.top) / scale };
      const next = zoomAt(now, Math.exp(-clamp(event.deltaY, -40, 40) * 0.008), at);
      viewRef.current = next;
      setView(next);
    };
    element.addEventListener('wheel', onWheel, { passive: false });
    return () => element.removeEventListener('wheel', onWheel);
  }, [take]);

  /* ——— The demo ——— */

  const demo = useEffectEvent((action: string, arg?: string) => {
    switch (action) {
      case 'reset':
        paletteList.current?.scrollTo({ top: 0 });
        setMenu(null);
        {
          // As on arrival: small stages start with the sidebar folded and the palette closed.
          const width = root.current?.offsetWidth ?? 1000;
          setSidebarOpen(width >= SIDEBAR_MIN);
          setPalette({ open: width >= 560, attach: null });
          // Fitted for the panels as they will be, so the view glides once, straight there.
          moved.current = false;
          refit(true, START_NODES, coverFor(width >= SIDEBAR_MIN, width >= 560));
        }
        setSettings((now) => ({ ...now, open: false }));
        setRun((now) => ({ ...now, open: false }));
        setConfig(START_CONFIG);
        setSelected([]);
        setTip(null);
        setAddTip(null);
        setMode('hand');
        removeNodes(nodesRef.current.filter((node) => node.added).map((node) => node.id));
        setEdges((now) => now.filter((edge) => !edge.added));
        break;
      case 'tip':
        setTip(arg ?? null);
        break;
      case 'addTip':
        setAddTip(arg ?? null);
        break;
      case 'fold':
        setSidebarOpen(false);
        break;
      case 'closePalette':
        setPalette({ open: false, attach: null });
        setTip(null);
        break;
      case 'run':
        startRun();
        break;
      case 'closeRun':
        setRun((now) => ({ ...now, open: false }));
        break;
      case 'attach':
        setAddTip(null);
        setPalette({ open: true, attach: { node: arg!, port: 'out' } });
        break;
      case 'settings':
        setSelected([arg!]);
        setSettings({ id: arg!, open: true });
        setPalette((now) => ({ ...now, open: false }));
        break;
      case 'chip':
        setConfig((now) => ({
          ...now,
          extensions: now.extensions.includes(arg!)
            ? now.extensions.filter((item) => item !== arg)
            : [...now.extensions, arg!],
        }));
        break;
      case 'closeSettings':
        setSettings((now) => ({ ...now, open: false }));
        setSelected([]);
        break;
    }
  });

  const demoOn = autoplay && !defaultState && playing && ready && size.w > 0;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demoOn || !stage || !arrow) return;
    /** Where an element's middle is, in the stage's px; null when it is not showing. */
    const middleOf = (selector: string): Point | null => {
      const element = stage.querySelector<HTMLElement>(selector);
      if (!element || getComputedStyle(element).visibility === 'hidden') return null;
      const frame = stage.getBoundingClientRect();
      const scale = frame.width / stage.offsetWidth || 1;
      const rect = element.getBoundingClientRect();
      if (!rect.width || rect.right < frame.left || rect.left > frame.right) return null;
      return {
        x: (rect.left + rect.width / 2 - frame.left) / scale,
        y: (rect.top + rect.height / 2 - frame.top) / scale,
      };
    };
    /** Briefly shows an element pressed, as a click does. */
    const press = (selector: string) => {
      const element = stage.querySelector<HTMLElement>(selector);
      if (!element) return;
      element.dataset.pressed = 'true';
      window.setTimeout(() => delete element.dataset.pressed, 160);
    };

    const stop = runDemo(stage, arrow, async ({ wait, glide, glyph }) => {
      const go = async (selector: string, ms = 620, hand = true) => {
        const at = middleOf(selector);
        if (!at) return false;
        glyph('arrow');
        await glide(at, ms);
        if (hand) glyph('hand');
        return true;
      };
      const click = async (selector: string, action: string, arg?: string, ms?: number) => {
        if (!(await go(selector, ms))) return false;
        await wait(140);
        press(selector);
        demo(action, arg);
        return true;
      };
      for (;;) {
        demo('reset');
        await wait(600);
        // The palette's tooltips. On a short stage the list scrolls first, as a reader's would,
        // to bring the actions up.
        const list = paletteList.current;
        const webhook = list?.querySelector<HTMLElement>('[data-kind="webhook"]');
        if (list && webhook && webhook.offsetTop + webhook.offsetHeight > list.clientHeight - 40) {
          list.scrollTo({ top: webhook.offsetTop - list.clientHeight / 2, behavior: 'smooth' });
          await wait(600);
        }
        if (await go('[data-kind="webhook"]', 760)) {
          await wait(300);
          demo('tip', 'webhook');
          await wait(1100);
          demo('tip');
        }
        // Fold the sidebar; close the palette.
        if (stage.dataset.sidebar === 'open')
          await click('[data-part="sidebar-toggle"]', 'fold', undefined, 760);
        await wait(750);
        await click('[data-part="palette-close"]', 'closePalette', undefined, 760);
        await wait(500);
        // A test run.
        await click('[data-part="run"]', 'run', undefined, 760);
        await wait(FIRST_STEP_MS + STEP_MS * stepCount.current + 800);
        await click('[data-part="runs-close"]', 'closeRun', undefined, 760);
        await wait(500);
        // The upload's plus opens the palette, ready to add after it.
        if (await go('[data-node-id="upload"] [data-part="add-handle"]', 760)) {
          await wait(100);
          demo('addTip', 'upload');
          await wait(600);
          press('[data-node-id="upload"] [data-part="add-handle"]');
          demo('attach', 'upload');
        }
        await wait(800);
        // The upload's settings instead: one file type fewer.
        await click('[data-node-id="upload"] [data-part="node"]', 'settings', 'upload', 700);
        await wait(1300);
        await click('[data-ext=".txt"]', 'chip', '.txt', 760);
        await wait(1600);
        await click('[data-part="settings-close"]', 'closeSettings', undefined, 700);
        await wait(900);
        // Run again: the upload's branch runs too now.
        await click('[data-part="run"]', 'run', undefined, 760);
        await wait(FIRST_STEP_MS + STEP_MS * stepCount.current + 1400);
        await click('[data-part="runs-close"]', 'closeRun', undefined, 760);
        await wait(1400);
      }
    });
    return () => {
      stop();
      setTip(null);
      setAddTip(null);
    };
    // The script reads the step count once per loop; it restarts only with the demo itself.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [demoOn, root]);

  /* ——— Drawing ——— */

  const byId = useMemo(() => new Map(nodes.map((node) => [node.id, node])), [nodes]);
  const runState = (id: string) => {
    if (!run.open && run.at >= run.steps.length) return undefined;
    const index = run.steps.findIndex((step) => step.id === id);
    if (index < 0) return undefined;
    if (index < run.at) return 'done';
    if (index === run.at) return run.settled ? 'done' : 'running';
    return undefined;
  };
  const settingsNode = settings.id ? (byId.get(settings.id) ?? null) : null;

  // The minimap: the workflow's box, drawn as bars.
  const box = bounds(nodes);
  const mapLeft = box.x - 40;
  const mapTop = box.y - 40;
  const mapW = box.w + 80;
  const mapH = box.h + 80;

  const filtered = KINDS.filter((kind) =>
    kind.label.toLowerCase().includes(query.trim().toLowerCase()),
  );
  const branchNow = BRANCHES[branch];

  const switcher = (
    id: Exclude<Menu, null>,
    label: string,
    options: string[],
    current: number,
    choose: (index: number) => void,
    badge?: { text: string; tone: 'accent' | 'plain' },
  ) => (
    <div className={styles.crumb} data-menu={id} data-part={`crumb-${id}`}>
      <button
        type="button"
        className={styles.crumbButton}
        aria-haspopup="menu"
        aria-expanded={menu === id}
        aria-controls={`${ids}${id}`}
        onClick={() => setMenu(menu === id ? null : id)}
        onKeyDown={(event) => {
          if (event.key === 'ArrowDown') {
            event.preventDefault();
            setMenu(id);
            requestAnimationFrame(() =>
              root.current
                ?.querySelector<HTMLElement>(`#${CSS.escape(`${ids}${id}`)} [role="menuitemradio"]`)
                ?.focus(),
            );
          }
        }}
      >
        <span className={styles.crumbLabel}>{label}</span>
        {badge && (
          <span className={styles.badge} data-tone={badge.tone}>
            {badge.text}
          </span>
        )}
        <Icon name="updown" className={styles.crumbChevron} />
      </button>
      <div
        className={styles.menu}
        id={`${ids}${id}`}
        role="menu"
        aria-label={label}
        data-open={menu === id}
        inert={menu !== id}
        onKeyDown={(event) => {
          const items = [
            ...event.currentTarget.querySelectorAll<HTMLElement>('[role="menuitemradio"]'),
          ];
          const index = items.indexOf(document.activeElement as HTMLElement);
          if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
            event.preventDefault();
            const step = event.key === 'ArrowDown' ? 1 : -1;
            items[(index + step + items.length) % items.length]?.focus();
          }
          if (event.key === 'Escape') {
            event.stopPropagation();
            setMenu(null);
            (event.currentTarget.previousElementSibling as HTMLElement)?.focus();
          }
        }}
      >
        {options.map((option, index) => (
          <button
            key={option}
            type="button"
            role="menuitemradio"
            aria-checked={index === current}
            className={styles.menuItem}
            tabIndex={-1}
            onClick={() => {
              choose(index);
              setMenu(null);
              announce(`${option} selected.`);
            }}
          >
            <span>{option}</span>
            {index === current && <Icon name="check" className={styles.icon} />}
          </button>
        ))}
      </div>
    </div>
  );

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-part="app"
      data-color-scheme={colorScheme}
      data-ready={ready}
      data-sidebar={sidebarOpen ? 'open' : 'folded'}
      data-palette={palette.open}
      data-runs={run.open}
      data-map={!cramped}
      data-settings={settings.open}
      style={{ '--runs-h': `${runsHeight}px`, '--toolbar-x': `${toolbarX}px` } as CSSProperties}
      onPointerDownCapture={(event) => {
        if (event.isPrimary) take();
      }}
      onKeyDownCapture={take}
      onKeyDown={onRootKey}
    >
      {/* The sidebar: open, or folded to a rail of icons by a clip. */}
      <aside className={styles.sidebar} data-part="sidebar" aria-label="Workspace">
        <div className={styles.brand}>
          <span className={styles.logo} aria-hidden="true">
            <svg viewBox="0 0 24 24" className={styles.logoMark}>
              <path d="M6 7.5h5v4H6zM13 12.5h5v4h-5zM11 9.5h2.5v5H13" />
            </svg>
          </span>
          <span className={styles.brandName}>Relayboard</span>
        </div>
        <nav className={styles.nav} aria-label="Workspace">
          <p className={styles.navHeading}>Workflow management</p>
          <ul className={styles.navList}>
            {NAV.map((item) => (
              <li key={item.id}>
                <button
                  type="button"
                  className={styles.navItem}
                  aria-current={nav === item.id ? 'page' : undefined}
                  aria-label={item.label}
                  title={sidebarOpen ? undefined : item.label}
                  onClick={() => setNav(item.id)}
                >
                  <Icon name={item.icon} className={styles.icon} />
                  <span className={styles.navLabel}>{item.label}</span>
                </button>
              </li>
            ))}
          </ul>
          <p className={styles.navHeading}>Other</p>
          <ul className={styles.navList}>
            {NAV_OTHER.map((item) => (
              <li key={item.id}>
                <button
                  type="button"
                  className={styles.navItem}
                  aria-current={nav === item.id ? 'page' : undefined}
                  aria-label={item.label}
                  title={sidebarOpen ? undefined : item.label}
                  onClick={() => setNav(item.id)}
                >
                  <Icon name={item.icon} className={styles.icon} />
                  <span className={styles.navLabel}>{item.label}</span>
                </button>
              </li>
            ))}
          </ul>
        </nav>
      </aside>
      <div className={styles.scrim} aria-hidden="true" onClick={() => setSidebarOpen(false)} />

      {/* The header: the breadcrumb, then search and the account. */}
      <header className={styles.header} data-part="header">
        <div className={styles.crumbs} data-part="breadcrumb">
          {/* Fold, inside the open sidebar; and the button on its edge, which opens it again. */}
          <button
            type="button"
            className={styles.edgeButton}
            data-part="sidebar-toggle"
            aria-label="Fold the sidebar"
            aria-expanded={sidebarOpen}
            onClick={() => setSidebarOpen(false)}
          >
            <Icon name="fold" className={styles.icon} />
          </button>
          <button
            type="button"
            className={styles.edgeButton}
            data-part="sidebar-edge"
            aria-label={sidebarOpen ? 'Fold the sidebar' : 'Open the sidebar'}
            aria-expanded={sidebarOpen}
            aria-hidden={sidebarOpen || undefined}
            tabIndex={sidebarOpen ? -1 : undefined}
            onClick={() => setSidebarOpen(!sidebarOpen)}
          >
            <Icon name="org" className={styles.icon} />
          </button>
          <nav aria-label="Breadcrumb" className={styles.crumbNav}>
            {switcher('org', ORGS[org], ORGS, org, setOrg, { text: 'Free', tone: 'accent' })}
            <span className={styles.slash} aria-hidden="true">
              /
            </span>
            {switcher('project', PROJECTS[project], PROJECTS, project, setProject)}
            <span className={styles.slash} aria-hidden="true">
              /
            </span>
            {switcher(
              'branch',
              branchNow.name,
              BRANCHES.map((item) => item.name),
              branch,
              setBranch,
              { text: branchNow.env, tone: 'plain' },
            )}
          </nav>
        </div>
        <div className={styles.headerEnd}>
          <label className={styles.search} data-part="search">
            <Icon name="search" className={styles.icon} />
            <input
              type="search"
              placeholder="Search…"
              aria-label="Search node kinds"
              value={query}
              onChange={(event) => {
                setQuery(event.target.value);
                if (event.target.value) setPalette((now) => ({ ...now, open: true }));
              }}
            />
            <kbd className={styles.kbd}>⌘K</kbd>
          </label>
          <button type="button" className={styles.squareButton} aria-label="About this workflow">
            <Icon name="info" className={styles.icon} />
          </button>
          <button type="button" className={styles.squareButton} aria-label="Notifications">
            <Icon name="bell" className={styles.icon} />
          </button>
          <button
            type="button"
            className={styles.squareButton}
            data-tone="raised"
            aria-label="Account: Ana Ribeiro"
          >
            <Icon name="user" className={styles.icon} />
          </button>
        </div>
      </header>

      {/* The canvas. */}
      <div className={styles.main}>
        <div
          ref={canvas}
          className={styles.canvas}
          data-part="canvas"
          data-mode={mode}
          role="group"
          aria-label={`${workflowName} workflow canvas`}
          aria-describedby={`${ids}help`}
          style={
            {
              backgroundPosition: `${view.x + shiftX}px ${view.y - shift}px`,
              backgroundSize: `${20 * view.k}px ${20 * view.k}px`,
            } as CSSProperties
          }
          data-smooth={smooth}
          onPointerDown={onCanvasDown}
        >
          <p id={`${ids}help`} className={styles.hidden}>
            Nodes are buttons: arrows move a node, Enter opens its settings, Delete removes a node
            you added. The plus after a node adds the next one.
          </p>
          <div
            className={styles.world}
            data-part="world"
            data-smooth={smooth}
            style={{
              transform: `translate(${view.x + shiftX}px, ${view.y - shift}px) scale(${view.k})`,
            }}
            onTransitionEnd={() => setSmooth(false)}
          >
            <svg className={styles.wires} aria-hidden="true" data-part="connectors">
              {edges.map((edge) => {
                const a = byId.get(edge.from);
                const b = byId.get(edge.to);
                if (!a || !b) return null;
                const d = connector(startOf(a, edge.port), inPort(b), edge.port !== 'out');
                const live = runState(edge.from) === 'done' && runState(edge.to) === 'running';
                return (
                  <g
                    key={edge.id}
                    data-leaving={leaving.includes(edge.from) || leaving.includes(edge.to)}
                  >
                    <path
                      d={d}
                      className={styles.wire}
                      data-added={edge.added}
                      pathLength={edge.added ? 1 : undefined}
                    />
                    {live && <path d={d} className={styles.wireLive} />}
                  </g>
                );
              })}
              {wire && (
                <path d={connector(wire.from, wire.to, wire.down)} className={styles.wireDraft} />
              )}
            </svg>
            {nodes.map((node) => {
              const state = runState(node.id);
              return (
                <div
                  key={node.id}
                  className={styles.nodeBox}
                  data-node-id={node.id}
                  data-tone={node.tone}
                  data-added={node.added}
                  data-leaving={leaving.includes(node.id)}
                  data-selected={selected.includes(node.id)}
                  data-tail={node.role !== 'Trigger' && !outgoing(node.id)}
                  data-run={state}
                  data-off={
                    config.disabled.includes(node.id) || (node.kind === 'upload' && !config.active)
                  }
                  style={{ transform: `translate(${node.x}px, ${node.y}px)`, width: node.w }}
                >
                  <button
                    type="button"
                    className={styles.node}
                    data-part="node"
                    aria-pressed={selected.includes(node.id)}
                    aria-label={`${node.title}${node.sub ? `, ${node.sub}` : ''}${state === 'running' ? ', running' : ''}`}
                    onPointerDown={(event) => onNodeDown(event, node)}
                    onKeyDown={(event) => onNodeKey(event, node)}
                    onClick={(event) => {
                      // Keyboard presses; the pointer opens settings on release instead.
                      if (event.detail === 0) {
                        setSelected([node.id]);
                        openSettings(node.id);
                      }
                    }}
                  >
                    <span className={styles.nodeIcon} aria-hidden="true">
                      <Icon name={node.icon} className={styles.icon} />
                    </span>
                    <span className={styles.nodeText}>
                      <span className={styles.nodeTitle}>
                        {config.labels[`${node.id}:name`] || node.title}
                      </span>
                      {node.sub && <span className={styles.nodeSub}>{node.sub}</span>}
                    </span>
                  </button>
                  {node.role !== 'Trigger' && (
                    <span className={styles.port} data-side="in" aria-hidden="true" />
                  )}
                  <span
                    className={styles.port}
                    data-side="out"
                    aria-hidden="true"
                    title="Drag to a node to connect"
                    onPointerDown={(event) => onPortDown(event, node, 'out')}
                  />
                  {node.kind === 'agent' &&
                    AGENT_PORTS.map((stem) => (
                      <span
                        key={stem.id}
                        className={styles.stem}
                        style={{ left: stem.x }}
                        data-part={`stem-${stem.id}`}
                      >
                        <span
                          className={styles.port}
                          data-side="stem"
                          aria-hidden="true"
                          onPointerDown={(event) => onPortDown(event, node, stem.id)}
                        />
                        <button
                          type="button"
                          className={styles.stemPlus}
                          aria-label={`Add a ${stem.label.toLowerCase()} to ${node.title}`}
                          onClick={() => {
                            setPalette({ open: true, attach: { node: node.id, port: stem.id } });
                            setSettings((now) => ({ ...now, open: false }));
                          }}
                        >
                          <Icon name="plus" className={styles.icon} />
                        </button>
                        <span className={styles.stemLabel} aria-hidden="true">
                          {stem.label}
                        </span>
                      </span>
                    ))}
                  {!outgoing(node.id) && (
                    <span className={styles.addWrap}>
                      <button
                        type="button"
                        className={styles.addHandle}
                        data-part="add-handle"
                        data-hover={addTip === node.id}
                        aria-label={`Add a node after ${node.title}`}
                        aria-describedby={addTip === node.id ? `${ids}addtip` : undefined}
                        onPointerEnter={() => setAddTip(node.id)}
                        onPointerLeave={() => setAddTip(null)}
                        onFocus={() => setAddTip(node.id)}
                        onBlur={() => setAddTip(null)}
                        onClick={() => {
                          setAddTip(null);
                          setSettings((now) => ({ ...now, open: false }));
                          setPalette({ open: true, attach: { node: node.id, port: 'out' } });
                          requestAnimationFrame(() =>
                            paletteList.current?.querySelector<HTMLElement>('button')?.focus({
                              preventScroll: true,
                            }),
                          );
                        }}
                      >
                        <Icon name="plus" className={styles.icon} />
                      </button>
                      <span
                        className={styles.addTip}
                        role="tooltip"
                        id={addTip === node.id ? `${ids}addtip` : undefined}
                        data-open={addTip === node.id}
                      >
                        Add node
                      </span>
                    </span>
                  )}
                </div>
              );
            })}
          </div>
          {marquee && (
            <span
              className={styles.marquee}
              aria-hidden="true"
              style={{
                transform: `translate(${marquee.x}px, ${marquee.y}px)`,
                width: marquee.w,
                height: marquee.h,
              }}
            />
          )}

          {/* The palette of node kinds. */}
          <aside
            className={styles.palette}
            data-part="palette"
            data-open={palette.open}
            aria-label="Add a node"
            inert={!palette.open}
          >
            <button
              type="button"
              className={styles.paletteClose}
              data-part="palette-close"
              aria-label="Close the palette"
              onClick={() => {
                setPalette({ open: false, attach: null });
                setTip(null);
              }}
            >
              <Icon name="close" className={styles.icon} />
            </button>
            {/* Where a pick will land, for screen readers. */}
            {palette.attach && (
              <p className={styles.hidden}>
                After {byId.get(palette.attach.node)?.title}
                {palette.attach.port !== 'out' ? ` · ${palette.attach.port}` : ''}
              </p>
            )}
            <div ref={paletteList} className={styles.paletteList} onScroll={() => setTip(null)}>
              {GROUPS.map((group) => {
                const items = filtered.filter((kind) => kind.group === group);
                if (!items.length) return null;
                return (
                  <section key={group} className={styles.paletteGroup} aria-label={group}>
                    <h3 className={styles.paletteHeading}>
                      {group} ({items.length})
                    </h3>
                    <ul className={styles.paletteItems}>
                      {items.map((kind) => (
                        <li key={kind.id}>
                          <button
                            type="button"
                            className={styles.paletteItem}
                            data-kind={kind.id}
                            data-hover={tip === kind.id}
                            aria-describedby={tip === kind.id ? `${ids}tip` : undefined}
                            onPointerEnter={() => setTip(kind.id)}
                            onPointerLeave={() => setTip(null)}
                            onFocus={() => setTip(kind.id)}
                            onBlur={() => setTip(null)}
                            onClick={() => addNode(kind.id)}
                          >
                            <Icon name={kind.icon} className={styles.icon} />
                            {kind.label}
                          </button>
                        </li>
                      ))}
                    </ul>
                  </section>
                );
              })}
              {!filtered.length && <p className={styles.paletteEmpty}>No node kind matches.</p>}
            </div>
            <p
              className={styles.tooltip}
              role="tooltip"
              id={`${ids}tip`}
              data-open={tip !== null}
              style={{ top: tipTop }}
            >
              {tipText.current}
            </p>
          </aside>

          {/* The toolbar, and the minimap. */}
          <div
            ref={toolbar}
            className={styles.toolbar}
            role="toolbar"
            aria-label="Canvas"
            data-part="toolbar"
            onKeyDown={(event) => {
              if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return;
              const buttons = [
                ...event.currentTarget.querySelectorAll<HTMLButtonElement>('button'),
              ];
              const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
              if (index < 0) return;
              event.preventDefault();
              buttons[
                (index + (event.key === 'ArrowRight' ? 1 : -1) + buttons.length) % buttons.length
              ].focus();
            }}
          >
            <Icon name="grip" className={styles.grip} />
            <button
              type="button"
              className={styles.toolButton}
              data-part="palette-toggle"
              data-tone={palette.open ? 'danger' : 'accent'}
              aria-label={palette.open ? 'Close the palette' : 'Add a node'}
              aria-expanded={palette.open}
              onClick={() => {
                setPalette(
                  palette.open ? { open: false, attach: null } : { open: true, attach: null },
                );
                setTip(null);
              }}
            >
              <Icon name={palette.open ? 'close' : 'plus'} className={styles.icon} />
            </button>
            <button
              type="button"
              className={styles.toolButton}
              aria-label="Pan"
              aria-pressed={mode === 'hand'}
              onClick={() => setMode('hand')}
            >
              <Icon name="hand" className={styles.icon} />
            </button>
            <button
              type="button"
              className={styles.toolButton}
              aria-label="Select with a lasso"
              aria-pressed={mode === 'select'}
              onClick={() => setMode('select')}
            >
              <Icon name="lasso" className={styles.icon} />
            </button>
            <button
              type="button"
              className={styles.toolButton}
              aria-label="Fit the workflow"
              onClick={() => {
                moved.current = false;
                refit(true);
              }}
            >
              <Icon name="fit" className={styles.icon} />
            </button>
            <button
              type="button"
              className={styles.toolButton}
              aria-label="Zoom out"
              onClick={() => zoomBy(1 / 1.2)}
            >
              <Icon name="zoomOut" className={styles.icon} />
            </button>
            <button
              type="button"
              className={styles.toolButton}
              aria-label="Zoom in"
              onClick={() => zoomBy(1.2)}
            >
              <Icon name="zoomIn" className={styles.icon} />
            </button>
            <button
              type="button"
              className={styles.toolButton}
              data-part="run"
              data-tone="go"
              aria-label="Run a test"
              onClick={startRun}
            >
              <Icon name="play" className={styles.icon} />
            </button>
            <Icon name="grip" className={styles.grip} />
          </div>
          <button
            type="button"
            className={styles.minimap}
            data-part="minimap"
            aria-label={`Minimap: zoom ${Math.round(view.k * 100)}%. Fit the workflow`}
            onClick={() => {
              moved.current = false;
              refit(true);
            }}
          >
            <svg
              viewBox={`${mapLeft} ${mapTop} ${mapW} ${mapH}`}
              preserveAspectRatio="xMidYMid meet"
              aria-hidden="true"
            >
              {nodes.map((node) => (
                <rect
                  key={node.id}
                  x={node.x}
                  y={node.y + NODE_HEIGHT * 0.1}
                  width={node.w}
                  height={NODE_HEIGHT * 0.8}
                  rx={NODE_HEIGHT * 0.12}
                  className={styles.mapNode}
                  data-tone={node.tone}
                />
              ))}
            </svg>
          </button>
          <div className={styles.dim} aria-hidden="true" onClick={closeSettings} />
        </div>

        <RunsPanel
          open={run.open}
          tab={run.tab}
          steps={run.steps}
          at={run.at}
          settled={run.settled}
          expanded={expanded}
          scheduleSet={Boolean(config.repeat)}
          uploadActive={config.active}
          onTab={(tab) => setRun((now) => ({ ...now, tab }))}
          onExpand={(id) =>
            setExpanded((now) =>
              now.includes(id) ? now.filter((item) => item !== id) : [...now, id],
            )
          }
          onStart={startRun}
          onClose={() => setRun((now) => ({ ...now, open: false }))}
        />
      </div>

      <SettingsPanel
        node={settingsNode}
        open={settings.open}
        config={config}
        onConfig={onConfig}
        onRename={(title) =>
          setNodes((now) =>
            now.map((node) =>
              node.id === settings.id
                ? { ...node, title, w: Math.max(node.w, widthFor(title, node.sub)) }
                : node,
            ),
          )
        }
        onDelete={() => settings.id && removeNodes([settings.id])}
        onClose={closeSettings}
        headingRef={settingsHeading}
      />

      <div className={styles.pointerLayer} aria-hidden="true">
        <DemoPointer
          ref={pointer}
          className={styles.pointer}
          arrowClass={styles.arrow}
          handClass={styles.hand}
        />
      </div>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### workflow-builder/panels.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useId,
  useRef,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type Ref,
} from 'react';
import { COST, EXTENSIONS, type Node } from './data';
import { Icon, type IconName } from './icons';
import styles from './workflow-builder.module.css';

/*
 * The two panels that slide over the canvas: a node's settings, from the right, and the test
 * run's report, from the bottom. They only draw what the builder hands them; every change goes
 * back up through a callback, so the canvas and the panels always show the same workflow.
 */

export type Config = {
  /** The upload trigger: where files come from, which types it takes, and whether it is on. */
  source: string;
  allTypes: boolean;
  extensions: string[];
  active: boolean;
  /** The schedule trigger: how often it fires ('' until it is set). */
  repeat: string;
  /** Turned off nodes are skipped by a run. */
  disabled: string[];
  /** Each node's display name and key, by id. */
  labels: Record<string, string>;
};

export const REPEATS = [
  { value: 'hourly', label: 'Every hour' },
  { value: 'daily', label: 'Every day at 09:00' },
  { value: 'weekly', label: 'Every Monday at 09:00' },
];
const SOURCES = ['Integration', 'Upload form', 'Email inbox'];

const keyOf = (node: Node) =>
  capital(
    `${node.kind === 'upload' || node.kind === 'schedule' ? 'trigger' : node.kind.replace(/-/g, '_')}_${node.id === 'upload' ? 2 : node.id === 'schedule' ? 1 : node.id.split('-').pop()}`,
  );
const capital = (text: string) => text.charAt(0).toUpperCase() + text.slice(1);

export function SettingsPanel({
  node,
  open,
  config,
  onConfig,
  onRename,
  onDelete,
  onClose,
  headingRef,
}: {
  node: Node | null;
  open: boolean;
  config: Config;
  onConfig: (patch: Partial<Config>) => void;
  onRename: (title: string) => void;
  onDelete: () => void;
  onClose: () => void;
  headingRef: Ref<HTMLHeadingElement>;
}) {
  const ids = useId();
  if (!node)
    return (
      <aside
        className={styles.settings}
        data-part="settings"
        data-open="false"
        aria-hidden="true"
      />
    );
  const isUpload = node.kind === 'upload';
  const isSchedule = node.kind === 'schedule';
  const key = config.labels[`${node.id}:key`] ?? keyOf(node);
  const enabled = isUpload ? config.active : !config.disabled.includes(node.id);
  const toggle = () => {
    if (isUpload) onConfig({ active: !config.active });
    else
      onConfig({
        disabled: enabled
          ? [...config.disabled, node.id]
          : config.disabled.filter((id) => id !== node.id),
      });
  };
  const schema = isUpload
    ? [
        `"${key}": {`,
        `  "type": "document_upload",`,
        `  "source": "${config.source.toLowerCase().replace(/ /g, '_')}",`,
        `  "accepts": ${config.allTypes ? '"*"' : `[${config.extensions.map((e) => `"${e}"`).join(', ')}]`},`,
        `  "executed_at": "2026-10-13T07:37:34.064Z",`,
        `  "node_id": "56ec32a2-ae6f-4cf7-97d3-7fd05d7ea6b6",`,
        `  "file": { "name": "intake-form.docx", "size": 48213 }`,
        `}`,
      ]
    : [
        `"${key}": {`,
        `  "type": "${node.kind.replace(/-/g, '_')}",`,
        isSchedule
          ? `  "repeat": ${config.repeat ? `"${config.repeat}"` : 'null'},`
          : `  "input": "${node.sub || 'data'}",`,
        `  "enabled": ${enabled},`,
        `  "executed_at": "2026-10-13T07:37:34.064Z",`,
        `  "duration_ms": ${COST[node.id]?.ms ?? 2}`,
        `}`,
      ];

  const onChipKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
    // The chips are one group: arrows move along them.
    if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return;
    const chips = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button')];
    const index = chips.indexOf(document.activeElement as HTMLButtonElement);
    if (index < 0) return;
    event.preventDefault();
    const step = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1;
    chips[(index + step + chips.length) % chips.length].focus();
  };

  return (
    <aside
      className={styles.settings}
      data-part="settings"
      data-open={open}
      aria-labelledby={`${ids}title`}
      inert={!open}
    >
      <div className={styles.settingsHead} data-part="settings-header">
        <span className={styles.settingsIcon} aria-hidden="true">
          <Icon name={node.icon} className={styles.icon} />
        </span>
        <h3 ref={headingRef} id={`${ids}title`} className={styles.settingsTitle} tabIndex={-1}>
          {node.title}
        </h3>
        <button
          type="button"
          className={styles.iconButton}
          data-part="settings-close"
          aria-label="Close settings"
          onClick={onClose}
        >
          <Icon name="close" className={styles.icon} />
        </button>
      </div>
      <div className={styles.settingsBody}>
        {isUpload && (
          <>
            <section className={styles.field} style={{ '--i': 0 } as CSSProperties}>
              <label className={styles.fieldLabel} htmlFor={`${ids}source`}>
                Trigger
              </label>
              <span className={styles.selectWrap}>
                <select
                  id={`${ids}source`}
                  className={styles.select}
                  value={config.source}
                  onChange={(event) => onConfig({ source: event.target.value })}
                >
                  {SOURCES.map((source) => (
                    <option key={source}>{source}</option>
                  ))}
                </select>
                <Icon name="chevronDown" className={styles.selectChevron} />
              </span>
            </section>
            <fieldset className={styles.field} style={{ '--i': 1 } as CSSProperties}>
              <legend className={styles.fieldLabel}>File extensions</legend>
              <label className={styles.radio}>
                <input
                  type="radio"
                  name={`${ids}types`}
                  checked={config.allTypes}
                  onChange={() => onConfig({ allTypes: true })}
                />
                <span>All file types</span>
              </label>
              <label className={styles.radio}>
                <input
                  type="radio"
                  name={`${ids}types`}
                  checked={!config.allTypes}
                  onChange={() => onConfig({ allTypes: false })}
                />
                <span>Specific file types</span>
              </label>
              <div
                className={styles.chips}
                data-part="extensions"
                data-off={config.allTypes}
                role="group"
                aria-label="Accepted file types"
                onKeyDown={onChipKey}
              >
                {EXTENSIONS.map((extension) => {
                  const on = config.extensions.includes(extension);
                  return (
                    <button
                      key={extension}
                      type="button"
                      className={styles.chip}
                      data-ext={extension}
                      aria-pressed={on}
                      disabled={config.allTypes}
                      onClick={() =>
                        onConfig({
                          extensions: on
                            ? config.extensions.filter((item) => item !== extension)
                            : [...config.extensions, extension],
                        })
                      }
                    >
                      {extension}
                      {on && <Icon name="close" className={styles.chipX} />}
                    </button>
                  );
                })}
              </div>
            </fieldset>
          </>
        )}
        {isSchedule && (
          <section className={styles.field} style={{ '--i': 0 } as CSSProperties}>
            <label className={styles.fieldLabel} htmlFor={`${ids}repeat`}>
              Repeat
            </label>
            <span className={styles.selectWrap}>
              <select
                id={`${ids}repeat`}
                className={styles.select}
                value={config.repeat}
                onChange={(event) => onConfig({ repeat: event.target.value })}
              >
                <option value="">Not set</option>
                {REPEATS.map((repeat) => (
                  <option key={repeat.value} value={repeat.value}>
                    {repeat.label}
                  </option>
                ))}
              </select>
              <Icon name="chevronDown" className={styles.selectChevron} />
            </span>
            {!config.repeat && (
              <p className={styles.fieldNote}>
                <Icon name="alert" className={styles.icon} />
                Pick how often it runs to clear the warning.
              </p>
            )}
          </section>
        )}
        {!isUpload && !isSchedule && (
          <section className={styles.field} style={{ '--i': 0 } as CSSProperties}>
            <label className={styles.fieldLabel} htmlFor={`${ids}name`}>
              Name
            </label>
            <input
              id={`${ids}name`}
              className={styles.input}
              value={node.title}
              maxLength={28}
              onChange={(event) => onRename(event.target.value)}
            />
          </section>
        )}
        <section className={styles.field} style={{ '--i': 2 } as CSSProperties}>
          <h4 className={styles.fieldLabel}>Options</h4>
          <label className={styles.switchRow}>
            <input
              type="checkbox"
              role="switch"
              className={styles.switch}
              checked={enabled}
              onChange={toggle}
            />
            <span>{isUpload || isSchedule ? 'Trigger activated' : 'Step enabled'}</span>
          </label>
          <input
            className={styles.input}
            placeholder="Display name"
            aria-label="Display name"
            value={config.labels[`${node.id}:name`] ?? ''}
            onChange={(event) =>
              onConfig({ labels: { ...config.labels, [`${node.id}:name`]: event.target.value } })
            }
          />
          <input
            className={styles.input}
            aria-label="Key"
            value={key}
            onChange={(event) =>
              onConfig({
                labels: {
                  ...config.labels,
                  [`${node.id}:key`]: event.target.value.replace(/[^\w]/g, '_'),
                },
              })
            }
          />
        </section>
        <section className={styles.field} style={{ '--i': 3 } as CSSProperties}>
          <h4 className={styles.fieldLabel}>
            Output scheme <span className={styles.fieldHint}>(Example data)</span>
          </h4>
          <pre className={styles.code} tabIndex={0} aria-label="Output scheme, example data">
            {schema.join('\n')}
          </pre>
        </section>
        {node.added && (
          <button type="button" className={styles.danger} onClick={onDelete}>
            <Icon name="trash" className={styles.icon} />
            Delete node
          </button>
        )}
      </div>
    </aside>
  );
}

export type RunTab = 'payload' | 'triggers' | 'error' | 'timeline' | 'runs';
const TABS: { id: RunTab; label: string; icon: IconName }[] = [
  { id: 'payload', label: 'Payload', icon: 'braces' },
  { id: 'triggers', label: 'Triggers', icon: 'bolt' },
  { id: 'error', label: 'Error', icon: 'alert' },
  { id: 'timeline', label: 'Timeline', icon: 'clock' },
  { id: 'runs', label: 'Runs', icon: 'play' },
];

export type RunStep = { id: string; title: string; role: string; ms: number; value: string };

export function RunsPanel({
  open,
  tab,
  steps,
  at,
  settled,
  expanded,
  scheduleSet,
  uploadActive,
  onTab,
  onExpand,
  onStart,
  onClose,
}: {
  open: boolean;
  tab: RunTab;
  steps: RunStep[];
  /** The step shown last: -1 before the first, steps.length once the run is over. */
  at: number;
  /** The step at `at` has taken its check. */
  settled: boolean;
  expanded: string[];
  scheduleSet: boolean;
  uploadActive: boolean;
  onTab: (tab: RunTab) => void;
  onExpand: (id: string) => void;
  onStart: () => void;
  onClose: () => void;
}) {
  const ids = useId();
  const tabs = useRef<(HTMLButtonElement | null)[]>([]);
  const done = at >= steps.length;
  const total = steps.reduce((sum, step) => sum + step.ms, 0);

  const onTabKey = (event: ReactKeyboardEvent, index: number) => {
    let next = -1;
    if (event.key === 'ArrowRight') next = (index + 1) % TABS.length;
    if (event.key === 'ArrowLeft') next = (index - 1 + TABS.length) % TABS.length;
    if (event.key === 'Home') next = 0;
    if (event.key === 'End') next = TABS.length - 1;
    if (next < 0) return;
    event.preventDefault();
    onTab(TABS[next].id);
    tabs.current[next]?.focus();
  };

  return (
    <section
      className={styles.runs}
      data-part="runs"
      data-open={open}
      aria-label="Test run"
      inert={!open}
    >
      <div className={styles.runsHead}>
        <div className={styles.tabs} role="tablist" aria-label="Run details" data-part="run-tabs">
          {TABS.map((item, index) => (
            <button
              key={item.id}
              ref={(element) => {
                tabs.current[index] = element;
              }}
              type="button"
              role="tab"
              id={`${ids}tab-${item.id}`}
              aria-controls={`${ids}panel`}
              aria-selected={tab === item.id}
              tabIndex={tab === item.id ? 0 : -1}
              className={styles.tab}
              data-tab={item.id}
              onClick={() => onTab(item.id)}
              onKeyDown={(event) => onTabKey(event, index)}
            >
              <Icon name={item.icon} className={styles.icon} />
              {item.label}
            </button>
          ))}
        </div>
        <button
          type="button"
          className={styles.iconButton}
          data-part="runs-close"
          aria-label="Close test run"
          onClick={onClose}
        >
          <Icon name="close" className={styles.icon} />
        </button>
      </div>
      <div
        className={styles.runsBody}
        role="tabpanel"
        id={`${ids}panel`}
        aria-labelledby={`${ids}tab-${tab}`}
        tabIndex={0}
      >
        {tab === 'runs' && (
          <div className={styles.runList} data-part="run-list">
            <div className={styles.runGroup}>
              <span
                className={styles.runCheck}
                data-state={done ? 'done' : 'running'}
                aria-hidden="true"
              >
                <Icon name="check" className={styles.icon} />
              </span>
              <Icon name="chevronDown" className={styles.runChevron} />
              <span className={styles.runName}>Test</span>
            </div>
            <ol className={styles.runRows}>
              {steps.map((step, index) =>
                index > at ? null : (
                  <li
                    key={step.id}
                    className={styles.runRow}
                    data-state={index < at || settled ? 'done' : 'running'}
                  >
                    <button
                      type="button"
                      className={styles.runRowButton}
                      aria-expanded={expanded.includes(step.id)}
                      onClick={() => onExpand(step.id)}
                    >
                      <span
                        className={styles.runCheck}
                        data-state={index < at || settled ? 'done' : 'running'}
                        aria-hidden="true"
                      >
                        <Icon name="check" className={styles.icon} />
                      </span>
                      <Icon name="chevronRight" className={styles.runChevron} />
                      <span className={styles.runName}>{step.title}</span>
                      <span className={styles.runRole}>{step.role}</span>
                      <span className={styles.runBadge}>1/3</span>
                      <span className={styles.runTime}>{step.ms}ms</span>
                      <span className={styles.runValue}>{step.value}</span>
                    </button>
                    {expanded.includes(step.id) && (
                      <p className={styles.runDetail}>
                        Input 1 item · output 1 item · finished in {step.ms}ms on worker eu-2
                      </p>
                    )}
                  </li>
                ),
              )}
            </ol>
          </div>
        )}
        {tab === 'payload' && (
          <pre className={styles.code}>
            {[
              '{',
              '  "workflow": "customer_intake",',
              `  "trigger": "${steps[0]?.id ?? 'manual'}",`,
              `  "steps": ${steps.length},`,
              `  "status": "${done ? 'success' : 'running'}",`,
              '  "record": { "id": "cus_4821", "email": "ana.ribeiro@example.com" }',
              '}',
            ].join('\n')}
          </pre>
        )}
        {tab === 'triggers' && (
          <ul className={styles.plainList}>
            <li>
              <Icon name="clock" className={styles.icon} /> Schedule
              <span className={styles.pill} data-tone={scheduleSet ? 'ok' : 'error'}>
                {scheduleSet ? 'Configured' : 'Not configured'}
              </span>
            </li>
            <li>
              <Icon name="upload" className={styles.icon} /> Document upload
              <span className={styles.pill} data-tone={uploadActive ? 'ok' : undefined}>
                {uploadActive ? 'Active' : 'Paused'}
              </span>
            </li>
          </ul>
        )}
        {tab === 'error' && (
          <p className={styles.runNote}>
            {scheduleSet
              ? 'No errors in the last run.'
              : 'Schedule: the trigger is not configured, so this test was started by hand. Set how often it repeats in its settings.'}
          </p>
        )}
        {tab === 'timeline' && (
          <ol className={styles.timeline}>
            {steps.map((step, index) => {
              const before = steps.slice(0, index).reduce((sum, item) => sum + item.ms, 0);
              return (
                <li key={step.id}>
                  <span className={styles.timelineName}>{step.title}</span>
                  <span className={styles.timelineTrack}>
                    <span
                      className={styles.timelineBar}
                      style={{
                        left: `${(before / Math.max(total, 1)) * 100}%`,
                        width: `${Math.max(2, (step.ms / Math.max(total, 1)) * 100)}%`,
                      }}
                    />
                  </span>
                  <span className={styles.runTime}>{step.ms}ms</span>
                </li>
              );
            })}
          </ol>
        )}
      </div>
      <button type="button" className={styles.startButton} data-part="run-start" onClick={onStart}>
        Start workflow
      </button>
    </section>
  );
}
```

### workflow-builder/data.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import type { IconName } from './icons';

/*
 * The builder's data: the kinds of node the palette offers, the workflow it opens on, and the
 * numbers a test run reports. All of it is invented.
 *
 * Positions are in the canvas's own px (its "world"), which the view pans and zooms. Every node
 * is NODE_HEIGHT tall and as wide as its entry says, so the connectors can be drawn from the data
 * alone, without measuring the page.
 */

export type Group = 'Tools' | 'Logic' | 'Action';

export type Kind = {
  id: string;
  label: string;
  group: Group;
  icon: IconName;
  /** What it does: the palette's tooltip. */
  description: string;
  /** The line under its name on the canvas, until it is set up. */
  sub: string;
};

export const KINDS: Kind[] = [
  {
    id: 'calculation',
    label: 'Calculation',
    group: 'Tools',
    icon: 'calculator',
    description: 'Runs a formula over the fields of the incoming data.',
    sub: 'Formula not set',
  },
  {
    id: 'integration',
    label: 'Integration',
    group: 'Tools',
    icon: 'rocket',
    description: 'Calls one of the apps connected to the project with the data.',
    sub: 'No app chosen',
  },
  {
    id: 'file-parser',
    label: 'File parser',
    group: 'Tools',
    icon: 'file',
    description: 'Reads a document and returns its text and its fields.',
    sub: '[document.text]',
  },
  {
    id: 'http',
    label: 'HTTP request',
    group: 'Tools',
    icon: 'globe',
    description: 'Sends a request to any address and passes on the answer.',
    sub: 'GET',
  },
  {
    id: 'validation',
    label: 'Validation',
    group: 'Tools',
    icon: 'shield',
    description: 'Checks the data against a schema and stops whatever fails.',
    sub: 'Schema not set',
  },
  {
    id: 'branch',
    label: 'Branch',
    group: 'Logic',
    icon: 'branch',
    description: 'Sends the data down one path or another, by a condition.',
    sub: 'If / else',
  },
  {
    id: 'merge',
    label: 'Merge',
    group: 'Logic',
    icon: 'merge',
    description: 'Waits for several paths and joins their data into one.',
    sub: 'Wait for all',
  },
  {
    id: 'wait',
    label: 'Wait',
    group: 'Logic',
    icon: 'clock',
    description: 'Holds the run for a set time, or until a date.',
    sub: '5 minutes',
  },
  {
    id: 'release',
    label: 'Release',
    group: 'Logic',
    icon: 'release',
    description: 'Lets a held run go on once someone approves it.',
    sub: 'Needs approval',
  },
  {
    id: 'catch',
    label: 'Catch error',
    group: 'Logic',
    icon: 'catch',
    description: 'Catches a step that failed and runs a fallback instead.',
    sub: 'Fallback',
  },
  {
    id: 'database',
    label: 'Database',
    group: 'Action',
    icon: 'database',
    description: 'Writes the data to one of the tables of the project.',
    sub: '[customer.insert]',
  },
  {
    id: 'file',
    label: 'File',
    group: 'Action',
    icon: 'fileText',
    description: 'Saves the data as a file in the project’s storage.',
    sub: '/exports',
  },
  {
    id: 'webhook',
    label: 'Webhook',
    group: 'Action',
    icon: 'webhook',
    description: 'Posts the workflow data to an endpoint you define, then ends the run.',
    sub: 'POST',
  },
  {
    id: 'sheet',
    label: 'Spreadsheet',
    group: 'Action',
    icon: 'sheet',
    description: 'Adds the data as a row to a sheet of the project.',
    sub: 'Append row',
  },
  {
    id: 'notification',
    label: 'Notification',
    group: 'Action',
    icon: 'megaphone',
    description: 'Tells a channel or a person that the run got here.',
    sub: '#ops',
  },
  {
    id: 'workflow',
    label: 'Workflow',
    group: 'Action',
    icon: 'workflow',
    description: 'Starts another workflow of the project with this data.',
    sub: 'Not linked',
  },
  {
    id: 'log',
    label: 'Log',
    group: 'Action',
    icon: 'log',
    description: 'Keeps a record of the data for later audits.',
    sub: '30 days',
  },
  {
    id: 'end',
    label: 'End',
    group: 'Action',
    icon: 'stop',
    description: 'Ends the run here.',
    sub: 'Stop',
  },
];

export const GROUPS: Group[] = ['Tools', 'Logic', 'Action'];

/** How a run names each kind of step. */
export const ROLE: Record<Group | 'Trigger' | 'Agent', string> = {
  Tools: 'Tool',
  Logic: 'Logic',
  Action: 'Action',
  Trigger: 'Trigger',
  Agent: 'Tool-ai',
};

export const NODE_HEIGHT = 66;

export type Node = {
  id: string;
  /** A kind's id, or one of the starting workflow's own: schedule, upload, agent, transform. */
  kind: string;
  title: string;
  sub: string;
  icon: IconName;
  x: number;
  y: number;
  w: number;
  /** Error: red, a trigger not set up. Trigger: blue. */
  tone?: 'error' | 'trigger';
  role: keyof typeof ROLE;
  /** Added in this session: drawn in with a short pop. */
  added?: boolean;
};

/** The AI agent's two extra inputs, under it: where their stems leave it, from its left edge. */
export const AGENT_PORTS = [
  { id: 'model', label: 'Model', x: 44 },
  { id: 'tools', label: 'Tools', x: 112 },
] as const;
/** How far below the agent's bottom edge the plus of each stem sits, and its label. */
export const STEM = 36;

export type Edge = {
  id: string;
  from: string;
  to: string;
  /** Which output: the right-hand one, or one of the agent's stems. */
  port: 'out' | 'model' | 'tools';
  added?: boolean;
};

export const START_NODES: Node[] = [
  {
    id: 'schedule',
    kind: 'schedule',
    title: 'Schedule',
    sub: 'Schedule not configured',
    icon: 'warning',
    x: 0,
    y: 155,
    w: 230,
    tone: 'error',
    role: 'Trigger',
  },
  {
    id: 'agent',
    kind: 'agent',
    title: 'AI Agent',
    sub: 'Agent node',
    icon: 'sparkle',
    x: 315,
    y: 67,
    w: 155,
    role: 'Agent',
  },
  {
    id: 'transform',
    kind: 'transform',
    title: 'Transformation',
    sub: '[customer.insert]',
    icon: 'swap',
    x: 555,
    y: 0,
    w: 191,
    role: 'Tools',
  },
  {
    id: 'database',
    kind: 'database',
    title: 'Database',
    sub: '[customer.insert]',
    icon: 'database',
    x: 831,
    y: 134,
    w: 188,
    role: 'Action',
  },
  {
    id: 'upload',
    kind: 'upload',
    title: 'Document upload',
    sub: '',
    icon: 'upload',
    x: 0,
    y: 306,
    w: 207,
    tone: 'trigger',
    role: 'Trigger',
  },
];

export const START_EDGES: Edge[] = [
  { id: 'schedule-agent', from: 'schedule', to: 'agent', port: 'out' },
  { id: 'agent-transform', from: 'agent', to: 'transform', port: 'out' },
  { id: 'transform-database', from: 'transform', to: 'database', port: 'out' },
];

/** What a test run reports for each step: its time and its cost. */
export const COST: Record<string, { ms: number; value: string }> = {
  schedule: { ms: 11, value: '24.00 Credits (~$0.02)' },
  upload: { ms: 9, value: '18.00 Credits (~$0.01)' },
  agent: { ms: 2, value: '1.00' },
  transform: { ms: 2, value: '6.00' },
  database: { ms: 3, value: '6.00' },
};

/** The file types the upload trigger can take, and the ones it starts with. */
export const EXTENSIONS = [
  '.pdf',
  '.doc',
  '.docx',
  '.odt',
  '.rtf',
  '.txt',
  '.xls',
  '.xlsx',
  '.csv',
  '.ods',
  '.jpg',
  '.jpeg',
  '.png',
  '.gif',
  '.webp',
  '.svg',
  '.tiff',
  '.zip',
  '.rar',
  '.7z',
  '.tar',
  '.gz',
  '.json',
  '.xml',
  '.yaml',
  '.ppt',
  '.pptx',
  '.eml',
  '.msg',
];
export const START_EXTENSIONS = ['.doc', '.docx', '.txt'];

/** The switchers of the breadcrumb. */
export const ORGS = ['Larkspur Studio', 'Halden Group', 'Corvo Labs'];
export const PROJECTS = ['Project Meridian', 'Project Lumen', 'Project Tidewater'];
export const BRANCHES = [
  { name: 'Main', env: 'Production' },
  { name: 'Next', env: 'Staging' },
  { name: 'Sandbox', env: 'Preview' },
];

/** The sidebar. */
export const NAV = [
  { id: 'dashboard', label: 'Dashboard', icon: 'home' },
  { id: 'collection', label: 'Collection', icon: 'folder' },
  { id: 'integrations', label: 'Integrations', icon: 'rocket' },
  { id: 'templates', label: 'Templates', icon: 'templates' },
  { id: 'runs', label: 'Runs', icon: 'runs' },
  { id: 'variables', label: 'Variables', icon: 'variables' },
  { id: 'tables', label: 'Data tables', icon: 'tables' },
] as const;
export const NAV_OTHER = [
  { id: 'knowledge', label: 'Knowledge hub', icon: 'book' },
  { id: 'settings', label: 'Settings', icon: 'gear' },
] as const;
```

### workflow-builder/geometry.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

import { AGENT_PORTS, NODE_HEIGHT, STEM, type Edge, type Node } from './data';

/*
 * The canvas's geometry, in world px: where each node's ports are, the path of a connector, the
 * box around the whole workflow, and the view (pan and zoom) that fits it in the canvas.
 */

export type Point = { x: number; y: number };
export type View = { x: number; y: number; k: number };

export const ZOOM_MIN = 0.25;
export const ZOOM_MAX = 1.6;
/** How far right of a node its "add node" plus sits, from the output port. */
export const ADD_REACH = 36;

export const inPort = (node: Node): Point => ({ x: node.x, y: node.y + NODE_HEIGHT / 2 });
export const outPort = (node: Node): Point => ({ x: node.x + node.w, y: node.y + NODE_HEIGHT / 2 });

/** Where a connector leaves a node: its output port, or under the plus of one of the agent's stems. */
export function startOf(node: Node, port: Edge['port']): Point {
  const stem = AGENT_PORTS.find((item) => item.id === port);
  if (!stem) return outPort(node);
  return { x: node.x + stem.x, y: node.y + NODE_HEIGHT + STEM + 8 };
}

/**
 * A connector's path. From an output port, it leaves to the right, turns down or up through
 * rounded corners at the middle and comes in to the right; when the target is behind the source
 * it loops as a soft S instead. From a stem it leaves downwards and bends into the target.
 */
export function connector(a: Point, b: Point, down = false): string {
  if (down) {
    const reach = Math.max(40, Math.abs(b.y - a.y) * 0.6);
    return `M${a.x} ${a.y}C${a.x} ${a.y + reach} ${b.x - 60} ${b.y} ${b.x} ${b.y}`;
  }
  const dx = b.x - a.x;
  const dy = b.y - a.y;
  if (dx < 24) {
    const reach = Math.max(60, Math.abs(dx) * 0.5 + 40);
    return `M${a.x} ${a.y}C${a.x + reach} ${a.y} ${b.x - reach} ${b.y} ${b.x} ${b.y}`;
  }
  if (Math.abs(dy) < 1) return `M${a.x} ${a.y}H${b.x}`;
  const mid = a.x + dx / 2;
  const r = Math.min(30, dx / 2, Math.abs(dy) / 2);
  const s = Math.sign(dy);
  return [
    `M${a.x} ${a.y}`,
    `H${mid - r}`,
    `Q${mid} ${a.y} ${mid} ${a.y + s * r}`,
    `V${b.y - s * r}`,
    `Q${mid} ${b.y} ${mid + r} ${b.y}`,
    `H${b.x}`,
  ].join('');
}

/** The box around every node, with the agent's stems and labels under it. */
export function bounds(nodes: Node[]) {
  if (!nodes.length) return { x: 0, y: 0, w: 1, h: 1 };
  let left = Infinity;
  let top = Infinity;
  let right = -Infinity;
  let bottom = -Infinity;
  for (const node of nodes) {
    left = Math.min(left, node.x);
    top = Math.min(top, node.y);
    right = Math.max(right, node.x + node.w + (node.tone === 'trigger' ? ADD_REACH + 10 : 0));
    bottom = Math.max(bottom, node.y + NODE_HEIGHT + (node.kind === 'agent' ? STEM + 34 : 0));
  }
  return { x: left, y: top, w: right - left, h: bottom - top };
}

/**
 * The view that centres the workflow in a w × h canvas, right of a strip `left` px wide (what the
 * open palette and sidebar cover), never zoomed in past 1.
 */
export function fit(nodes: Node[], w: number, h: number, left = 0): View {
  const box = bounds(nodes);
  const pad = w < 500 ? 14 : 48;
  const room = w - left;
  const k = clamp(Math.min((room - pad * 2) / box.w, (h - pad * 2) / box.h, 1), ZOOM_MIN, 1);
  return {
    k,
    x: left + room / 2 - (box.x + box.w / 2) * k,
    y: h / 2 - (box.y + box.h / 2) * k,
  };
}

/** Zooms the view by `factor`, keeping the canvas point `at` where it is. */
export function zoomAt(view: View, factor: number, at: Point): View {
  const k = clamp(view.k * factor, ZOOM_MIN, ZOOM_MAX);
  const ratio = k / view.k;
  return { k, x: at.x - (at.x - view.x) * ratio, y: at.y - (at.y - view.y) * ratio };
}

export const clamp = (value: number, low: number, high: number) =>
  Math.min(high, Math.max(low, value));
```

### workflow-builder/icons.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

/*
 * The builder's icons: line drawings on a 24-unit grid, each one or a few path strings, stroked
 * with the current colour, and framed on the middle 20 units so the drawings fill their box as
 * the interface's do. They are decorative (aria-hidden): the button or the row around each
 * one says what it is. Their size comes from the stylesheet (.icon), never from the SVG.
 */

/** A circle as a path, so every icon is paths only. */
const ring = (cx: number, cy: number, r: number) =>
  `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0`;

export const ICONS = {
  home: ['M4.5 10.2 12 4.2l7.5 6V19a1 1 0 0 1-1 1H15v-5.5H9V20H5.5a1 1 0 0 1-1-1z'],
  folder: [
    'M3.5 7A1.5 1.5 0 0 1 5 5.5h4.3l2 2.2H19A1.5 1.5 0 0 1 20.5 9.2V17A1.5 1.5 0 0 1 19 18.5H5A1.5 1.5 0 0 1 3.5 17z',
    'M3.5 10h17',
  ],
  rocket: [
    'M13.6 5.1c2.8-1.2 5.9-.9 5.9-.9s.3 3.1-.9 5.9L13 15.7 8.3 11z',
    'M8.3 11 5 11.4l-1.6 2.4 3.9.6M13 15.7l-.4 3.3-2.4 1.6-.6-3.9',
    'M6.8 17.2 4.5 19.5',
    ring(15.2, 8.8, 1.2),
  ],
  templates: ['M5 4.5h14v15H5z', 'M5 9.2h14', 'M10 9.2v10.3'],
  runs: [ring(12, 12, 8.3), 'M10.3 9.1v5.8l4.6-2.9z'],
  variables: [
    ring(7, 5.8, 1.8),
    ring(7, 18.2, 1.8),
    ring(17, 7.5, 1.8),
    'M7 7.6v8.8',
    'M17 9.3c0 3.7-3.6 4-10 6',
  ],
  tables: ['M4.5 5h15v14h-15z', 'M4.5 10h15', 'M4.5 14.5h15', 'M10 10v9'],
  book: [
    'M6 4.5h10.5a1.5 1.5 0 0 1 1.5 1.5v13.5H7.5A1.5 1.5 0 0 1 6 18z',
    'M6 16.5a1.5 1.5 0 0 1 1.5-1.5H18',
    'M9.5 8h5',
  ],
  gear: [
    ring(12, 12, 2.6),
    'M12 3.8l1.6 1.9 2.4-.5.6 2.4 2.3.9-.8 2.3 1.4 2-2 1.4.3 2.5-2.5.2-1 2.3-2.3-1-2.3 1-1-2.3-2.5-.2.3-2.5-2-1.4 1.4-2-.8-2.3 2.3-.9.6-2.4 2.4.5z',
  ],
  fold: ['M4.5 5.5h15v13h-15z', 'M15.5 9v6', 'M12.5 12H8M10.2 9.8 8 12l2.2 2.2'],
  org: ['M4.5 5.5h15v13h-15z', 'M9 12h6.5M12.8 9.3 15.5 12l-2.7 2.7'],
  updown: ['M8.5 9.5 12 6l3.5 3.5', 'M8.5 14.5 12 18l3.5-3.5'],
  search: [ring(10.8, 10.8, 5.8), 'M15 15l4.5 4.5'],
  info: [ring(12, 12, 8.3), 'M12 11v5', 'M12 8h.01'],
  bell: ['M6.5 16.5V11a5.5 5.5 0 0 1 11 0v5.5l1.5 1.5H5z', 'M10.2 20h3.6'],
  user: [ring(12, 8.8, 3.3), 'M5.5 19.5c.8-3.3 3.4-5 6.5-5s5.7 1.7 6.5 5'],
  menu: ['M4.5 7h15', 'M4.5 12h15', 'M4.5 17h15'],
  close: ['M6.5 6.5l11 11', 'M17.5 6.5l-11 11'],
  calculator: ['M8.5 4.5h11v11', 'M4.5 8.5h11v11h-11z', 'M7.5 14h5M10 11.5v5'],
  file: ['M6 3.5h8l4 4v13H6z', 'M14 3.5v4h4'],
  fileText: ['M6 3.5h8l4 4v13H6z', 'M14 3.5v4h4', 'M9 12h6M9 15.5h6'],
  globe: [
    ring(12, 12, 8.3),
    'M3.7 12h16.6',
    'M12 3.7c2.4 2.4 3.4 5.2 3.4 8.3s-1 5.9-3.4 8.3c-2.4-2.4-3.4-5.2-3.4-8.3s1-5.9 3.4-8.3z',
  ],
  shield: [
    'M12 3.8 18.8 6.5V11c0 4.2-2.8 7.6-6.8 9.2C8 18.6 5.2 15.2 5.2 11V6.5z',
    'M9.3 12l1.9 1.9 3.6-3.8',
  ],
  branch: [
    ring(7, 5.8, 1.8),
    ring(7, 18.2, 1.8),
    ring(17, 8.5, 1.8),
    'M7 7.6v8.8',
    'M17 10.3c0 3.2-2.7 3.9-10 6',
  ],
  merge: [
    ring(7, 5.8, 1.8),
    ring(17, 5.8, 1.8),
    ring(12, 18.2, 1.8),
    'M7 7.6c0 4 5 4.5 5 8.8',
    'M17 7.6c0 4-5 4.5-5 8.8',
  ],
  clock: [ring(12, 12, 8.3), 'M12 7.5V12l3 2'],
  release: [ring(12, 12, 8.3), 'M10.3 9.1v5.8l4.6-2.9z'],
  catch: [ring(12, 12, 8.3), 'M9.2 9.2l5.6 5.6', 'M14.8 9.2l-5.6 5.6'],
  database: [
    'M5.5 6.5c0-1.4 2.9-2.5 6.5-2.5s6.5 1.1 6.5 2.5v11c0 1.4-2.9 2.5-6.5 2.5s-6.5-1.1-6.5-2.5z',
    'M5.5 6.5c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5',
    'M5.5 12c0 1.4 2.9 2.5 6.5 2.5s6.5-1.1 6.5-2.5',
  ],
  webhook: [
    'M10.2 7.8a3.3 3.3 0 1 1 4.9 2.9l2.2 4',
    'M7.5 11.6a3.3 3.3 0 1 0 3.3 5.3h4.6',
    'M17.4 14.7a3.3 3.3 0 1 1-1 4.3',
    'M12.2 8.3 9.9 12.6',
  ],
  sheet: ['M5 4.5h14v15H5z', 'M5 9.5h14M5 14.5h14M10.5 4.5v15'],
  megaphone: ['M4.5 10v4h3l7 4V6l-7 4z', 'M7.5 14l1 5h2.2l-.9-4.4', 'M18 9.5a3 3 0 0 1 0 5'],
  workflow: [ring(6.5, 6.5, 2), ring(17.5, 17.5, 2), 'M6.5 8.5v4a3 3 0 0 0 3 3h6'],
  log: ['M8.5 4.5h10v12', 'M5.5 7.5h10v12h-10z', 'M8 11.5h5M8 14.5h5'],
  stop: [ring(12, 12, 8.3), 'M9.5 9.5h5v5h-5z'],
  warning: ['M12 4.2 20.5 19h-17z', 'M12 10v4.2', 'M12 16.6h.01'],
  upload: [
    'M7.5 17.5h-.8a4 4 0 0 1-.6-8 5.8 5.8 0 0 1 11.3 1.2 3.4 3.4 0 0 1-.3 6.8h-1',
    'M12 19.5v-7',
    'M9.5 14.8 12 12.3l2.5 2.5',
  ],
  sparkle: [
    'M10 3.5l1.7 4.8 4.8 1.7-4.8 1.7L10 16.5l-1.7-4.8L3.5 10l4.8-1.7z',
    'M17.5 14.5l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8z',
  ],
  swap: [
    ring(6.5, 6.5, 1.8),
    ring(17.5, 17.5, 1.8),
    'M6.5 8.3v5.2a3 3 0 0 0 3 3h3.5',
    'M11 14.5l2 2-2 2',
    'M17.5 15.7v-5.2a3 3 0 0 0-3-3H11',
    'M13 5.5l-2 2 2 2',
  ],
  grip: [
    ring(9, 6, 0.9),
    ring(15, 6, 0.9),
    ring(9, 12, 0.9),
    ring(15, 12, 0.9),
    ring(9, 18, 0.9),
    ring(15, 18, 0.9),
  ],
  plus: ['M12 5.5v13', 'M5.5 12h13'],
  hand: [
    'M8.2 12.4V6.3a1.3 1.3 0 0 1 2.6 0v5',
    'M10.8 11V5.1a1.3 1.3 0 0 1 2.6 0V11',
    'M13.4 11V6.3a1.3 1.3 0 0 1 2.6 0V12',
    'M16 11.3V9a1.3 1.3 0 0 1 2.6 0v5.2c0 3.3-2.4 5.8-5.6 5.8-2.2 0-3.6-.9-4.9-2.6l-2.8-3.8a1.3 1.3 0 0 1 2-1.6l1 1.3',
  ],
  lasso: [
    'M6.6 15.4C4.7 14.3 3.5 12.7 3.5 11c0-3.6 3.8-6.5 8.5-6.5s8.5 2.9 8.5 6.5-3.8 6.5-8.5 6.5c-.8 0-1.6-.1-2.4-.3',
    ring(8, 16.3, 1.7),
    'M8 18c0 1.3-.7 2-1.5 2.5',
  ],
  fit: ['M4.5 9V4.5H9', 'M15 4.5h4.5V9', 'M19.5 15v4.5H15', 'M9 19.5H4.5V15'],
  zoomIn: [ring(10.8, 10.8, 5.8), 'M15 15l4.5 4.5', 'M10.8 8.4v4.8M8.4 10.8h4.8'],
  zoomOut: [ring(10.8, 10.8, 5.8), 'M15 15l4.5 4.5', 'M8.4 10.8h4.8'],
  play: ['M8.2 6v12l9.6-6z'],
  check: ['M7.5 12.3l3 3 6-6.3'],
  chevronRight: ['M10 7.5l4.5 4.5-4.5 4.5'],
  chevronDown: ['M7.5 10l4.5 4.5 4.5-4.5'],
  braces: [
    'M9 4.5c-1.6 0-2.3.7-2.3 2.2v2.6c0 1.2-.6 2-1.7 2.2 1.1.2 1.7 1 1.7 2.2v2.6c0 1.5.7 2.2 2.3 2.2',
    'M15 4.5c1.6 0 2.3.7 2.3 2.2v2.6c0 1.2.6 2 1.7 2.2-1.1.2-1.7 1-1.7 2.2v2.6c0 1.5-.7 2.2-2.3 2.2',
  ],
  bolt: ['M13 3.8 5.8 13.2H12l-1 7 7.2-9.4H12z'],
  alert: ['M12 4.2 20.5 19h-17z', 'M12 10v4.2', 'M12 16.6h.01'],
  trash: ['M5 7h14', 'M9.5 7V5h5v2', 'M7 7l.8 12.5h8.4L17 7'],
} as const;

export type IconName = keyof typeof ICONS;

export function Icon({ name, className }: { name: IconName; className?: string }) {
  return (
    <svg
      className={className}
      viewBox="2 2 20 20"
      fill="none"
      stroke="currentColor"
      strokeWidth="1.7"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      focusable="false"
    >
      {ICONS[name].map((d) => (
        <path key={d} d={d} />
      ))}
    </svg>
  );
}
```

### workflow-builder/demo-pointer.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, or
 * glide the pointer somewhere. Stopping the demo (the reader takes over, or it scrolls away) ends
 * the step in progress at once: its promise rejects, which ends the script right there, and the
 * pointer fades out.
 *
 * Each glide is a curved flight played by the Web Animations API, so nothing runs frame by frame.
 * The points of the flight are handed to whoever wants to move along with it. The arrow and the hand are HTML boxes
 * that swap by their visibility: a change of style inside an SVG would lay the SVG out again.
 */

export type Point = { x: number; y: number };

/** A glide's path and timing, for an animation that plays along with it. */
export type Flight = { points: Point[]; timing: KeyframeAnimationOptions };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer to `to`, in the stage's own px, in `ms`; `along` gets the flight. */
  glide: (to: Point, ms: number, along?: (flight: Flight) => void) => Promise<void>;
  /** Shows the arrow, or the hand a link shows. */
  glyph: (name: 'arrow' | 'hand') => void;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, as shares of its box: the point the pointer stands on. */
const TIP: Point = { x: 0.15, y: 0.1 };

/** How many points a flight is drawn through. */
const SAMPLES = 24;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  // The stylesheet sizes the pointer from the stage's width; its tip is where it stands.
  const tip = { x: pointer.offsetWidth * TIP.x, y: pointer.offsetHeight * TIP.y };
  const place = ({ x, y }: Point) => `translate(${x - tip.x}px, ${y - tip.y}px)`;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const glide: Demo['glide'] = (to, ms, along) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.18, y: (from.y + to.y) / 2 + dx * 0.18 };
    const points: Point[] = [];
    for (let i = 0; i <= SAMPLES; i += 1) {
      const t = i / SAMPLES;
      const u = 1 - t;
      points.push({
        x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
        y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
      });
    }
    const timing: KeyframeAnimationOptions = {
      duration: ms,
      easing: 'cubic-bezier(0.45, 0, 0.25, 1)',
    };
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(
      points.map((point) => ({ transform: place(point) })),
      timing,
    );
    along?.({ points, timing });
    return step(flight.finished);
  };

  const glyph: Demo['glyph'] = (name) => {
    pointer.dataset.glyph = name;
  };

  pointer.dataset.glyph = 'arrow';
  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, glide, glyph }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/**
 * The pointer's box: it moves, on a layer of its own. Its size comes from its class, its colours
 * from the stage's tokens (workflow-builder.module.css).
 */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 2,
  display: 'block',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** One glyph over the other, each filling the box. */
const GLYPH: CSSProperties = { position: 'absolute', inset: 0, display: 'block' };
const DRAWING: CSSProperties = {
  display: 'block',
  width: '100%',
  height: '100%',
  overflow: 'visible',
};
/** The arrow is dark on a white edge, the hand white on a dark one, as the system draws them. */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };
const HAND: CSSProperties = { fill: 'var(--pointer-edge)', stroke: 'var(--pointer)' };

/**
 * The pointer the demo moves around: an arrow, or a pointing hand over what it clicks. It is
 * decorative: what it does shows on the builder itself. The stylesheet shows one glyph at a time,
 * from the box's data-glyph.
 */
export function DemoPointer({
  ref,
  className,
  arrowClass,
  handClass,
}: {
  ref: Ref<HTMLSpanElement>;
  className?: string;
  arrowClass?: string;
  handClass?: string;
}) {
  return (
    <span ref={ref} className={className} style={POINTER} aria-hidden="true">
      <span className={arrowClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
      <span className={handClass} style={GLYPH}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          {/* The tip of the finger sits where the arrow's tip is. */}
          <path
            d="M6.4 3.3c0-.8.6-1.4 1.3-1.4S9 2.5 9 3.3v5.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.5c.2-.5.6-.8 1.2-.8.7 0 1.2.5 1.2 1.2v.7c.2-.4.6-.7 1.1-.7.7 0 1.2.5 1.2 1.2v3.5c0 2.8-1.9 4.9-4.6 4.9h-1.2c-1.8 0-3.1-.8-4.1-2.3l-2.6-3.9c-.4-.6-.2-1.3.4-1.7.6-.3 1.3-.2 1.7.4l.6.9z"
            style={HAND}
            strokeWidth="1.1"
            strokeLinejoin="round"
            transform="translate(-4.7 0.1)"
          />
        </svg>
      </span>
    </span>
  );
}
```

### workflow-builder/use-autoplay.ts

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input, or never when `idle` is null (a builder the
 * reader has edited is theirs: the demo would undo their work).
 */
export function useAutoplay<T extends HTMLElement>(idle: number | null = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    if (idle !== null) timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```

### workflow-builder/workflow-builder.module.css

```css
/* Workflow builder (workflow-builder.tsx): a whole app screen. A sidebar on the left, a header
   along the top, and under it a dotted canvas with the workflow, its palette, toolbar and minimap;
   the run's report rises from the bottom and a node's settings slide in from the right.

   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it
   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop
   sets (data-color-scheme). The dark scheme is near-black greys with one blue, a red for what
   needs attention and a green for Run.

   Memory and motion. The stage is a layout boundary of a fixed height. What moves moves by
   transform or opacity: the world of nodes (pan and zoom), the panels, the toolbar and the
   minimap; the sidebar folds by a clip-path. Sizes are in px for a 1200–1440 px app and shrink
   by container queries down to 320 px. */

.root {
  --app: #fbfbfb;
  --canvas: #f6f6f6;
  --surface: #ffffff;
  --raised: #f1f1f1;
  --field: #f7f7f7;
  --line: #e7e7e7;
  --line-strong: #dadada;
  --ink: #141414;
  --ink-2: #5b5b5b;
  --ink-3: #9a9a9a;
  --dot: #d9d9d9;
  --wire: #9b9b9b;
  --port: #8b8b8b;
  --accent: #3a63d4;
  --accent-ink: #2e55c4;
  --accent-soft: #e9efff;
  --accent-line: #c5d4fb;
  --plus: #6f8ff0;
  --danger: #d9263b;
  --danger-ink: #c81e33;
  --danger-soft: #fff1f2;
  --danger-line: #f5c9ce;
  --danger-icon: #ffe2e5;
  --trigger-soft: #f1f5ff;
  --trigger-line: #cfdcfb;
  --trigger-icon: #dfe8ff;
  --go: #2fbf2a;
  --go-ink: #ffffff;
  --stem: #b9b9b9;
  --stem-ink: #1a1a1a;
  --shadow: rgb(0 0 0 / 0.08);
  --dim: rgb(250 250 250 / 0.55);
  --map: #b8b8b8;
  --tip-ink: #6b6b6b;
  --focus: #3a6ff0;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.3);

  --rail: 67px;
  --side: 277px;
  --head: 67px;
  --ease: cubic-bezier(0.32, 0.72, 0, 1);
  /* The run's report and what moves with it: a gentle start and end. */
  --ease-io: cubic-bezier(0.4, 0, 0.2, 1);

  position: relative;
  container-type: inline-size;
  contain: layout paint;
  isolation: isolate;
  width: 100%;
  height: 720px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--app);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family:
    Inter,
    ui-sans-serif,
    system-ui,
    -apple-system,
    'Segoe UI',
    sans-serif;
  font-size: 14px;
  line-height: 1.3;
  text-align: left;
  -webkit-tap-highlight-color: transparent;
  -webkit-font-smoothing: antialiased;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --app: light-dark(#fbfbfb, #111111);
    --canvas: light-dark(#f6f6f6, #111111);
    --surface: light-dark(#ffffff, #161616);
    --raised: light-dark(#f1f1f1, #1f1f1f);
    --field: light-dark(#f7f7f7, #1b1b1b);
    --line: light-dark(#e7e7e7, #262626);
    --line-strong: light-dark(#dadada, #303030);
    --ink: light-dark(#141414, #ececec);
    --ink-2: light-dark(#5b5b5b, #a1a1a1);
    --ink-3: light-dark(#9a9a9a, #626262);
    --dot: light-dark(#d6d6d6, #2b2b2b);
    --wire: light-dark(#9b9b9b, #8a8a8a);
    --port: light-dark(#8b8b8b, #8f8f8f);
    --accent: light-dark(#3a63d4, #3a63d4);
    --accent-ink: light-dark(#2e55c4, #a8bcff);
    --accent-soft: light-dark(#e9efff, #1a2338);
    --accent-line: light-dark(#c5d4fb, #2c4687);
    --plus: light-dark(#6f8ff0, #7f9cf3);
    --danger-ink: light-dark(#c81e33, #ff5d6c);
    --danger-soft: light-dark(#fff1f2, #221414);
    --danger-line: light-dark(#f5c9ce, #3c1a1e);
    --danger-icon: light-dark(#ffe2e5, #3a161a);
    --trigger-soft: light-dark(#f1f5ff, #171d2d);
    --trigger-line: light-dark(#cfdcfb, #26324f);
    --trigger-icon: light-dark(#dfe8ff, #22305a);
    --go: light-dark(#2fbf2a, #43d13d);
    --go-ink: #ffffff;
    --stem: light-dark(#b9b9b9, #bdbdbd);
    --stem-ink: light-dark(#ffffff, #1a1a1a);
    --shadow: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.45));
    --dim: light-dark(rgb(250 250 250 / 0.55), rgb(8 8 8 / 0.5));
    --map: light-dark(#b8b8b8, #979797);
    --tip-ink: light-dark(#6b6b6b, #777777);
  }
}
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
:where(.root) button,
:where(.root) input,
:where(.root) select {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}
:where(.root) button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}
.root[data-ready='false'] * {
  transition: none !important;
}

.icon {
  display: block;
  flex: none;
  width: 16px;
  height: 16px;
}

/* ——— The sidebar ——— */

.sidebar {
  position: absolute;
  z-index: 6;
  top: 0;
  bottom: 0;
  left: 0;
  width: var(--side);
  background: var(--app);
  clip-path: inset(0 0 0 0);
  transition:
    clip-path 0.36s var(--ease),
    visibility 0s;
}
/* Its right edge, which slides with the clip. */
.sidebar::after {
  content: '';
  position: absolute;
  top: 0;
  right: 0;
  bottom: 0;
  width: 1px;
  background: var(--line);
  transition: transform 0.36s var(--ease);
}
.root[data-sidebar='folded'] .sidebar {
  clip-path: inset(0 calc(100% - var(--rail)) 0 0);
}
.root[data-sidebar='folded'] .sidebar::after {
  transform: translateX(calc(var(--rail) - var(--side)));
}
.brand {
  display: flex;
  align-items: center;
  gap: 12px;
  height: var(--head);
  margin: 0 17px;
  padding: 0;
  border-bottom: 1px solid var(--line);
}
.logo {
  display: grid;
  flex: none;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 8px;
  background: var(--raised);
  box-shadow: inset 0 0 0 1px var(--line);
}
.logoMark {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: var(--ink-2);
  stroke-width: 1.6;
  stroke-linejoin: round;
}
.brandName,
.navLabel,
.navHeading {
  transition: opacity 0.25s ease;
  font-size: 10.5px;
}
.brandName {
  font-size: 15px;
  font-weight: 500;
  white-space: nowrap;
}
.root[data-sidebar='folded'] .brandName,
.root[data-sidebar='folded'] .navLabel,
.root[data-sidebar='folded'] .navHeading {
  opacity: 0;
}
.nav {
  padding: 0 17px;
}
.navHeading {
  height: 13px;
  margin: 18px 12px 11px;
  overflow: hidden;
  color: var(--ink-3);
  font-size: 10px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  white-space: nowrap;
  transition:
    opacity 0.25s ease,
    height 0.36s var(--ease),
    margin 0.36s var(--ease);
}
.navList {
  display: grid;
  gap: 5px;
  margin: 0;
  padding: 0 0 16px;
  border-bottom: 1px solid var(--line);
  list-style: none;
  transition: margin 0.36s var(--ease);
}
/* Folded, the headings close up and the items become squares on the rail. */
.root[data-sidebar='folded'] .navHeading {
  height: 0;
  margin: 17px 12px 0;
}
.root[data-sidebar='folded'] .navList {
  margin-right: calc(var(--side) - var(--rail));
}
.root[data-sidebar='folded'] .navItem {
  padding: 0 8.5px;
}
.navList:last-child {
  border-bottom: 0;
}
.navItem {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  height: 33px;
  overflow: hidden;
  padding: 0 14px;
  border-radius: 7px;
  color: var(--ink-2);
  white-space: nowrap;
  transition:
    background-color 0.15s ease,
    color 0.15s ease,
    padding 0.36s var(--ease);
}
.navItem:hover {
  background: var(--raised);
  color: var(--ink);
}
.navItem[aria-current='page'] {
  background: var(--raised);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
}
.navLabel {
  font-size: 15px;
}
.scrim {
  display: none;
}

/* ——— The header ——— */

/* The header lies over the open sidebar's top row (the fold button sits there), so it has no
   background of its own; its bottom line starts at the sidebar's edge and slides with it. */
.header {
  position: absolute;
  z-index: 7;
  top: 0;
  right: 0;
  left: var(--rail);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: var(--head);
  padding: 0 17px 0 0;
  pointer-events: none;
}
.header > * {
  pointer-events: auto;
}
.header::after {
  content: '';
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  height: 1px;
  background: var(--line);
  transform: translateX(calc(var(--side) - var(--rail)));
  transition: transform 0.36s var(--ease);
}
.root[data-sidebar='folded'] .header::after {
  transform: none;
}
.crumbs {
  position: relative;
  display: flex;
  flex: 1 1 auto;
  align-items: center;
  gap: 6px;
  min-width: 0;
  height: 100%;
  padding-left: 13px;
  transform: translateX(calc(var(--side) - var(--rail)));
  transition: transform 0.36s var(--ease);
}
.root[data-sidebar='folded'] .crumbs {
  transform: none;
}
/* Two small square buttons by the sidebar's edge: the fold, just inside the open sidebar, and
   the one on the edge itself, which opens it again once folded. */
.edgeButton {
  position: absolute;
  top: 50%;
  display: grid;
  place-items: center;
  width: 22px;
  height: 22px;
  margin-top: -11px;
  border-radius: 6px;
  color: var(--ink-2);
  transition:
    background-color 0.15s ease,
    color 0.15s ease,
    opacity 0.2s ease,
    visibility 0s;
}
.edgeButton:hover {
  background: var(--raised);
  color: var(--ink);
}
.edgeButton .icon {
  width: 16px;
  height: 16px;
}
.edgeButton[data-part='sidebar-toggle'] {
  left: -36px;
}
.root[data-sidebar='folded'] .edgeButton[data-part='sidebar-toggle'] {
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.15s ease,
    visibility 0s 0.15s;
}
.edgeButton[data-part='sidebar-edge'] {
  left: -11px;
  background: var(--app);
}
.edgeButton[data-part='sidebar-edge']:hover {
  background: var(--raised);
}
.crumbNav {
  display: flex;
  align-items: center;
  gap: 4px;
  min-width: 0;
}
.crumb {
  position: relative;
  min-width: 0;
}
.crumbButton {
  display: flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  height: 30px;
  padding: 0 4px;
  border-radius: 6px;
  white-space: nowrap;
}
.crumbButton:hover {
  background: var(--raised);
}
.crumbLabel {
  overflow: hidden;
  font-size: 15px;
  font-weight: 500;
  text-overflow: ellipsis;
}
.crumbChevron {
  flex: none;
  width: 13px;
  height: 13px;
  color: var(--ink-3);
}
.badge {
  flex: none;
  padding: 3px 9px;
  border-radius: 999px;
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.badge[data-tone='accent'] {
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 1px var(--accent-line);
  color: var(--accent-ink);
}
.badge[data-tone='plain'] {
  box-shadow: inset 0 0 0 1px var(--line-strong);
  color: var(--ink-2);
}
.slash {
  flex: none;
  padding: 0 4px;
  color: var(--ink-3);
}
.menu {
  position: absolute;
  z-index: 10;
  top: calc(100% + 6px);
  left: 0;
  display: grid;
  min-width: 190px;
  padding: 4px;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--line),
    0 12px 28px var(--shadow);
  opacity: 0;
  transform: translateY(-4px) scale(0.98);
  transform-origin: top left;
  visibility: hidden;
  transition:
    opacity 0.16s ease,
    transform 0.16s ease,
    visibility 0s 0.16s;
}
.menu[data-open='true'] {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition:
    opacity 0.16s ease,
    transform 0.16s ease;
}
.menuItem {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  height: 32px;
  padding: 0 10px;
  border-radius: 6px;
  white-space: nowrap;
}
.menuItem:hover,
.menuItem:focus-visible {
  background: var(--raised);
}
.headerEnd {
  display: flex;
  flex: none;
  align-items: center;
  gap: 8px;
}
.search {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 128px;
  height: 34px;
  padding: 0 9px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-3);
}
.search:focus-within {
  box-shadow: inset 0 0 0 1px var(--focus);
}
.search .icon {
  width: 15px;
  height: 15px;
}
.search input {
  flex: 1;
  min-width: 0;
  padding: 0;
  border: 0;
  outline: none;
  background: none;
  font-size: 12.5px;
}
.search input::placeholder {
  color: var(--ink-3);
}
.search input::-webkit-search-cancel-button {
  display: none;
}
.kbd {
  font-family: inherit;
  font-size: 11.5px;
  color: var(--ink-2);
}
.search:focus-within .kbd {
  display: none;
}
.squareButton {
  position: relative;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 8px;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
  transition: background-color 0.15s ease;
}
.squareButton:hover,
.squareButton[data-tone='raised'] {
  background: var(--raised);
}
.iconButton {
  display: grid;
  flex: none;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 7px;
  color: var(--ink-2);
  transition:
    background-color 0.15s ease,
    transform 0.12s ease;
}
.iconButton:hover {
  background: var(--raised);
  color: var(--ink);
}

/* ——— The canvas ——— */

.main {
  position: absolute;
  top: var(--head);
  right: 0;
  bottom: 0;
  left: var(--rail);
  overflow: hidden;
}
.canvas {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background-color: var(--canvas);
  background-image: radial-gradient(circle, var(--dot) 1px, transparent 1.3px);
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
}
.canvas[data-smooth='true'] {
  transition: background-position 0.6s var(--ease-io);
}
.canvas[data-mode='select'] {
  cursor: crosshair;
}
.canvas:active {
  cursor: grabbing;
}
.canvas[data-mode='select']:active {
  cursor: crosshair;
}
.world {
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  transform-origin: 0 0;
  will-change: transform;
}
.world[data-smooth='true'] {
  transition: transform 0.6s var(--ease-io);
}
.wires {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  overflow: visible;
  pointer-events: none;
}
.wire,
.wireLive,
.wireDraft {
  fill: none;
  stroke-width: 1.3;
  stroke-linecap: round;
}
.wire {
  stroke: var(--wire);
}
.wire[data-added='true'] {
  stroke-dasharray: 1;
  animation: draw 0.55s var(--ease) both;
}
@keyframes draw {
  from {
    stroke-dashoffset: 1;
  }
  to {
    stroke-dashoffset: 0;
  }
}
.wireLive {
  stroke: var(--accent);
  stroke-width: 2;
  stroke-dasharray: 6 6;
  animation: flow 0.5s linear infinite;
}
@keyframes flow {
  to {
    stroke-dashoffset: -12;
  }
}
.wireDraft {
  stroke: var(--accent);
  stroke-dasharray: 5 4;
}
g[data-leaving='true'] .wire,
.wires g[data-leaving='true'] {
  opacity: 0;
  transition: opacity 0.18s ease;
}

/* A node: its box is placed by a transform; the button inside is what you press and drag. */
.nodeBox {
  position: absolute;
  top: 0;
  left: 0;
  height: 66px;
}
.nodeBox[data-added='true'] {
  animation: pop 0.36s var(--ease) both;
}
@keyframes pop {
  from {
    opacity: 0;
    scale: 0.9;
  }
}
.nodeBox[data-leaving='true'] {
  opacity: 0;
  scale: 0.94;
  transition:
    opacity 0.18s ease,
    scale 0.18s ease;
}
.node {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  height: 100%;
  padding: 0 14px 0 12px;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    inset 0 0 0 1px var(--line),
    0 1px 2px var(--shadow);
  text-align: left;
  cursor: grab;
  touch-action: none;
  transition:
    box-shadow 0.2s ease,
    background-color 0.2s ease,
    opacity 0.2s ease;
}
.node:active {
  cursor: grabbing;
}
.node:hover {
  box-shadow:
    inset 0 0 0 1px var(--line-strong),
    0 1px 2px var(--shadow);
}
.nodeBox[data-selected='true'] .node {
  box-shadow:
    inset 0 0 0 1px var(--accent),
    0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.nodeBox[data-tone='error'] .node {
  background: var(--danger-soft);
  box-shadow: inset 0 0 0 1px var(--danger-line);
}
.nodeBox[data-tone='trigger'] .node {
  background: var(--trigger-soft);
  box-shadow: inset 0 0 0 1px var(--trigger-line);
}
.nodeBox[data-tone][data-selected='true'] .node {
  box-shadow:
    inset 0 0 0 1px var(--accent),
    0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.nodeBox[data-run='running'] .node {
  box-shadow:
    inset 0 0 0 1px var(--accent),
    0 0 0 4px color-mix(in srgb, var(--accent) 28%, transparent);
}
.nodeBox[data-off='true'] .node {
  opacity: 0.5;
}
.nodeIcon {
  position: relative;
  display: grid;
  flex: none;
  place-items: center;
  width: 42px;
  height: 42px;
  border-radius: 8px;
  background: var(--raised);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
}
.nodeBox[data-tone='error'] .nodeIcon {
  background: var(--danger-icon);
  box-shadow: none;
  color: var(--danger-ink);
}
.nodeBox[data-tone='trigger'] .nodeIcon {
  background: var(--trigger-icon);
  box-shadow: none;
  color: var(--accent-ink);
}
.nodeIcon .icon {
  width: 16px;
  height: 16px;
}
.nodeText {
  display: grid;
  gap: 2px;
  min-width: 0;
}
.nodeTitle {
  overflow: hidden;
  font-size: 15px;
  font-weight: 450;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nodeSub {
  overflow: hidden;
  color: var(--ink-2);
  font-size: 13px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nodeBox[data-tone='error'] .nodeSub {
  color: color-mix(in srgb, var(--danger-ink) 70%, var(--ink-2));
}

/* Ports: small rings on the edges. An output is a drag handle, with a wider invisible grip. */
.port {
  position: absolute;
  top: 50%;
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1.4px var(--port);
  transition: opacity 0.18s ease;
}
/* A last step shows no output until it is hovered or selected (its plus comes with it). */
.nodeBox[data-tail='true']:not(:hover, [data-selected='true'], :focus-within)
  .port[data-side='out'] {
  opacity: 0;
}
.port[data-side='in'] {
  left: 0;
}
.port[data-side='out'] {
  left: 100%;
  cursor: crosshair;
  touch-action: none;
}
.port[data-side='out']::before,
.port[data-side='stem']::before {
  content: '';
  position: absolute;
  inset: -7px;
  border-radius: 50%;
}
.port[data-side='out']:hover {
  background: var(--accent);
  box-shadow: inset 0 0 0 1.3px var(--accent);
}
.stem {
  position: absolute;
  top: 100%;
  width: 0;
  height: 70px;
}
.stem::before {
  content: '';
  position: absolute;
  top: 0;
  left: -0.65px;
  width: 1.3px;
  height: 36px;
  background: var(--wire);
}
.port[data-side='stem'] {
  top: 0;
  left: 0;
  margin: -6px 0 0 -6px;
  cursor: crosshair;
  touch-action: none;
}
.stemPlus {
  position: absolute;
  top: 27px;
  left: -9px;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--stem);
  color: var(--stem-ink);
  transition: transform 0.15s ease;
}
.stemPlus .icon {
  width: 12px;
  height: 12px;
  stroke-width: 2.4;
}
.stemPlus:hover {
  transform: scale(1.15);
}
.stemLabel {
  position: absolute;
  top: 53px;
  left: 0;
  color: var(--ink-2);
  font-size: 13px;
  font-weight: 500;
  white-space: nowrap;
  transform: translateX(-50%);
}
.addWrap {
  position: absolute;
  top: 50%;
  left: calc(100% + 36px);
  transition: opacity 0.18s ease;
}
/* The short line from the output to the plus. */
.addWrap::before {
  content: '';
  position: absolute;
  top: -0.65px;
  left: -31px;
  width: 23px;
  height: 1.3px;
  background: var(--wire);
}
/* A trigger with nothing after it always shows its plus; other nodes on hover, focus or selection. */
.nodeBox:not([data-tone='trigger']) .addWrap {
  opacity: 0;
}
.nodeBox:not([data-tone='trigger']):hover .addWrap,
.nodeBox:not([data-tone='trigger'])[data-selected='true'] .addWrap,
.nodeBox:not([data-tone='trigger']) .addWrap:focus-within {
  opacity: 1;
}
.addHandle {
  position: absolute;
  top: -8px;
  left: -8px;
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  border-radius: 50%;
  background: var(--plus);
  color: #fff;
  transition: transform 0.18s var(--ease);
}
.addHandle::before {
  content: '';
  position: absolute;
  inset: -8px;
  border-radius: 50%;
}
.addHandle .icon {
  width: 12px;
  height: 12px;
  stroke-width: 2.4;
}
.addHandle:hover,
.addHandle[data-hover='true'] {
  transform: scale(1.18);
}
.addTip {
  position: absolute;
  bottom: 18px;
  left: 0;
  padding: 8px 14px;
  border-radius: 9px;
  background: var(--raised);
  box-shadow:
    0 0 0 1px var(--line),
    0 6px 16px var(--shadow);
  color: var(--ink-2);
  font-size: 13px;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, 4px);
  transition:
    opacity 0.16s ease,
    transform 0.16s ease;
}
.addTip[data-open='true'] {
  opacity: 1;
  transform: translate(-50%, 0);
}
.marquee {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 4px;
  background: color-mix(in srgb, var(--accent) 10%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 60%, transparent);
  pointer-events: none;
}

/* ——— The palette ——— */

.palette {
  position: absolute;
  z-index: 2;
  top: 10px;
  bottom: 10px;
  left: 9px;
  width: 276px;
  border-radius: 14px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--line),
    0 10px 30px var(--shadow);
  cursor: default;
  transform: translateX(calc(var(--side) - var(--rail)));
  transition:
    transform 0.36s var(--ease),
    opacity 0.3s ease,
    visibility 0s;
}
.root[data-sidebar='folded'] .palette {
  transform: none;
}
.palette[data-open='false'] {
  opacity: 0;
  visibility: hidden;
  transform: translateX(calc(var(--side) - var(--rail) - 16px));
  transition:
    transform 0.36s var(--ease),
    opacity 0.22s ease,
    visibility 0s 0.36s;
}
.root[data-sidebar='folded'] .palette[data-open='false'] {
  transform: translateX(-16px);
}
.paletteClose {
  position: absolute;
  z-index: 1;
  top: 15px;
  right: 16px;
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 7px;
  color: var(--ink-2);
}
.paletteClose:hover {
  background: var(--raised);
  color: var(--ink);
}
.paletteList {
  position: absolute;
  top: 34px;
  right: 0;
  bottom: 0;
  left: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0 16px 28px;
  scrollbar-width: none;
  mask-image: linear-gradient(to bottom, #000 calc(100% - 64px), transparent);
}
.paletteList::-webkit-scrollbar {
  display: none;
}
.paletteGroup {
  padding: 0 0 17px;
  border-bottom: 1px solid var(--line);
}
.paletteGroup:last-of-type {
  border-bottom: 0;
}
.paletteHeading {
  margin: 18px 13px 10px;
  color: var(--ink-3);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.paletteGroup:first-child .paletteHeading {
  margin-top: 9px;
}
.paletteItems {
  display: grid;
  gap: 5px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.paletteItem {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  height: 33px;
  padding: 0 14px;
  border-radius: 7px;
  color: var(--ink-2);
  font-size: 15px;
  white-space: nowrap;
  transition:
    background-color 0.12s ease,
    color 0.12s ease;
}
.paletteItem .icon {
  width: 16px;
  height: 16px;
}
.paletteItem:hover,
.paletteItem[data-hover='true'] {
  background: var(--raised);
  color: var(--ink);
}
.paletteEmpty {
  margin: 8px;
  color: var(--ink-3);
}
.tooltip {
  position: absolute;
  z-index: 3;
  left: calc(100% + 9px);
  width: 243px;
  margin: 0;
  padding: 16px 17px;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--line),
    0 10px 24px var(--shadow);
  color: var(--tip-ink);
  font-size: 13px;
  line-height: 17px;
  opacity: 0;
  pointer-events: none;
  transform: translate(-4px, -50%);
  transition:
    opacity 0.16s ease,
    transform 0.16s ease;
}
.tooltip[data-open='true'] {
  opacity: 1;
  transform: translate(0, -50%);
}

/* ——— The toolbar and the minimap ——— */

.toolbar {
  position: absolute;
  z-index: 2;
  bottom: 16px;
  left: 50%;
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 8px 5px;
  border-radius: 12px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--line),
    0 6px 18px var(--shadow);
  cursor: default;
  transform: translate(calc(-50% + var(--toolbar-x, 0px)), var(--toolbar-y, 0px));
  transition: transform 0.6s var(--ease-io);
}
/* It keeps to the free part of the canvas, as the fitted workflow does: the component sets
   --toolbar-x, clear of the palette, the open sidebar and the minimap. */
.root[data-runs='true'] .toolbar {
  --toolbar-y: calc(-1 * var(--runs-h));
}
.grip {
  width: 14px;
  height: 14px;
  color: var(--ink-3);
  margin: 0 4px;
}
.toolButton {
  display: grid;
  place-items: center;
  width: 33px;
  height: 33px;
  border-radius: 7px;
  color: var(--ink-2);
  transition:
    background-color 0.15s ease,
    color 0.15s ease,
    transform 0.12s ease;
}
.toolButton:hover,
.toolButton[aria-pressed='true'] {
  background: var(--raised);
  color: var(--ink);
}
.toolButton[aria-pressed='true'] {
  box-shadow: inset 0 0 0 1px var(--line);
}
.toolButton[data-tone='danger'] {
  background: var(--danger);
  color: #fff;
}
.toolButton[data-tone='accent'] {
  background: var(--accent);
  color: #fff;
}
.toolButton[data-tone='go'] {
  background: var(--go);
  color: var(--go-ink);
}
.toolButton[data-tone='go'] .icon {
  stroke-width: 1.8;
}
.root [data-pressed='true'] {
  transform: scale(0.92);
}
.root .addHandle[data-pressed='true'] {
  transform: scale(0.9);
}
.minimap {
  position: absolute;
  z-index: 1;
  right: 16px;
  bottom: 16px;
  width: 162px;
  height: 116px;
  padding: 18px;
  border-radius: 10px;
  background: var(--surface);
  box-shadow: 0 0 0 1px var(--line);
  cursor: pointer;
  transition: transform 0.6s var(--ease-io);
}
.root[data-runs='true'] .minimap {
  transform: translateY(calc(-1 * var(--runs-h)));
}
/* Where the toolbar needs its room, the minimap gives way. */
.root[data-map='false'] .minimap {
  opacity: 0;
  visibility: hidden;
  transition:
    transform 0.6s var(--ease-io),
    opacity 0.2s ease,
    visibility 0s 0.2s;
}
.minimap svg {
  display: block;
  width: 100%;
  height: 100%;
  overflow: visible;
}
.mapNode {
  fill: var(--map);
}
.dim {
  position: absolute;
  z-index: 4;
  inset: 0;
  background: var(--dim);
  opacity: 0;
  visibility: hidden;
  transition:
    opacity 0.35s ease,
    visibility 0s 0.35s;
}
.root[data-settings='true'] .dim {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.35s ease;
}

/* ——— The run's report ——— */

.runs {
  position: absolute;
  z-index: 3;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  flex-direction: column;
  height: var(--runs-h);
  border-top: 1px solid var(--line);
  background: var(--app);
  transform: translateY(100%);
  visibility: hidden;
  transition:
    transform 0.6s var(--ease-io),
    visibility 0s 0.6s;
}
.runs[data-open='true'] {
  transform: none;
  visibility: visible;
  transition: transform 0.6s var(--ease-io);
}
.runsHead {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 0 16px;
  padding: 16px 0;
  border-bottom: 1px solid var(--line);
}
.tabs {
  display: flex;
  gap: 4px;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
  display: none;
}
.tab {
  display: flex;
  flex: none;
  align-items: center;
  gap: 5px;
  height: 34px;
  padding: 0 12px;
  border-radius: 8px;
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
  font-size: 13.5px;
}
.tab .icon {
  width: 15px;
  height: 15px;
}
.tab:hover {
  color: var(--ink);
}
.tab[aria-selected='true'] {
  background: var(--raised);
  color: var(--ink);
}
.runsBody {
  position: relative;
  flex: 1;
  overflow-y: auto;
  scrollbar-width: thin;
  padding: 6px 16px 56px;
  outline-offset: -2px;
}
.runList {
  display: grid;
  gap: 2px;
}
.runGroup,
.runRowButton {
  display: flex;
  align-items: center;
  gap: 6px;
  min-height: 42px;
  white-space: nowrap;
}
.runGroup {
  color: var(--ink);
  font-size: 15px;
}
.runRows {
  display: grid;
  margin: 0;
  padding: 0;
  list-style: none;
}
.runRow {
  position: relative;
  overflow: hidden;
  margin: 0 -16px;
  padding: 0 16px 0 36px;
  animation: rowIn 0.3s var(--ease) both;
}
@keyframes rowIn {
  from {
    opacity: 0;
    transform: translateX(-10px);
  }
}
/* The step running now: dimmed, under a sweep of light. */
.runRow[data-state='running'] {
  background: color-mix(in srgb, var(--accent) 8%, transparent);
}
.runRow[data-state='running']::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    90deg,
    transparent,
    color-mix(in srgb, var(--accent) 14%, transparent),
    transparent
  );
  animation: sweep 0.9s linear infinite;
  pointer-events: none;
}
@keyframes sweep {
  from {
    transform: translateX(-100%);
  }
  to {
    transform: translateX(100%);
  }
}
.runRow[data-state='running'] .runRowButton {
  opacity: 0.55;
}
.runRowButton {
  width: 100%;
  min-height: 42px;
  text-align: left;
}
.runCheck {
  display: grid;
  flex: none;
  place-items: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--accent);
  color: #fff;
}
.runGroup .runCheck {
  background: none;
  box-shadow: inset 0 0 0 1.4px var(--accent);
  color: var(--accent-ink);
}
.runCheck[data-state='running'] {
  background: var(--raised);
  color: transparent;
}
.runCheck .icon {
  width: 13px;
  height: 13px;
  stroke-width: 2.4;
}
.runChevron {
  flex: none;
  width: 15px;
  height: 15px;
  color: var(--ink-2);
}
.runRowButton[aria-expanded='true'] .runChevron {
  transform: rotate(90deg);
}
.runName {
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
  font-size: 15px;
}
.runRole {
  color: var(--ink-3);
  font-size: 13px;
}
.runBadge {
  padding: 2px 6px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--line-strong);
  color: var(--ink-2);
  font-size: 10.5px;
}
.runTime {
  margin-left: auto;
  color: var(--ink-3);
  font-size: 13px;
}
.runValue {
  flex: none;
  min-width: 36px;
  text-align: right;
  font-size: 15px;
  font-variant-numeric: tabular-nums;
}
.runDetail {
  margin: 0 0 8px 56px;
  color: var(--ink-2);
  font-size: 12px;
}
.startButton {
  position: absolute;
  bottom: 17px;
  left: 50%;
  transform: translateX(-50%);
  padding: 7px 13px;
  border-radius: 7px;
  background: var(--raised);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: 13.5px;
}
.startButton:hover {
  box-shadow: inset 0 0 0 1px var(--line-strong);
}
.plainList {
  display: grid;
  gap: 10px;
  margin: 4px 0;
  padding: 0;
  list-style: none;
}
.plainList li {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13.5px;
}
.pill {
  padding: 2px 8px;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--line-strong);
  color: var(--ink-2);
  font-size: 11px;
}
.pill[data-tone='ok'] {
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 1px var(--accent-line);
  color: var(--accent-ink);
}
.pill[data-tone='error'] {
  background: var(--danger-soft);
  box-shadow: inset 0 0 0 1px var(--danger-line);
  color: var(--danger-ink);
}
.runNote {
  max-width: 60ch;
  margin: 4px 0;
  color: var(--ink-2);
  font-size: 13px;
  line-height: 1.5;
}
.timeline {
  display: grid;
  gap: 10px;
  margin: 4px 0;
  padding: 0;
  list-style: none;
}
.timeline li {
  display: grid;
  grid-template-columns: minmax(80px, 140px) 1fr auto;
  align-items: center;
  gap: 12px;
}
.timelineName {
  overflow: hidden;
  font-size: 13px;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timelineTrack {
  position: relative;
  height: 8px;
  border-radius: 4px;
  background: var(--raised);
}
.timelineBar {
  position: absolute;
  top: 0;
  bottom: 0;
  border-radius: 4px;
  background: var(--accent);
}

/* ——— A node's settings ——— */

.settings {
  position: absolute;
  z-index: 8;
  top: 17px;
  right: 17px;
  bottom: 17px;
  display: flex;
  flex-direction: column;
  width: min(440px, calc(100% - 24px));
  border-radius: 12px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--line),
    0 18px 40px var(--shadow);
  cursor: default;
  opacity: 0;
  transform: translateX(calc(100% + 24px));
  visibility: hidden;
  transition:
    transform 0.5s var(--ease),
    opacity 0.3s ease,
    visibility 0s 0.5s;
}
.settings[data-open='true'] {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition:
    transform 0.55s var(--ease),
    opacity 0.2s ease;
}
.settingsHead {
  display: flex;
  flex: none;
  align-items: center;
  gap: 10px;
  margin: 0 16px;
  padding: 17px 0;
  border-bottom: 1px solid var(--line);
}
.settingsIcon {
  display: grid;
  place-items: center;
  width: 33px;
  height: 33px;
  border-radius: 8px;
  background: var(--raised);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
}
.settingsTitle {
  flex: 1;
  margin: 0;
  overflow: hidden;
  font-size: 15px;
  font-weight: 500;
  text-overflow: ellipsis;
  white-space: nowrap;
  outline: none;
}
.settingsBody {
  flex: 1;
  overflow-y: auto;
  scrollbar-width: none;
  overscroll-behavior: contain;
  padding: 0 16px 16px;
}
/* The sections come in one after the other as the panel slides. */
.field {
  display: grid;
  gap: 10px;
  min-width: 0;
  margin: 0;
  padding: 26px 0;
  border: 0;
  border-bottom: 1px solid var(--line);
}
.settings[data-open='true'] .field {
  animation: fieldIn 0.4s var(--ease) both;
  animation-delay: calc(0.08s + var(--i, 0) * 0.05s);
}
@keyframes fieldIn {
  from {
    opacity: 0;
    transform: translateX(14px);
  }
}
.fieldLabel {
  margin: 0;
  padding: 0;
  color: var(--ink-2);
  font-size: 13.5px;
  font-weight: 450;
}
legend.fieldLabel {
  float: left;
  width: 100%;
  margin-bottom: 6px;
}
.fieldHint {
  color: var(--ink-3);
}
.fieldNote {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 0;
  color: var(--danger-ink);
  font-size: 12px;
}
.fieldNote .icon {
  width: 14px;
  height: 14px;
}
.selectWrap {
  position: relative;
  display: block;
}
.select,
.input {
  width: 100%;
  height: 37px;
  padding: 0 12px;
  border: 0;
  border-radius: 8px;
  background: var(--field);
  box-shadow: inset 0 0 0 1px var(--line);
  font-size: 15px;
  appearance: none;
}
.select:focus-visible,
.input:focus-visible {
  outline: none;
  box-shadow: inset 0 0 0 1px var(--focus);
}
.input::placeholder {
  color: var(--ink-3);
}
.selectChevron {
  position: absolute;
  top: 50%;
  right: 12px;
  width: 16px;
  height: 16px;
  color: var(--ink-2);
  pointer-events: none;
  transform: translateY(-50%);
}
.radio {
  display: flex;
  align-items: center;
  gap: 9px;
  min-height: 24px;
  font-size: 15px;
  cursor: pointer;
}
.radio input {
  display: grid;
  place-items: center;
  width: 20px;
  height: 20px;
  margin: 0;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1.3px var(--line-strong);
  appearance: none;
  cursor: pointer;
}
.radio input:checked {
  box-shadow: inset 0 0 0 1.3px var(--accent);
}
.radio input:checked::after {
  content: '';
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--accent);
}
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 8px 5px;
  margin-top: 7px;
  transition: opacity 0.2s ease;
}
.chips[data-off='true'] {
  opacity: 0.4;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: 34px;
  padding: 0 12px;
  border-radius: 999px;
  background: var(--field);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-3);
  font-size: 15px;
  transition:
    background-color 0.15s ease,
    color 0.15s ease,
    box-shadow 0.15s ease,
    transform 0.12s ease;
}
.chip:hover:not(:disabled) {
  color: var(--ink);
}
.chip:disabled {
  cursor: default;
}
.chip[aria-pressed='true'] {
  background: var(--accent-soft);
  box-shadow: inset 0 0 0 1px var(--accent-line);
  color: var(--accent-ink);
}
.chipX {
  width: 11px;
  height: 11px;
  stroke-width: 2.2;
}
.switchRow {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 24px;
  margin: 6px 0;
  font-size: 15px;
  cursor: pointer;
}
.switch {
  position: relative;
  flex: none;
  width: 38px;
  height: 21px;
  margin: 0;
  border-radius: 999px;
  background: var(--line-strong);
  appearance: none;
  cursor: pointer;
  transition: background-color 0.2s ease;
}
.switch::after {
  content: '';
  position: absolute;
  top: 3px;
  left: 3px;
  width: 15px;
  height: 15px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 1px 2px rgb(0 0 0 / 0.25);
  transition: transform 0.2s var(--ease);
}
.switch:checked {
  background: var(--accent);
}
.switch:checked::after {
  transform: translateX(17px);
}
.code {
  margin: 0;
  padding: 12px 14px;
  overflow-x: auto;
  border-radius: 8px;
  background: var(--field);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
  font-family: inherit;
  font-size: 14px;
  line-height: 1.4;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}
.danger {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 16px;
  padding: 8px 12px;
  border-radius: 7px;
  box-shadow: inset 0 0 0 1px var(--danger-line);
  color: var(--danger-ink);
}
.danger:hover {
  background: var(--danger-soft);
}

/* ——— The demo pointer, and what only screen readers read ——— */

.pointerLayer {
  position: absolute;
  z-index: 20;
  inset: 0;
  pointer-events: none;
}
.pointer {
  width: 22px;
  height: 22px;
}
.hand {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .arrow {
  visibility: hidden;
}
.pointer[data-glyph='hand'] .hand {
  visibility: visible;
}
.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ——— Smaller stages ——— */

@container (max-width: 1080px) {
  .crumb[data-part='crumb-project'],
  .crumb[data-part='crumb-project'] + .slash {
    display: none;
  }
}
@container (max-width: 900px) {
  .search {
    width: 36px;
    padding: 0 9px;
  }
  .search input,
  .kbd {
    display: none;
  }
  .search:focus-within {
    width: 160px;
  }
  .search:focus-within input {
    display: block;
  }
  .headerEnd .squareButton:first-of-type {
    display: none;
  }
}
/* Below 760 px the open sidebar lies over the canvas, under a scrim; the header stays put. */
@container (max-width: 760px) {
  .crumbs,
  .palette,
  .header::after {
    transform: none;
  }
  .header {
    z-index: 3;
    background: var(--app);
  }
  .palette[data-open='false'] {
    transform: translateX(-16px);
  }
  /* The header lies under the sidebar here, so the edge button sits inside it, past the rail. */
  .crumbs {
    padding-left: 38px;
  }
  .edgeButton[data-part='sidebar-edge'] {
    left: 8px;
  }
  .scrim {
    position: absolute;
    z-index: 5;
    inset: 0;
    display: block;
    background: var(--dim);
    opacity: 0;
    visibility: hidden;
    transition:
      opacity 0.3s ease,
      visibility 0s 0.3s;
  }
  .root[data-sidebar='open'] .scrim {
    opacity: 1;
    visibility: visible;
    transition: opacity 0.3s ease;
  }
  .root[data-sidebar='open'] .sidebar {
    box-shadow: 0 0 40px var(--shadow);
  }
  .minimap {
    width: 120px;
    height: 86px;
    padding: 12px;
  }
  .tooltip {
    display: none;
  }
}
@container (max-width: 640px) {
  .crumb[data-part='crumb-branch'],
  .crumb[data-part='crumb-branch'] ~ .slash,
  .crumbNav .slash {
    display: none;
  }
  .headerEnd .squareButton:nth-of-type(2) {
    display: none;
  }
  .minimap {
    display: none;
  }
  .toolbar {
    bottom: 10px;
    gap: 2px;
  }
  .grip {
    display: none;
  }
  .toolButton {
    width: 30px;
    height: 30px;
  }
  .runTime,
  .runBadge {
    display: none;
  }
  .runValue {
    margin-left: auto;
  }
}
/* Below 520 px there is no rail: the sidebar is a drawer, opened from the header. */
@container (max-width: 520px) {
  /* (A container query cannot style the container itself, so its children take the value.) */
  .root > * {
    --rail: 0px;
  }
  .root[data-sidebar='folded'] .sidebar {
    visibility: hidden;
    transition:
      clip-path 0.42s var(--ease),
      visibility 0s 0.42s;
  }
  .brand {
    margin-left: 12px;
  }
  .header {
    padding-left: 8px;
  }
  .palette {
    width: min(268px, calc(100% - 20px));
  }
  .badge {
    display: none;
  }
  .runRole {
    display: none;
  }
}

@media (max-width: 600px) {
  .root {
    height: 620px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0s !important;
  }
}
```