Skip to examples
Bento / Kitchen sink
Bento / compositions

Workspaces

Surfaces people build on rather than read: a canvas where steps are joined into a workflow, a board where work moves through stages, and a dashboard each person arranges. The rules live in a framework-free kernel; the components only draw them.

WorkflowEditor

palette · ports · inspector · undo · tidy

Connections are checked, not just drawn. An output joins an input of the same type (“any” takes everything); a taken input, a duplicate, or a loop is refused and the reason announced. Try joining “Find customer” to “Send email”.

Every gesture has a key. Palette items add on Enter; steps take Tab, Enter and the arrows; Delete removes; ⌘Z and ⇧⌘Z undo and redo; the inspector connects any output without a pointer.

One step per gesture. A drag, a run of arrow presses, or typing in one field is a single undo step. Changes are kept in this browser; reload to see them come back.

Sourcecomponents/workspaces/workflow-editor/doc.ts · components/workspaces/workflow-editor/workflow-editor.tsx · components/workspaces/workflow-editor/workflow-node.tsx · components/workspaces/workflow-editor/inspector.tsx · components/workspaces/workflow-editor/palette.tsx · components/workspaces/workflow-editor/workflow-editor.module.css · components/workspaces/_kernel/workflow.ts · components/workspaces/_kernel/history.ts · components/workspaces/_kernel/layout.ts

components/workspaces/workflow-editor/doc.ts

/**
 * WorkflowEditor — build a workflow by placing steps and joining their ports.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the canvas
 *     kinds          REQUIRED   { kind, label, category, description?, icon?,
 *                               tone?, inputs, outputs, fields, defaults?,
 *                               max? }[]
 *                      ports    { id, label, type, required?, multiple? }
 *                      fields   text | number | select | toggle, each with
 *                               key, label, hint?, required?
 *     defaultValue?  { nodes, edges }: where editing starts (empty)
 *     onChange?      every committed change, to persist
 *     rules?         { allowCycles? } (loops refused by default)
 *     readOnly?      look, select and inspect; nothing changes
 *     empty?         what an empty canvas says
 *
 * # Behaviour
 *
 * R1  Three panes: a palette of kinds by category, the canvas, and an
 *     inspector. A toolbar holds undo, redo, tidy and fit, and says whether
 *     the workflow is complete.
 * R2  A kind is added by dragging it onto the canvas (it lands under the
 *     pointer) or by pressing it (it lands in the middle of the view). A
 *     kind at its `max` is disabled, and says why.
 * R3  A connection runs from an output to an input. One is refused — the
 *     drop does nothing and the reason is announced — when it joins a node
 *     to itself, the port types differ ("any" matches every type), the
 *     input is taken (unless `multiple`), it exists already, or it would
 *     close a loop (unless `allowCycles`). While dragging, only ports that
 *     would accept are lit.
 * R4  Everything the pointer does has a keyboard way: Tab reaches steps and
 *     connections, Enter selects, arrows move the selected step, Delete or
 *     Backspace removes the selection, and the inspector connects an output
 *     to any input that would accept it ("Connect to…") and disconnects.
 * R5  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change. A
 *     burst of one change — a drag, arrow presses on one step, typing in
 *     one field — is one step. Keys in a text field keep their own undo.
 * R6  Tidy lays steps out left to right by longest path from a start, orders
 *     each column to uncross edges, and fits the view. It is one undo step.
 * R7  The inspector shows the selected step's name and settings (with each
 *     missing required value as the field's error) and its connections;
 *     the selected connection, with a way to remove it; or, with nothing
 *     selected, a list of what keeps the workflow from being complete,
 *     each item selecting its step.
 * R8  A step with an issue is marked on the canvas, and its accessible name
 *     says it needs attention. Every change is announced politely.
 * R9  onChange receives committed changes only: not the starting value, not
 *     positions mid-drag, not selection.
 * R10 Below about 860px wide the panes stack: canvas, palette strip,
 *     inspector.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The workflow, its rules, history and tidy are the framework-free kernel
 * in ../_kernel (identical in both apps, tested there); this renderer only
 * draws it and calls it. The canvas is xyflow, fully controlled: positions
 * mid-drag, selection and measured sizes live in renderer state, and each
 * node is passed its measured size back — without it the engine loses its
 * handle bounds on every update and the edges vanish. The engine's CSS is
 * unlayered, so this module is too, and themes it through the --xy-*
 * variables; the image reset's max-width cap is lifted from edge SVGs.
 */
export {};

components/workspaces/workflow-editor/workflow-editor.tsx

"use client";

import {
  Background,
  Controls,
  ReactFlow,
  ReactFlowProvider,
  useReactFlow,
  type Edge,
  type EdgeChange,
  type NodeChange,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { Badge } from "@/components/display/badge";
import { Button } from "@/components/forms/button";
import {
  Maximize2,
  Redo2,
  Undo2,
  WandSparkles,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import { autoLayout, type Size } from "../_kernel/layout";
import {
  addNode,
  checkConnection,
  connect,
  EMPTY,
  issues as findIssues,
  kindMap,
  moveNodes,
  nodeName,
  REFUSALS,
  removeEdges,
  removeNodes,
  updateNode,
  type Connection,
  type NodeKind,
  type Point,
  type Rules,
  type Workflow,
} from "../_kernel/workflow";
import { Inspector } from "./inspector";
import { DRAG_TYPE, Palette } from "./palette";
import { WorkflowNode, type FlowNode } from "./workflow-node";
import styles from "./workflow-editor.module.css";

export type WorkflowEditorProps = {
  /** Names the canvas for assistive technology. */
  label: string;
  /** The kinds of step the palette offers. */
  kinds: readonly NodeKind[];
  /** Where editing starts. The editor keeps its own history from here. */
  defaultValue?: Workflow;
  /** Every committed change: a drop, a connection, a finished drag, a
   *  setting. Persist here. */
  onChange?: (workflow: Workflow) => void;
  rules?: Rules;
  /** Look, select and inspect; no palette, no changes. */
  readOnly?: boolean;
  /** Shown on an empty canvas. */
  empty?: ReactNode;
  className?: string;
};

const nodeTypes = { step: WorkflowNode };

/** Builds workflows by dragging steps from a palette onto a canvas and
 *  joining their ports; see doc.ts. */
export function WorkflowEditor(props: WorkflowEditorProps) {
  // The engine's viewport helpers are needed outside the canvas (the
  // palette adds in view), so the provider wraps the whole editor.
  return (
    <ReactFlowProvider>
      <Editor {...props} />
    </ReactFlowProvider>
  );
}

/** An id not yet used in the workflow: n1, n2… (e1, e2… for edges). */
function nextId(prefix: string, taken: readonly { id: string }[]) {
  const ids = new Set(taken.map((item) => item.id));
  let n = taken.length + 1;
  while (ids.has(`${prefix}${n}`)) n += 1;
  return `${prefix}${n}`;
}

const isTyping = (target: EventTarget | null) =>
  target instanceof HTMLElement &&
  (target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));

function Editor({
  label,
  kinds: kindList,
  defaultValue = EMPTY,
  onChange,
  rules = {},
  readOnly = false,
  empty = "Drag a step here from the palette, or choose one to add it.",
  className,
}: WorkflowEditorProps) {
  const kinds = useMemo(() => kindMap(kindList), [kindList]);
  const flow = useReactFlow<FlowNode, Edge>();
  const canvasRef = useRef<HTMLDivElement>(null);

  const [history, setHistory] = useState<History<Workflow>>(() =>
    start(defaultValue),
  );
  const workflow = history.present;
  const change = useCallback(
    (next: Workflow | null, key: string | null = null) => {
      if (next) setHistory((current) => commit(current, next, key));
    },
    [],
  );
  // Tell the caller about committed changes, not the starting value.
  const reported = useRef(workflow);
  useEffect(() => {
    if (reported.current === workflow) return;
    reported.current = workflow;
    onChange?.(workflow);
  }, [workflow, onChange]);

  // Selection, drag positions and measurements belong to the renderer,
  // not to the workflow (and not to its history).
  const [selectedNodes, setSelectedNodes] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [selectedEdges, setSelectedEdges] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [dragging, setDragging] = useState<ReadonlyMap<string, Point>>(
    () => new Map(),
  );
  const [measured, setMeasured] = useState<ReadonlyMap<string, Size>>(
    () => new Map(),
  );
  const [message, setMessage] = useState("");

  const issues = useMemo(() => findIssues(workflow, kinds), [workflow, kinds]);

  const flowNodes = useMemo<FlowNode[]>(
    () =>
      workflow.nodes.map((node) => ({
        id: node.id,
        type: "step",
        position: dragging.get(node.id) ?? node.position,
        // Controlled nodes must carry their measurements back, or the
        // engine loses its handle bounds and the edges disappear.
        measured: measured.get(node.id),
        selected: selectedNodes.has(node.id),
        ariaLabel: `${nodeName(node, kinds)}, ${kinds.get(node.kind)?.label ?? node.kind}${
          issues.some((issue) => issue.nodeId === node.id)
            ? ", needs attention"
            : ""
        }`,
        data: {
          spec: kinds.get(node.kind),
          kind: node.kind,
          name: nodeName(node, kinds),
          issues: issues.filter((issue) => issue.nodeId === node.id),
          connectable: !readOnly,
        },
      })),
    [
      workflow.nodes,
      dragging,
      measured,
      selectedNodes,
      kinds,
      issues,
      readOnly,
    ],
  );
  const flowEdges = useMemo<Edge[]>(
    () =>
      workflow.edges.map((edge) => ({
        id: edge.id,
        source: edge.source,
        sourceHandle: edge.sourcePort,
        target: edge.target,
        targetHandle: edge.targetPort,
        selected: selectedEdges.has(edge.id),
        ariaLabel: `Connection from ${nameOf(edge.source)} to ${nameOf(edge.target)}`,
      })),
    // eslint-disable-next-line react-hooks/exhaustive-deps -- nameOf reads workflow.nodes and kinds
    [workflow.edges, workflow.nodes, selectedEdges, kinds],
  );
  function nameOf(id: string) {
    const node = workflow.nodes.find((item) => item.id === id);
    return node ? nodeName(node, kinds) : id;
  }

  const select = useCallback((nodeId: string | null, edgeId?: string) => {
    setSelectedNodes(nodeId ? new Set([nodeId]) : new Set());
    setSelectedEdges(edgeId ? new Set([edgeId]) : new Set());
  }, []);

  function applySelect(
    current: ReadonlySet<string>,
    changes: readonly (NodeChange<FlowNode> | EdgeChange<Edge>)[],
  ) {
    let next = current;
    for (const item of changes) {
      if (item.type !== "select" || item.selected === next.has(item.id))
        continue;
      const copy = new Set(next);
      if (item.selected) copy.add(item.id);
      else copy.delete(item.id);
      next = copy;
    }
    return next;
  }

  function onNodesChange(changes: NodeChange<FlowNode>[]) {
    setSelectedNodes((current) => applySelect(current, changes));
    if (changes.some((item) => item.type === "dimensions" && item.dimensions))
      setMeasured((current) => {
        const next = new Map(current);
        for (const item of changes)
          if (item.type === "dimensions" && item.dimensions)
            next.set(item.id, item.dimensions);
        return next;
      });
    // A drag moves nodes in the renderer and commits when it ends; a
    // keyboard move commits at once (a burst of arrows folds into one step).
    const moving = new Map<string, Point>();
    const moved = new Map<string, Point>();
    for (const item of changes) {
      if (item.type !== "position" || !item.position) continue;
      (item.dragging ? moving : moved).set(item.id, item.position);
    }
    if (moving.size) setDragging((current) => new Map([...current, ...moving]));
    if (moved.size) {
      setDragging((current) =>
        current.size
          ? new Map([...current].filter(([id]) => !moved.has(id)))
          : current,
      );
      const actual = [...moved].some(([id, at]) => {
        const node = workflow.nodes.find((item) => item.id === id);
        return node && (node.position.x !== at.x || node.position.y !== at.y);
      });
      if (actual)
        change(moveNodes(workflow, moved), `move:${[...moved.keys()].join()}`);
    }
  }

  function onEdgesChange(changes: EdgeChange<Edge>[]) {
    setSelectedEdges((current) => applySelect(current, changes));
  }

  const toConnection = (value: {
    source: string | null;
    target: string | null;
    sourceHandle?: string | null;
    targetHandle?: string | null;
  }): Connection | null =>
    value.source && value.target && value.sourceHandle && value.targetHandle
      ? {
          source: value.source,
          sourcePort: value.sourceHandle,
          target: value.target,
          targetPort: value.targetHandle,
        }
      : null;

  function join(connection: Connection) {
    const result = connect(
      workflow,
      kinds,
      connection,
      nextId("e", workflow.edges),
      rules,
    );
    if (result.refusal) {
      setMessage(REFUSALS[result.refusal]);
      return;
    }
    change(result.workflow);
    setMessage(
      `Connected ${nameOf(connection.source)} to ${nameOf(connection.target)}.`,
    );
  }

  function add(kind: string, position: Point) {
    const id = nextId("n", workflow.nodes);
    const next = addNode(workflow, kinds, kind, position, id);
    if (!next) {
      setMessage(`Only ${kinds.get(kind)?.max ?? 0} of those per workflow.`);
      return;
    }
    change(next);
    select(id);
    setMessage(`Added ${kinds.get(kind)?.label ?? kind}.`);
  }

  /** Adds in the middle of the view, nudged so repeated adds don't stack. */
  function addInView(kind: string) {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;
    const centre = flow.screenToFlowPosition({
      x: rect.left + rect.width / 2,
      y: rect.top + rect.height / 2,
    });
    const nudge = (workflow.nodes.length % 5) * 24;
    add(kind, {
      x: Math.round(centre.x - 110 + nudge),
      y: Math.round(centre.y - 48 + nudge),
    });
  }

  function remove(nodeIds: Iterable<string>, edgeIds: Iterable<string>) {
    const nodes = [...nodeIds];
    const edges = [...edgeIds];
    if (!nodes.length && !edges.length) return;
    change(removeNodes(removeEdges(workflow, edges), nodes));
    select(null);
    setMessage(
      nodes.length
        ? `Removed ${nodes.length === 1 ? "a step" : `${nodes.length} steps`}.`
        : `Removed ${edges.length === 1 ? "a connection" : `${edges.length} connections`}.`,
    );
  }

  function tidy() {
    change(moveNodes(workflow, autoLayout(workflow, measured)));
    setMessage("Tidied the layout.");
    // After the moved nodes render.
    requestAnimationFrame(() => flow.fitView({ padding: 0.2, duration: 300 }));
  }

  function step(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (readOnly || isTyping(event.target)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      step(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      step("redo");
    }
  }

  const selectedNode =
    selectedNodes.size === 1
      ? (workflow.nodes.find((node) => selectedNodes.has(node.id)) ?? null)
      : null;
  const selectedEdge =
    !selectedNode && selectedEdges.size === 1
      ? (workflow.edges.find((edge) => selectedEdges.has(edge.id)) ?? null)
      : null;

  return (
    <div className={cn(styles.editor, className)} onKeyDown={onKeyDown}>
      <div className={cn(styles.frame, readOnly && styles.readOnly)}>
        <div className={styles.toolbar} role="toolbar" aria-label="Workflow">
          {readOnly ? null : (
            <>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Undo"
                title="Undo (⌘Z)"
                disabled={!canUndo(history)}
                onClick={() => step("undo")}
              >
                <Undo2 aria-hidden="true" />
              </Button>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Redo"
                title="Redo (⇧⌘Z)"
                disabled={!canRedo(history)}
                onClick={() => step("redo")}
              >
                <Redo2 aria-hidden="true" />
              </Button>
              <span className={styles.divider} aria-hidden="true" />
              <Button
                size="sm"
                variant="quiet"
                disabled={workflow.nodes.length < 2}
                onClick={tidy}
              >
                <WandSparkles aria-hidden="true" />
                Tidy
              </Button>
            </>
          )}
          <Button
            size="sm"
            variant="quiet"
            disabled={workflow.nodes.length === 0}
            onClick={() => flow.fitView({ padding: 0.2, duration: 300 })}
          >
            <Maximize2 aria-hidden="true" />
            Fit
          </Button>
          <span className={styles.toolbarEnd}>
            {workflow.nodes.length ? (
              issues.length ? (
                <Badge tone="warn" dot>
                  {issues.length} to finish
                </Badge>
              ) : (
                <Badge tone="accent" dot>
                  Ready
                </Badge>
              )
            ) : null}
          </span>
        </div>

        {readOnly ? null : (
          <Palette kinds={kindList} workflow={workflow} onAdd={addInView} />
        )}

        <div
          ref={canvasRef}
          className={styles.canvas}
          role="region"
          aria-label={label}
          // The engine keeps a press on the pane or a node from moving focus,
          // so undo's keys would go to the page; the canvas takes it instead.
          tabIndex={-1}
          onPointerDownCapture={(event) => {
            const target = event.target as HTMLElement;
            if (!target.closest("button, input, select, textarea, a"))
              canvasRef.current?.focus({ preventScroll: true });
          }}
          onDragOver={(event) => {
            if (readOnly || !event.dataTransfer.types.includes(DRAG_TYPE))
              return;
            event.preventDefault();
            event.dataTransfer.dropEffect = "copy";
          }}
          onDrop={(event) => {
            const kind = event.dataTransfer.getData(DRAG_TYPE);
            if (readOnly || !kind) return;
            event.preventDefault();
            const at = flow.screenToFlowPosition({
              x: event.clientX,
              y: event.clientY,
            });
            // The pointer holds the node by its header, not its corner.
            add(kind, { x: Math.round(at.x - 24), y: Math.round(at.y - 20) });
          }}
        >
          <ReactFlow<FlowNode, Edge>
            nodes={flowNodes}
            edges={flowEdges}
            nodeTypes={nodeTypes}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={(value) => {
              const connection = toConnection(value);
              if (connection) join(connection);
            }}
            isValidConnection={(value) => {
              const connection = toConnection(value);
              return (
                !!connection &&
                !checkConnection(workflow, kinds, connection, rules)
              );
            }}
            onConnectEnd={(_, state) => {
              // A drop on a port that refused: say why.
              if (
                state.isValid !== false ||
                !state.fromHandle ||
                !state.toHandle
              )
                return;
              const [out, into] =
                state.fromHandle.type === "source"
                  ? [state.fromHandle, state.toHandle]
                  : [state.toHandle, state.fromHandle];
              const refusal = checkConnection(
                workflow,
                kinds,
                {
                  source: out.nodeId,
                  sourcePort: out.id ?? "",
                  target: into.nodeId,
                  targetPort: into.id ?? "",
                },
                rules,
              );
              if (refusal) setMessage(REFUSALS[refusal]);
            }}
            onDelete={({ nodes, edges }) =>
              remove(
                nodes.map((node) => node.id),
                edges.map((edge) => edge.id),
              )
            }
            onPaneClick={() => select(null)}
            nodesDraggable={!readOnly}
            nodesConnectable={!readOnly}
            nodesFocusable
            edgesFocusable
            elementsSelectable
            deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
            fitView
            fitViewOptions={{ padding: 0.2 }}
            minZoom={0.25}
            maxZoom={2}
            proOptions={{ hideAttribution: true }}
            ariaLabelConfig={{
              "node.a11yDescription.default": readOnly
                ? "Press Enter to select this step. Press Escape to clear the selection."
                : "Press Enter to select this step. Use the arrow keys to move it, and Delete to remove it.",
            }}
          >
            <Background gap={20} />
            <Controls showInteractive={false} showFitView={false} />
          </ReactFlow>
          {workflow.nodes.length === 0 ? (
            <div className={styles.empty}>{empty}</div>
          ) : null}
        </div>

        <Inspector
          workflow={workflow}
          kinds={kinds}
          rules={rules}
          issues={issues}
          selectedNode={selectedNode}
          selectedEdge={selectedEdge}
          readOnly={readOnly}
          onUpdate={(id, value, key) =>
            change(updateNode(workflow, id, value), key)
          }
          onConnect={join}
          onDisconnect={(id) => remove([], [id])}
          onDelete={(id) => remove([id], [])}
          onSelect={(id) => select(id)}
        />
      </div>
      <p className={styles.status} role="status">
        {message}
      </p>
    </div>
  );
}

components/workspaces/workflow-editor/workflow-node.tsx

"use client";

import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";

import { TriangleAlert } from "@/components/utility/icon";
import type { Issue, NodeKind } from "../_kernel/workflow";
import { KindIcon } from "./icons";
import styles from "./workflow-editor.module.css";

export type WorkflowNodeData = {
  spec: NodeKind | undefined;
  kind: string;
  name: string;
  issues: readonly Issue[];
  connectable: boolean;
};

export type FlowNode = Node<WorkflowNodeData, "step">;

/** One step on the canvas: what it is, and its ports, inputs on the left
 *  and outputs on the right, each a handle on the node's edge. */
export function WorkflowNode({ data, selected }: NodeProps<FlowNode>) {
  const { spec, name, issues, connectable } = data;
  const inputs = spec?.inputs ?? [];
  const outputs = spec?.outputs ?? [];
  const rows = Math.max(inputs.length, outputs.length);

  return (
    <div
      className={styles.node}
      data-tone={spec?.tone ?? "neutral"}
      data-selected={selected || undefined}
      data-invalid={issues.length > 0 || undefined}
    >
      <div className={styles.nodeHead}>
        <span className={styles.nodeIcon} aria-hidden="true">
          <KindIcon name={spec?.icon} />
        </span>
        <span className={styles.nodeTitle}>
          <span className={styles.nodeName}>{name}</span>
          <span className={styles.nodeKind}>
            {spec?.label ?? `Unknown: ${data.kind}`}
          </span>
        </span>
        {issues.length ? (
          <span
            className={styles.nodeIssue}
            title={issues.map((issue) => issue.message).join("\n")}
          >
            <TriangleAlert aria-hidden="true" />
          </span>
        ) : null}
      </div>
      {rows ? (
        <div className={styles.ports}>
          {Array.from({ length: rows }, (_, row) => {
            const input = inputs[row];
            const output = outputs[row];
            return (
              <div key={row} className={styles.portRow}>
                <span className={styles.portIn}>
                  {input ? (
                    <>
                      <Handle
                        type="target"
                        position={Position.Left}
                        id={input.id}
                        isConnectable={connectable}
                        className={styles.handle}
                        data-type={input.type}
                        title={`${input.label} · ${input.type}`}
                      />
                      {input.label}
                      {input.required ? (
                        <span className={styles.portRequired} aria-hidden>
                          *
                        </span>
                      ) : null}
                    </>
                  ) : null}
                </span>
                <span className={styles.portOut}>
                  {output ? (
                    <>
                      {output.label}
                      <Handle
                        type="source"
                        position={Position.Right}
                        id={output.id}
                        isConnectable={connectable}
                        className={styles.handle}
                        data-type={output.type}
                        title={`${output.label} · ${output.type}`}
                      />
                    </>
                  ) : null}
                </span>
              </div>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

components/workspaces/workflow-editor/inspector.tsx

"use client";

import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input, Textarea } from "@/components/forms/input";
import { NumberInput } from "@/components/forms/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/forms/select";
import { Switch } from "@/components/forms/switch";
import { ArrowRight, Trash2, Unlink } from "@/components/utility/icon";
import {
  compatibleTargets,
  nodeName,
  type FieldSpec,
  type Issue,
  type Kinds,
  type Rules,
  type Workflow,
  type WorkflowEdge,
  type WorkflowNode,
} from "../_kernel/workflow";
import styles from "./workflow-editor.module.css";

export type InspectorProps = {
  workflow: Workflow;
  kinds: Kinds;
  rules: Rules;
  issues: readonly Issue[];
  selectedNode: WorkflowNode | null;
  selectedEdge: WorkflowEdge | null;
  readOnly: boolean;
  /** A change to a setting or a name: `key` folds a burst of typing into
   *  one undo step. */
  onUpdate: (
    id: string,
    change: { label?: string; data?: Record<string, unknown> },
    key: string,
  ) => void;
  onConnect: (edge: Omit<WorkflowEdge, "id">) => void;
  onDisconnect: (edgeId: string) => void;
  onDelete: (nodeId: string) => void;
  onSelect: (nodeId: string | null) => void;
};

/** The selected step's name, settings and connections; with nothing
 *  selected, what keeps the workflow from being complete. */
export function Inspector(props: InspectorProps) {
  const { selectedNode, selectedEdge } = props;
  return (
    <aside className={styles.inspector} aria-label="Inspector">
      {selectedNode ? (
        <NodePanel {...props} node={selectedNode} />
      ) : selectedEdge ? (
        <EdgePanel {...props} edge={selectedEdge} />
      ) : (
        <Overview {...props} />
      )}
    </aside>
  );
}

const endName = (
  workflow: Workflow,
  kinds: Kinds,
  nodeId: string,
  portId: string,
  side: "inputs" | "outputs",
) => {
  const node = workflow.nodes.find((item) => item.id === nodeId);
  if (!node) return nodeId;
  const port = kinds.get(node.kind)?.[side].find((item) => item.id === portId);
  const ports = kinds.get(node.kind)?.[side].length ?? 0;
  // A port's name only helps when the step has more than one.
  return ports > 1 && port
    ? `${nodeName(node, kinds)} · ${port.label}`
    : nodeName(node, kinds);
};

function NodePanel({
  workflow,
  kinds,
  rules,
  issues,
  node,
  readOnly,
  onUpdate,
  onConnect,
  onDisconnect,
  onDelete,
}: InspectorProps & { node: WorkflowNode }) {
  const spec = kinds.get(node.kind);
  const mine = issues.filter((issue) => issue.nodeId === node.id);
  const errorFor = (field: string) =>
    mine.find((issue) => issue.field === field)?.message;

  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>{spec?.label ?? node.kind}</p>
        <h3 className={styles.panelTitle}>{nodeName(node, kinds)}</h3>
        {spec?.description ? (
          <p className={styles.panelNote}>{spec.description}</p>
        ) : null}
      </header>

      <Field
        label="Name"
        hint={`Shown on the canvas. Empty uses “${spec?.label ?? node.kind}”.`}
      >
        {(control) => (
          <Input
            {...control}
            size="sm"
            value={node.label ?? ""}
            placeholder={spec?.label}
            readOnly={readOnly}
            onChange={(event) =>
              onUpdate(
                node.id,
                { label: event.target.value },
                `label:${node.id}`,
              )
            }
          />
        )}
      </Field>

      {spec?.fields.map((field) => (
        <SettingField
          key={field.key}
          field={field}
          value={node.data[field.key]}
          error={errorFor(field.key)}
          readOnly={readOnly}
          onChange={(value) =>
            onUpdate(
              node.id,
              { data: { [field.key]: value } },
              `field:${node.id}:${field.key}`,
            )
          }
        />
      ))}

      {spec?.inputs.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Inputs</h4>
          {spec.inputs.map((port) => {
            const edges = workflow.edges.filter(
              (edge) => edge.target === node.id && edge.targetPort === port.id,
            );
            const issue = mine.find((item) => item.port === port.id);
            return (
              <div key={port.id} className={styles.port}>
                <p className={styles.portName}>
                  {port.label}
                  <span className={styles.portType}>{port.type}</span>
                </p>
                {edges.length ? (
                  <ul className={styles.links}>
                    {edges.map((edge) => (
                      <li key={edge.id} className={styles.link}>
                        <span>
                          From{" "}
                          {endName(
                            workflow,
                            kinds,
                            edge.source,
                            edge.sourcePort,
                            "outputs",
                          )}
                        </span>
                        {readOnly ? null : (
                          <Button
                            size="icon"
                            variant="quiet"
                            aria-label={`Disconnect from ${endName(workflow, kinds, edge.source, edge.sourcePort, "outputs")}`}
                            onClick={() => onDisconnect(edge.id)}
                          >
                            <Unlink aria-hidden="true" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={issue ? styles.linkMissing : styles.linkNone}>
                    {issue ? issue.message : "Not connected."}
                  </p>
                )}
              </div>
            );
          })}
        </section>
      ) : null}

      {spec?.outputs.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Outputs</h4>
          {spec.outputs.map((port) => {
            const edges = workflow.edges.filter(
              (edge) => edge.source === node.id && edge.sourcePort === port.id,
            );
            const targets = readOnly
              ? []
              : compatibleTargets(workflow, kinds, node.id, port.id, rules);
            return (
              <div key={port.id} className={styles.port}>
                <p className={styles.portName}>
                  {port.label}
                  <span className={styles.portType}>{port.type}</span>
                </p>
                {edges.length ? (
                  <ul className={styles.links}>
                    {edges.map((edge) => (
                      <li key={edge.id} className={styles.link}>
                        <span>
                          To{" "}
                          {endName(
                            workflow,
                            kinds,
                            edge.target,
                            edge.targetPort,
                            "inputs",
                          )}
                        </span>
                        {readOnly ? null : (
                          <Button
                            size="icon"
                            variant="quiet"
                            aria-label={`Disconnect from ${endName(workflow, kinds, edge.target, edge.targetPort, "inputs")}`}
                            onClick={() => onDisconnect(edge.id)}
                          >
                            <Unlink aria-hidden="true" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={styles.linkNone}>Not connected.</p>
                )}
                {readOnly ? null : (
                  // Keyed by the count, so it resets to its placeholder
                  // after each connection.
                  <Select
                    key={edges.length}
                    disabled={targets.length === 0}
                    onValueChange={(value) => {
                      const [target, targetPort] = value.split("\u0000");
                      onConnect({
                        source: node.id,
                        sourcePort: port.id,
                        target,
                        targetPort,
                      });
                    }}
                  >
                    <SelectTrigger
                      size="sm"
                      aria-label={`Connect ${port.label} to`}
                      placeholder={
                        targets.length ? "Connect to…" : "Nothing to connect to"
                      }
                    />
                    <SelectContent>
                      {targets.map((target) => (
                        <SelectItem
                          key={`${target.node.id}:${target.port.id}`}
                          value={`${target.node.id}\u0000${target.port.id}`}
                        >
                          {endName(
                            workflow,
                            kinds,
                            target.node.id,
                            target.port.id,
                            "inputs",
                          )}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                )}
              </div>
            );
          })}
        </section>
      ) : null}

      {readOnly ? null : (
        <Button
          variant="danger"
          size="sm"
          className={styles.panelAction}
          onClick={() => onDelete(node.id)}
        >
          <Trash2 aria-hidden="true" />
          Delete step
        </Button>
      )}
    </div>
  );
}

function SettingField({
  field,
  value,
  error,
  readOnly,
  onChange,
}: {
  field: FieldSpec;
  value: unknown;
  error: string | undefined;
  readOnly: boolean;
  onChange: (value: unknown) => void;
}) {
  return (
    <Field
      label={field.label}
      hint={field.hint}
      error={error}
      required={field.required}
    >
      {(control) => {
        switch (field.kind) {
          case "text":
            return field.multiline ? (
              <Textarea
                {...control}
                size="sm"
                rows={3}
                value={typeof value === "string" ? value : ""}
                placeholder={field.placeholder}
                readOnly={readOnly}
                onChange={(event) => onChange(event.target.value)}
              />
            ) : (
              <Input
                {...control}
                size="sm"
                value={typeof value === "string" ? value : ""}
                placeholder={field.placeholder}
                readOnly={readOnly}
                onChange={(event) => onChange(event.target.value)}
              />
            );
          case "number":
            return (
              <NumberInput
                {...control}
                size="sm"
                value={typeof value === "number" ? value : null}
                min={field.min}
                max={field.max}
                step={field.step}
                readOnly={readOnly}
                onValueChange={onChange}
              />
            );
          case "select":
            return (
              <Select
                value={typeof value === "string" ? value : ""}
                disabled={readOnly}
                onValueChange={onChange}
              >
                <SelectTrigger {...control} size="sm" placeholder="Choose…" />
                <SelectContent>
                  {field.options.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            );
          case "toggle":
            return (
              <Switch
                {...control}
                checked={value === true}
                disabled={readOnly}
                onCheckedChange={onChange}
              />
            );
        }
      }}
    </Field>
  );
}

function EdgePanel({
  workflow,
  kinds,
  edge,
  readOnly,
  onDisconnect,
  onSelect,
}: InspectorProps & { edge: WorkflowEdge }) {
  const from = endName(
    workflow,
    kinds,
    edge.source,
    edge.sourcePort,
    "outputs",
  );
  const to = endName(workflow, kinds, edge.target, edge.targetPort, "inputs");
  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>Connection</p>
        <h3 className={styles.panelTitle}>
          {from} <ArrowRight className={styles.inlineIcon} aria-label="to" />{" "}
          {to}
        </h3>
      </header>
      <div className={styles.panelButtons}>
        <Button size="sm" variant="quiet" onClick={() => onSelect(edge.source)}>
          Go to {from}
        </Button>
        <Button size="sm" variant="quiet" onClick={() => onSelect(edge.target)}>
          Go to {to}
        </Button>
      </div>
      {readOnly ? null : (
        <Button
          variant="danger"
          size="sm"
          className={styles.panelAction}
          onClick={() => onDisconnect(edge.id)}
        >
          <Unlink aria-hidden="true" />
          Remove connection
        </Button>
      )}
    </div>
  );
}

function Overview({
  workflow,
  kinds,
  issues,
  readOnly,
  onSelect,
}: InspectorProps) {
  const steps = workflow.nodes.length;
  const links = workflow.edges.length;
  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>Workflow</p>
        <h3 className={styles.panelTitle}>
          {steps} {steps === 1 ? "step" : "steps"} · {links}{" "}
          {links === 1 ? "connection" : "connections"}
        </h3>
      </header>
      {issues.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>
            {issues.length} {issues.length === 1 ? "thing" : "things"} to finish
          </h4>
          <ul className={styles.issues}>
            {issues.map((issue, index) => {
              const node = workflow.nodes.find(
                (item) => item.id === issue.nodeId,
              );
              return (
                <li key={index}>
                  <button
                    type="button"
                    className={styles.issue}
                    onClick={() => onSelect(issue.nodeId)}
                  >
                    <span className={styles.issueNode}>
                      {node ? nodeName(node, kinds) : issue.nodeId}
                    </span>
                    {issue.message}
                  </button>
                </li>
              );
            })}
          </ul>
        </section>
      ) : steps ? (
        <p className={styles.panelNote}>Every step is set up and connected.</p>
      ) : null}
      {readOnly ? null : (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Keys</h4>
          <dl className={styles.keys}>
            <dt>Tab, Enter</dt>
            <dd>Reach and select a step</dd>
            <dt>Arrows</dt>
            <dd>Move the selected step</dd>
            <dt>Delete</dt>
            <dd>Remove what is selected</dd>
            <dt>⌘Z, ⇧⌘Z</dt>
            <dd>Undo, redo</dd>
          </dl>
        </section>
      )}
    </div>
  );
}

components/workspaces/workflow-editor/palette.tsx

"use client";

import { useId } from "react";

import { count, type NodeKind, type Workflow } from "../_kernel/workflow";
import { KindIcon } from "./icons";
import styles from "./workflow-editor.module.css";

/** The drag type a palette item carries; the canvas accepts only it. */
export const DRAG_TYPE = "application/x-bento-step";

export type PaletteProps = {
  kinds: readonly NodeKind[];
  workflow: Workflow;
  /** Adds the kind in view: the keyboard's (and a click's) way in. */
  onAdd: (kind: string) => void;
};

/** Every kind of step, by category. Drag one onto the canvas, or press it
 *  to add it in the middle of the view. */
export function Palette({ kinds, workflow, onAdd }: PaletteProps) {
  const id = useId();
  const categories = [...new Set(kinds.map((kind) => kind.category))];
  return (
    <nav className={styles.palette} aria-label="Steps">
      {categories.map((category) => (
        <section key={category} className={styles.paletteGroup}>
          <h3 className={styles.paletteHeading}>{category}</h3>
          <ul className={styles.paletteList}>
            {kinds
              .filter((kind) => kind.category === category)
              .map((kind) => {
                const full =
                  kind.max !== undefined &&
                  count(workflow, kind.kind) >= kind.max;
                return (
                  <li key={kind.kind}>
                    <button
                      type="button"
                      className={styles.paletteItem}
                      data-tone={kind.tone ?? "neutral"}
                      draggable={!full}
                      disabled={full}
                      onDragStart={(event) => {
                        event.dataTransfer.setData(DRAG_TYPE, kind.kind);
                        event.dataTransfer.effectAllowed = "copy";
                      }}
                      onClick={() => onAdd(kind.kind)}
                      aria-describedby={`${id}-${kind.kind}`}
                    >
                      <span className={styles.nodeIcon} aria-hidden="true">
                        <KindIcon name={kind.icon} />
                      </span>
                      <span className={styles.paletteText}>
                        <span className={styles.paletteLabel}>
                          {kind.label}
                        </span>
                        <span
                          id={`${id}-${kind.kind}`}
                          className={styles.paletteNote}
                          // Read as the description, not as part of the name.
                          aria-hidden="true"
                        >
                          {full
                            ? kind.max === 1
                              ? "Only one per workflow"
                              : `At most ${kind.max} per workflow`
                            : kind.description}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
          </ul>
        </section>
      ))}
    </nav>
  );
}

components/workspaces/workflow-editor/workflow-editor.module.css

/* Unlayered on purpose: the engine's stylesheet is unlayered, and an
   override in a layer would lose to it whatever its specificity. Every
   selector here is scoped to this module's classes. */

.editor {
  container-type: inline-size;
  min-inline-size: 0;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-3);
  background: var(--surface-panel);
  color: var(--ink);
  overflow: hidden;
}
/* The grid sits inside the container, so the container query can change it. */
.frame {
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr) 288px;
  grid-template-areas:
    "toolbar toolbar toolbar"
    "palette canvas inspector";
}
.readOnly {
  grid-template-columns: minmax(0, 1fr) 288px;
  grid-template-areas:
    "toolbar toolbar"
    "canvas inspector";
}

/* ── Toolbar ── */
.toolbar {
  grid-area: toolbar;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--line);
}
.toolbar svg {
  inline-size: 15px;
  block-size: 15px;
}
.divider {
  inline-size: 1px;
  block-size: 18px;
  margin-inline: var(--space-1);
  background: var(--line);
}
.toolbarEnd {
  margin-inline-start: auto;
}

/* ── Palette ── */
.palette {
  grid-area: palette;
  display: grid;
  align-content: start;
  gap: var(--space-6);
  max-block-size: 600px;
  padding: var(--space-5) var(--space-4);
  border-right: 1px solid var(--line);
  overflow-y: auto;
}
.paletteGroup {
  display: grid;
  align-content: start;
  gap: var(--space-2);
}
.paletteHeading {
  margin: 0;
  padding-inline: var(--space-2);
  color: var(--ink-3);
  font-size: var(--text-11);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.paletteList {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.paletteItem {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: var(--space-3);
  inline-size: 100%;
  padding: var(--space-2);
  border: 1px solid transparent;
  border-radius: var(--radius-2);
  background: none;
  color: var(--ink);
  font: inherit;
  text-align: start;
  cursor: grab;
}
.paletteItem:hover:not(:disabled) {
  border-color: var(--line);
  background: var(--surface-hover);
}
.paletteItem:active:not(:disabled) {
  cursor: grabbing;
}
.paletteItem:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.paletteItem:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}
.paletteText {
  display: grid;
  gap: 1px;
  min-inline-size: 0;
}
.paletteLabel {
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
}
.paletteNote {
  color: var(--ink-3);
  font-size: var(--text-12);
  line-height: var(--leading-snug);
}

/* ── Canvas ── */
.canvas {
  grid-area: canvas;
  position: relative;
  block-size: 600px;
  min-inline-size: 0;
  background: var(--surface-sunk);
  --xy-background-color: var(--surface-sunk);
  --xy-background-pattern-color: var(--line-strong);
  --xy-edge-stroke: var(--ink-3);
  --xy-edge-stroke-width: 1.5;
  --xy-edge-stroke-selected: var(--accent);
  --xy-connectionline-stroke: var(--accent);
  --xy-connectionline-stroke-width: 1.5;
  --xy-handle-background-color: var(--surface-panel);
  --xy-handle-border-color: var(--ink-3);
  --xy-node-border-radius: var(--radius-3);
  --xy-controls-button-background-color: var(--surface-panel);
  --xy-controls-button-background-color-hover: var(--surface-raised);
  --xy-controls-button-color: var(--ink);
  --xy-controls-button-color-hover: var(--ink);
  --xy-controls-button-border-color: var(--line-strong);
  --xy-controls-box-shadow: none;
  --xy-selection-background-color: var(--accent-tint);
  --xy-selection-border: 1px dashed var(--accent);
}
/* Focus lands here after a press on the pane, so keys reach the editor;
   it is not a stop in the tab order, so it shows no ring. */
.canvas:focus {
  outline: none;
}
/* The image reset caps an SVG's width at its parent. Edge SVGs overflow a
   zero-width container on purpose, so the cap stops them painting. */
.canvas :global(.react-flow__edges svg) {
  max-inline-size: none;
}
.canvas :global(.react-flow__node) {
  border-radius: var(--radius-3);
}
.canvas :global(.react-flow__node:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.canvas :global(.react-flow__edge:focus-visible .react-flow__edge-path) {
  stroke: var(--accent);
  stroke-width: 3;
}
.canvas :global(.react-flow__controls) {
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-2);
  overflow: hidden;
}
.canvas :global(.react-flow__controls-button) {
  inline-size: 30px;
  block-size: 30px;
}
.canvas :global(.react-flow__controls-button:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: var(--space-7);
  color: var(--ink-3);
  font-size: var(--text-13);
  text-align: center;
  pointer-events: none;
}

/* ── A step ── */
.node {
  min-inline-size: 200px;
  max-inline-size: 260px;
  border: 1px solid var(--line-heavy);
  border-radius: var(--radius-3);
  background: var(--surface-panel);
  color: var(--ink);
  font-size: var(--text-12);
  box-shadow: 0 1px 2px var(--alpha-b-08);
}
.node[data-selected] {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}
.node[data-invalid]:not([data-selected]) {
  border-color: var(--warn-line);
}
.nodeHead {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
}
.nodeIcon {
  display: grid;
  place-items: center;
  inline-size: 26px;
  block-size: 26px;
  border-radius: var(--radius-2);
  background: var(--surface-sunk);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
}
.nodeIcon svg {
  inline-size: 14px;
  block-size: 14px;
}
[data-tone] .nodeIcon {
  box-shadow: none;
}
[data-tone="neutral"] .nodeIcon {
  box-shadow: inset 0 0 0 1px var(--line);
}
[data-tone="accent"] .nodeIcon {
  background: var(--accent-tint);
  color: var(--accent);
}
[data-tone="info"] .nodeIcon {
  background: var(--info-tint);
  color: var(--info);
}
[data-tone="warn"] .nodeIcon {
  background: var(--warn-tint);
  color: var(--warn);
}
[data-tone="crit"] .nodeIcon {
  background: var(--crit-tint);
  color: var(--crit);
}
.nodeTitle {
  display: grid;
  min-inline-size: 0;
}
.nodeName {
  overflow: hidden;
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nodeKind {
  color: var(--ink-3);
  font-size: var(--text-11);
}
.nodeIssue {
  display: grid;
  color: var(--warn);
}
.nodeIssue svg {
  inline-size: 14px;
  block-size: 14px;
}
.ports {
  display: grid;
  gap: var(--space-1);
  padding-block: var(--space-2);
  border-top: 1px solid var(--line);
}
.portRow {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: center;
  min-block-size: 20px;
}
.portIn,
.portOut {
  position: relative;
  display: flex;
  align-items: center;
  gap: 2px;
  block-size: 100%;
  color: var(--ink-2);
  white-space: nowrap;
}
.portIn {
  padding-inline-start: var(--space-4);
}
.portOut {
  justify-content: flex-end;
  padding-inline-end: var(--space-4);
}
/* Each handle sits on the node's edge, level with its label. */
.portOut .handle {
  right: 0;
}
.portIn .handle,
.portOut .handle {
  position: absolute;
  inline-size: 10px;
  block-size: 10px;
  border-width: 1.5px;
}
.portIn .handle {
  left: 0;
}
.handle[data-type="any"] {
  border-style: dashed;
}
.handle:global(.connectingto),
.handle:global(.connectionindicator):hover {
  border-color: var(--accent);
}
.handle:global(.valid) {
  background: var(--accent);
  border-color: var(--accent);
}
.portRequired {
  color: var(--ink-3);
}

/* ── Inspector ── */
.inspector {
  grid-area: inspector;
  max-block-size: 600px;
  border-left: 1px solid var(--line);
  overflow-y: auto;
}
.panel {
  display: grid;
  align-content: start;
  gap: var(--space-5);
  padding: var(--space-5);
}
.panelHead {
  display: grid;
  gap: var(--space-1);
}
.panelEyebrow {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-11);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.panelTitle {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-15);
  font-weight: var(--weight-medium);
}
.inlineIcon {
  inline-size: 14px;
  block-size: 14px;
  color: var(--ink-3);
}
.panelNote {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-12);
  line-height: var(--leading-snug);
}
.panelSection {
  display: grid;
  gap: var(--space-3);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line);
}
.panelHeading {
  margin: 0;
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
}
.panelButtons {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}
.panelAction {
  justify-self: start;
}
.port {
  display: grid;
  gap: var(--space-2);
}
.portName {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-12);
  font-weight: var(--weight-medium);
}
.portType {
  color: var(--ink-3);
  font-family: var(--font-mono);
  font-size: var(--text-11);
  font-weight: var(--weight-regular);
}
.links {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding-inline-start: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-2);
  color: var(--ink-2);
  font-size: var(--text-12);
}
.link svg {
  inline-size: 14px;
  block-size: 14px;
}
.linkNone,
.linkMissing {
  margin: 0;
  font-size: var(--text-12);
}
.linkNone {
  color: var(--ink-3);
}
.linkMissing {
  color: var(--warn);
}
.issues {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.issue {
  display: grid;
  gap: 1px;
  inline-size: 100%;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-left: 2px solid var(--warn);
  border-radius: var(--radius-2);
  background: none;
  color: var(--ink-2);
  font: inherit;
  font-size: var(--text-12);
  text-align: start;
  cursor: pointer;
}
.issue:hover {
  background: var(--surface-hover);
}
.issue:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.issueNode {
  color: var(--ink);
  font-weight: var(--weight-medium);
}
.keys {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  font-size: var(--text-12);
}
.keys dt {
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: var(--text-11);
}
.keys dd {
  margin: 0;
  color: var(--ink-3);
}

/* Announced, not shown: the canvas itself shows what changed. */
.status {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Medium: the inspector moves below the palette and canvas. */
@container (max-width: 1040px) {
  .frame {
    grid-template-columns: 200px minmax(0, 1fr);
    grid-template-areas:
      "toolbar toolbar"
      "palette canvas"
      "inspector inspector";
  }
  .readOnly {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "toolbar"
      "canvas"
      "inspector";
  }
  .inspector {
    max-block-size: none;
    border-left: 0;
    border-top: 1px solid var(--line);
  }
}

/* Narrow: the canvas first, then the palette as a strip, then the
   inspector. */
@container (max-width: 640px) {
  .frame,
  .readOnly {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "toolbar"
      "canvas"
      "palette"
      "inspector";
  }
  .canvas {
    block-size: 440px;
  }
  .palette {
    grid-auto-flow: column;
    grid-auto-columns: minmax(200px, 1fr);
    max-block-size: none;
    overflow-x: auto;
    border-right: 0;
    border-top: 1px solid var(--line);
  }
  .inspector {
    max-block-size: none;
    border-left: 0;
    border-top: 1px solid var(--line);
  }
}

components/workspaces/_kernel/workflow.ts

/* A workflow: nodes of declared kinds, joined port to port. Plain data and
   pure functions, framework-free and identical in both apps; the canvas,
   palette and inspector only render it and call these. Every change returns
   a new workflow, so history can keep the old one. */

/** What a port carries. "any" connects to every type. */
export type PortType = string;

export type PortSpec = Readonly<{
  id: string;
  label: string;
  type: PortType;
  /** An input needs a connection before the workflow is complete. */
  required?: boolean;
  /** How many connections the port takes: inputs one by default, outputs
   *  many. */
  multiple?: boolean;
}>;

type FieldBase = Readonly<{
  key: string;
  label: string;
  hint?: string;
  required?: boolean;
}>;

/** A setting on a node, edited in the inspector. */
export type FieldSpec =
  | (FieldBase &
      Readonly<{ kind: "text"; placeholder?: string; multiline?: boolean }>)
  | (FieldBase &
      Readonly<{ kind: "number"; min?: number; max?: number; step?: number }>)
  | (FieldBase &
      Readonly<{
        kind: "select";
        options: readonly Readonly<{ value: string; label: string }>[];
      }>)
  | (FieldBase & Readonly<{ kind: "toggle" }>);

/** One kind of node the palette offers: its ports and its settings. */
export type NodeKind = Readonly<{
  kind: string;
  label: string;
  /** Groups the palette: "Triggers", "Logic", "Actions"… */
  category: string;
  description?: string;
  /** A name from the icon set, drawn by the renderer. */
  icon?: string;
  tone?: "accent" | "info" | "warn" | "crit" | "neutral";
  inputs: readonly PortSpec[];
  outputs: readonly PortSpec[];
  fields: readonly FieldSpec[];
  defaults?: Readonly<Record<string, unknown>>;
  /** At most this many in one workflow (say, one trigger). */
  max?: number;
}>;

export type Point = Readonly<{ x: number; y: number }>;

export type WorkflowNode = Readonly<{
  id: string;
  kind: string;
  position: Point;
  /** A name the author gave it; the kind's label otherwise. */
  label?: string;
  data: Readonly<Record<string, unknown>>;
}>;

export type WorkflowEdge = Readonly<{
  id: string;
  source: string;
  sourcePort: string;
  target: string;
  targetPort: string;
}>;

export type Workflow = Readonly<{
  nodes: readonly WorkflowNode[];
  edges: readonly WorkflowEdge[];
}>;

export type Connection = Omit<WorkflowEdge, "id">;

export type Rules = Readonly<{
  /** Allow an edge that closes a loop. Off by default: most workflows
   *  run once, start to end. */
  allowCycles?: boolean;
}>;

export const EMPTY: Workflow = { nodes: [], edges: [] };

export type Kinds = ReadonlyMap<string, NodeKind>;

export const kindMap = (kinds: readonly NodeKind[]): Kinds =>
  new Map(kinds.map((kind) => [kind.kind, kind]));

/** A node's name as a person reads it. */
export const nodeName = (node: WorkflowNode, kinds: Kinds) =>
  node.label?.trim() || kinds.get(node.kind)?.label || node.kind;

// ── Changes ──────────────────────────────────────────────────────────────

/** Adds a node of `kind` at `position`, with the kind's defaults. Null when
 *  the kind is unknown or already at its maximum. */
export function addNode(
  workflow: Workflow,
  kinds: Kinds,
  kind: string,
  position: Point,
  id: string,
): Workflow | null {
  const spec = kinds.get(kind);
  if (!spec) return null;
  if (spec.max !== undefined && count(workflow, kind) >= spec.max) return null;
  return {
    ...workflow,
    nodes: [
      ...workflow.nodes,
      { id, kind, position, data: { ...(spec.defaults ?? {}) } },
    ],
  };
}

/** How many nodes of `kind` the workflow has. */
export const count = (workflow: Workflow, kind: string) =>
  workflow.nodes.filter((node) => node.kind === kind).length;

/** Removes nodes, and every edge touching them. */
export function removeNodes(
  workflow: Workflow,
  ids: Iterable<string>,
): Workflow {
  const gone = new Set(ids);
  return {
    nodes: workflow.nodes.filter((node) => !gone.has(node.id)),
    edges: workflow.edges.filter(
      (edge) => !gone.has(edge.source) && !gone.has(edge.target),
    ),
  };
}

export function removeEdges(
  workflow: Workflow,
  ids: Iterable<string>,
): Workflow {
  const gone = new Set(ids);
  return {
    ...workflow,
    edges: workflow.edges.filter((edge) => !gone.has(edge.id)),
  };
}

/** Moves nodes to new positions; unknown ids are ignored. */
export function moveNodes(
  workflow: Workflow,
  positions: ReadonlyMap<string, Point>,
): Workflow {
  return {
    ...workflow,
    nodes: workflow.nodes.map((node) => {
      const to = positions.get(node.id);
      return to ? { ...node, position: { x: to.x, y: to.y } } : node;
    }),
  };
}

/** Changes a node's name or settings. */
export function updateNode(
  workflow: Workflow,
  id: string,
  change: Readonly<{
    label?: string;
    data?: Readonly<Record<string, unknown>>;
  }>,
): Workflow {
  return {
    ...workflow,
    nodes: workflow.nodes.map((node) =>
      node.id === id
        ? {
            ...node,
            ...(change.label !== undefined ? { label: change.label } : {}),
            ...(change.data ? { data: { ...node.data, ...change.data } } : {}),
          }
        : node,
    ),
  };
}

// ── Connections ──────────────────────────────────────────────────────────

/** Why a connection is refused. */
export type Refusal =
  | "same_node"
  | "unknown_port"
  | "type_mismatch"
  | "input_taken"
  | "output_taken"
  | "duplicate"
  | "cycle";

export const REFUSALS: Readonly<Record<Refusal, string>> = {
  same_node: "A node cannot connect to itself.",
  unknown_port: "That port does not exist.",
  type_mismatch: "Those ports carry different kinds of data.",
  input_taken: "That input already has a connection.",
  output_taken: "That output already has a connection.",
  duplicate: "Those ports are already connected.",
  cycle: "That connection would make a loop.",
};

const portOf = (
  workflow: Workflow,
  kinds: Kinds,
  nodeId: string,
  portId: string,
  side: "inputs" | "outputs",
) => {
  const node = workflow.nodes.find((item) => item.id === nodeId);
  const spec = node ? kinds.get(node.kind) : undefined;
  return spec?.[side].find((port) => port.id === portId);
};

const typesMatch = (a: PortType, b: PortType) =>
  a === "any" || b === "any" || a === b;

/** Whether `to` can be reached from `from` along edges. */
function reaches(workflow: Workflow, from: string, to: string) {
  const next = new Map<string, string[]>();
  for (const edge of workflow.edges)
    next.set(edge.source, [...(next.get(edge.source) ?? []), edge.target]);
  const seen = new Set<string>();
  const stack = [from];
  while (stack.length) {
    const id = stack.pop()!;
    if (id === to) return true;
    if (seen.has(id)) continue;
    seen.add(id);
    stack.push(...(next.get(id) ?? []));
  }
  return false;
}

/** Why `connection` would be refused, or null when it is allowed. Always
 *  from an output to an input. */
export function checkConnection(
  workflow: Workflow,
  kinds: Kinds,
  connection: Connection,
  rules: Rules = {},
): Refusal | null {
  const { source, sourcePort, target, targetPort } = connection;
  if (source === target) return "same_node";
  const out = portOf(workflow, kinds, source, sourcePort, "outputs");
  const into = portOf(workflow, kinds, target, targetPort, "inputs");
  if (!out || !into) return "unknown_port";
  if (!typesMatch(out.type, into.type)) return "type_mismatch";
  const same = (edge: WorkflowEdge) =>
    edge.source === source &&
    edge.sourcePort === sourcePort &&
    edge.target === target &&
    edge.targetPort === targetPort;
  if (workflow.edges.some(same)) return "duplicate";
  if (
    !into.multiple &&
    workflow.edges.some(
      (edge) => edge.target === target && edge.targetPort === targetPort,
    )
  )
    return "input_taken";
  if (
    out.multiple === false &&
    workflow.edges.some(
      (edge) => edge.source === source && edge.sourcePort === sourcePort,
    )
  )
    return "output_taken";
  if (!rules.allowCycles && reaches(workflow, target, source)) return "cycle";
  return null;
}

/** Joins two ports, or says why not. */
export function connect(
  workflow: Workflow,
  kinds: Kinds,
  connection: Connection,
  id: string,
  rules: Rules = {},
):
  { workflow: Workflow; refusal: null } | { workflow: null; refusal: Refusal } {
  const refusal = checkConnection(workflow, kinds, connection, rules);
  return refusal
    ? { workflow: null, refusal }
    : {
        workflow: {
          ...workflow,
          edges: [...workflow.edges, { id, ...connection }],
        },
        refusal: null,
      };
}

/** Every input an output could connect to now: the keyboard's way to draw
 *  an edge, and the canvas's way to highlight valid drops. */
export function compatibleTargets(
  workflow: Workflow,
  kinds: Kinds,
  source: string,
  sourcePort: string,
  rules: Rules = {},
) {
  const targets: { node: WorkflowNode; port: PortSpec }[] = [];
  for (const node of workflow.nodes) {
    for (const port of kinds.get(node.kind)?.inputs ?? []) {
      const connection = {
        source,
        sourcePort,
        target: node.id,
        targetPort: port.id,
      };
      if (!checkConnection(workflow, kinds, connection, rules))
        targets.push({ node, port });
    }
  }
  return targets;
}

// ── Checking ─────────────────────────────────────────────────────────────

export type Issue = Readonly<{
  nodeId: string;
  /** The field or input the issue is about, when it is one. */
  field?: string;
  port?: string;
  message: string;
}>;

const empty = (value: unknown) =>
  value === undefined ||
  value === null ||
  (typeof value === "string" && value.trim() === "");

/** What keeps the workflow from being complete: required settings left
 *  empty, required inputs left unconnected, kinds this build does not know. */
export function issues(workflow: Workflow, kinds: Kinds): Issue[] {
  const found: Issue[] = [];
  for (const node of workflow.nodes) {
    const spec = kinds.get(node.kind);
    if (!spec) {
      found.push({
        nodeId: node.id,
        message: `Unknown kind of node: ${node.kind}.`,
      });
      continue;
    }
    for (const field of spec.fields)
      if (field.required && empty(node.data[field.key]))
        found.push({
          nodeId: node.id,
          field: field.key,
          message: `${field.label} is required.`,
        });
    for (const port of spec.inputs)
      if (
        port.required &&
        !workflow.edges.some(
          (edge) => edge.target === node.id && edge.targetPort === port.id,
        )
      )
        found.push({
          nodeId: node.id,
          port: port.id,
          message: `${port.label} needs a connection.`,
        });
  }
  return found;
}

components/workspaces/_kernel/history.ts

/* Undo and redo for any value kept whole: each change is a new value, and
   the history keeps the ones before it. A burst of the same change (one
   drag, one field being typed in) is kept as one step. */

export type History<T> = Readonly<{
  past: readonly T[];
  present: T;
  future: readonly T[];
  /** What the last step was, and when: a same-key change soon after is
   *  folded into it. */
  last: Readonly<{ key: string; at: number }> | null;
}>;

/** Steps kept; the oldest go first. */
export const LIMIT = 100;
/** How close in time a same-key change must be to fold into the last. */
export const WINDOW_MS = 800;

export const start = <T>(present: T): History<T> => ({
  past: [],
  present,
  future: [],
  last: null,
});

/** Records `next` as the present. With a `key` (say, "move:n1"), a change
 *  within the window after one with the same key replaces it rather than
 *  adding a step. A change drops anything that was undone. */
export function commit<T>(
  history: History<T>,
  next: T,
  key: string | null = null,
  now = Date.now(),
): History<T> {
  if (next === history.present) return history;
  const fold =
    key !== null &&
    history.last?.key === key &&
    now - history.last.at <= WINDOW_MS &&
    history.past.length > 0;
  return {
    past: fold
      ? history.past
      : [...history.past, history.present].slice(-LIMIT),
    present: next,
    future: [],
    last: key === null ? null : { key, at: now },
  };
}

export const canUndo = <T>(history: History<T>) => history.past.length > 0;
export const canRedo = <T>(history: History<T>) => history.future.length > 0;

export function undo<T>(history: History<T>): History<T> {
  if (!canUndo(history)) return history;
  return {
    past: history.past.slice(0, -1),
    present: history.past[history.past.length - 1],
    future: [history.present, ...history.future],
    last: null,
  };
}

export function redo<T>(history: History<T>): History<T> {
  if (!canRedo(history)) return history;
  return {
    past: [...history.past, history.present],
    present: history.future[0],
    future: history.future.slice(1),
    last: null,
  };
}

components/workspaces/_kernel/layout.ts

import { longestPath } from "../../charts/_kernel/graph";
import type { Point, Workflow } from "./workflow";

/* Tidy: lays a workflow out left to right. Each node goes in the column of
   its longest path from a start (loops broken first, as for the flow chart);
   within a column, nodes are ordered to sit near what they connect to, which
   uncrosses most edges; columns are centred on each other. */

export type Size = Readonly<{ width: number; height: number }>;

export type LayoutOptions = Readonly<{
  /** Space between columns, and between nodes in a column. */
  gapX?: number;
  gapY?: number;
  origin?: Point;
  /** A node without a measured size is taken to be this big. */
  fallback?: Size;
}>;

/** Ordering passes, alternating down and up the columns. */
const SWEEPS = 4;

export function autoLayout(
  workflow: Workflow,
  sizes: ReadonlyMap<string, Size>,
  options: LayoutOptions = {},
): Map<string, Point> {
  const { gapX = 80, gapY = 32, origin = { x: 0, y: 0 } } = options;
  const fallback = options.fallback ?? { width: 220, height: 96 };
  const size = (id: string) => sizes.get(id) ?? fallback;
  const nodes = workflow.nodes;
  if (nodes.length === 0) return new Map();

  const depth = longestPath(
    nodes,
    workflow.edges.map((edge) => ({ from: edge.source, to: edge.target })),
  );
  const columns: string[][] = [];
  // Start from the current top-to-bottom order: tidy stays close to it.
  const byY = [...nodes].sort(
    (a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
  );
  for (const node of byY)
    (columns[depth.get(node.id) ?? 0] ??= []).push(node.id);
  const filled = columns.filter((column) => column && column.length);

  const before = new Map<string, string[]>();
  const after = new Map<string, string[]>();
  for (const edge of workflow.edges) {
    after.set(edge.source, [...(after.get(edge.source) ?? []), edge.target]);
    before.set(edge.target, [...(before.get(edge.target) ?? []), edge.source]);
  }
  const index = new Map<string, number>();
  const reindex = () =>
    filled.forEach((column) => column.forEach((id, i) => index.set(id, i)));
  reindex();
  // Each node's place in its column: the average place of its neighbours in
  // the column before (sweeping down) or after (sweeping up).
  const order = (column: string[], neighbours: Map<string, string[]>) => {
    const weight = (id: string) => {
      const near = (neighbours.get(id) ?? []).filter((n) => index.has(n));
      return near.length
        ? near.reduce((sum, n) => sum + index.get(n)!, 0) / near.length
        : index.get(id)!;
    };
    const weights = new Map(column.map((id) => [id, weight(id)]));
    column.sort(
      (a, b) =>
        weights.get(a)! - weights.get(b)! || index.get(a)! - index.get(b)!,
    );
    column.forEach((id, i) => index.set(id, i));
  };
  for (let pass = 0; pass < SWEEPS; pass += 1) {
    if (pass % 2 === 0)
      for (let c = 1; c < filled.length; c += 1) order(filled[c], before);
    else
      for (let c = filled.length - 2; c >= 0; c -= 1) order(filled[c], after);
  }

  const heights = filled.map(
    (column) =>
      column.reduce((sum, id) => sum + size(id).height, 0) +
      gapY * (column.length - 1),
  );
  const tallest = Math.max(...heights);
  const positions = new Map<string, Point>();
  let x = origin.x;
  filled.forEach((column, c) => {
    const width = Math.max(...column.map((id) => size(id).width));
    let y = origin.y + (tallest - heights[c]) / 2;
    for (const id of column) {
      positions.set(id, { x: Math.round(x), y: Math.round(y) });
      y += size(id).height + gapY;
    }
    x += width + gapX;
  });
  return positions;
}

Read only

inspect without changing

Sourcecomponents/workspaces/workflow-editor/doc.ts · components/workspaces/workflow-editor/workflow-editor.tsx · components/workspaces/workflow-editor/workflow-node.tsx · components/workspaces/workflow-editor/inspector.tsx · components/workspaces/workflow-editor/palette.tsx · components/workspaces/workflow-editor/workflow-editor.module.css · components/workspaces/_kernel/workflow.ts · components/workspaces/_kernel/history.ts · components/workspaces/_kernel/layout.ts

components/workspaces/workflow-editor/doc.ts

/**
 * WorkflowEditor — build a workflow by placing steps and joining their ports.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the canvas
 *     kinds          REQUIRED   { kind, label, category, description?, icon?,
 *                               tone?, inputs, outputs, fields, defaults?,
 *                               max? }[]
 *                      ports    { id, label, type, required?, multiple? }
 *                      fields   text | number | select | toggle, each with
 *                               key, label, hint?, required?
 *     defaultValue?  { nodes, edges }: where editing starts (empty)
 *     onChange?      every committed change, to persist
 *     rules?         { allowCycles? } (loops refused by default)
 *     readOnly?      look, select and inspect; nothing changes
 *     empty?         what an empty canvas says
 *
 * # Behaviour
 *
 * R1  Three panes: a palette of kinds by category, the canvas, and an
 *     inspector. A toolbar holds undo, redo, tidy and fit, and says whether
 *     the workflow is complete.
 * R2  A kind is added by dragging it onto the canvas (it lands under the
 *     pointer) or by pressing it (it lands in the middle of the view). A
 *     kind at its `max` is disabled, and says why.
 * R3  A connection runs from an output to an input. One is refused — the
 *     drop does nothing and the reason is announced — when it joins a node
 *     to itself, the port types differ ("any" matches every type), the
 *     input is taken (unless `multiple`), it exists already, or it would
 *     close a loop (unless `allowCycles`). While dragging, only ports that
 *     would accept are lit.
 * R4  Everything the pointer does has a keyboard way: Tab reaches steps and
 *     connections, Enter selects, arrows move the selected step, Delete or
 *     Backspace removes the selection, and the inspector connects an output
 *     to any input that would accept it ("Connect to…") and disconnects.
 * R5  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change. A
 *     burst of one change — a drag, arrow presses on one step, typing in
 *     one field — is one step. Keys in a text field keep their own undo.
 * R6  Tidy lays steps out left to right by longest path from a start, orders
 *     each column to uncross edges, and fits the view. It is one undo step.
 * R7  The inspector shows the selected step's name and settings (with each
 *     missing required value as the field's error) and its connections;
 *     the selected connection, with a way to remove it; or, with nothing
 *     selected, a list of what keeps the workflow from being complete,
 *     each item selecting its step.
 * R8  A step with an issue is marked on the canvas, and its accessible name
 *     says it needs attention. Every change is announced politely.
 * R9  onChange receives committed changes only: not the starting value, not
 *     positions mid-drag, not selection.
 * R10 Below about 860px wide the panes stack: canvas, palette strip,
 *     inspector.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The workflow, its rules, history and tidy are the framework-free kernel
 * in ../_kernel (identical in both apps, tested there); this renderer only
 * draws it and calls it. The canvas is xyflow, fully controlled: positions
 * mid-drag, selection and measured sizes live in renderer state, and each
 * node is passed its measured size back — without it the engine loses its
 * handle bounds on every update and the edges vanish. The engine's CSS is
 * unlayered, so this module is too, and themes it through the --xy-*
 * variables; the image reset's max-width cap is lifted from edge SVGs.
 */
export {};

components/workspaces/workflow-editor/workflow-editor.tsx

"use client";

import {
  Background,
  Controls,
  ReactFlow,
  ReactFlowProvider,
  useReactFlow,
  type Edge,
  type EdgeChange,
  type NodeChange,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { Badge } from "@/components/display/badge";
import { Button } from "@/components/forms/button";
import {
  Maximize2,
  Redo2,
  Undo2,
  WandSparkles,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import { autoLayout, type Size } from "../_kernel/layout";
import {
  addNode,
  checkConnection,
  connect,
  EMPTY,
  issues as findIssues,
  kindMap,
  moveNodes,
  nodeName,
  REFUSALS,
  removeEdges,
  removeNodes,
  updateNode,
  type Connection,
  type NodeKind,
  type Point,
  type Rules,
  type Workflow,
} from "../_kernel/workflow";
import { Inspector } from "./inspector";
import { DRAG_TYPE, Palette } from "./palette";
import { WorkflowNode, type FlowNode } from "./workflow-node";
import styles from "./workflow-editor.module.css";

export type WorkflowEditorProps = {
  /** Names the canvas for assistive technology. */
  label: string;
  /** The kinds of step the palette offers. */
  kinds: readonly NodeKind[];
  /** Where editing starts. The editor keeps its own history from here. */
  defaultValue?: Workflow;
  /** Every committed change: a drop, a connection, a finished drag, a
   *  setting. Persist here. */
  onChange?: (workflow: Workflow) => void;
  rules?: Rules;
  /** Look, select and inspect; no palette, no changes. */
  readOnly?: boolean;
  /** Shown on an empty canvas. */
  empty?: ReactNode;
  className?: string;
};

const nodeTypes = { step: WorkflowNode };

/** Builds workflows by dragging steps from a palette onto a canvas and
 *  joining their ports; see doc.ts. */
export function WorkflowEditor(props: WorkflowEditorProps) {
  // The engine's viewport helpers are needed outside the canvas (the
  // palette adds in view), so the provider wraps the whole editor.
  return (
    <ReactFlowProvider>
      <Editor {...props} />
    </ReactFlowProvider>
  );
}

/** An id not yet used in the workflow: n1, n2… (e1, e2… for edges). */
function nextId(prefix: string, taken: readonly { id: string }[]) {
  const ids = new Set(taken.map((item) => item.id));
  let n = taken.length + 1;
  while (ids.has(`${prefix}${n}`)) n += 1;
  return `${prefix}${n}`;
}

const isTyping = (target: EventTarget | null) =>
  target instanceof HTMLElement &&
  (target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));

function Editor({
  label,
  kinds: kindList,
  defaultValue = EMPTY,
  onChange,
  rules = {},
  readOnly = false,
  empty = "Drag a step here from the palette, or choose one to add it.",
  className,
}: WorkflowEditorProps) {
  const kinds = useMemo(() => kindMap(kindList), [kindList]);
  const flow = useReactFlow<FlowNode, Edge>();
  const canvasRef = useRef<HTMLDivElement>(null);

  const [history, setHistory] = useState<History<Workflow>>(() =>
    start(defaultValue),
  );
  const workflow = history.present;
  const change = useCallback(
    (next: Workflow | null, key: string | null = null) => {
      if (next) setHistory((current) => commit(current, next, key));
    },
    [],
  );
  // Tell the caller about committed changes, not the starting value.
  const reported = useRef(workflow);
  useEffect(() => {
    if (reported.current === workflow) return;
    reported.current = workflow;
    onChange?.(workflow);
  }, [workflow, onChange]);

  // Selection, drag positions and measurements belong to the renderer,
  // not to the workflow (and not to its history).
  const [selectedNodes, setSelectedNodes] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [selectedEdges, setSelectedEdges] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [dragging, setDragging] = useState<ReadonlyMap<string, Point>>(
    () => new Map(),
  );
  const [measured, setMeasured] = useState<ReadonlyMap<string, Size>>(
    () => new Map(),
  );
  const [message, setMessage] = useState("");

  const issues = useMemo(() => findIssues(workflow, kinds), [workflow, kinds]);

  const flowNodes = useMemo<FlowNode[]>(
    () =>
      workflow.nodes.map((node) => ({
        id: node.id,
        type: "step",
        position: dragging.get(node.id) ?? node.position,
        // Controlled nodes must carry their measurements back, or the
        // engine loses its handle bounds and the edges disappear.
        measured: measured.get(node.id),
        selected: selectedNodes.has(node.id),
        ariaLabel: `${nodeName(node, kinds)}, ${kinds.get(node.kind)?.label ?? node.kind}${
          issues.some((issue) => issue.nodeId === node.id)
            ? ", needs attention"
            : ""
        }`,
        data: {
          spec: kinds.get(node.kind),
          kind: node.kind,
          name: nodeName(node, kinds),
          issues: issues.filter((issue) => issue.nodeId === node.id),
          connectable: !readOnly,
        },
      })),
    [
      workflow.nodes,
      dragging,
      measured,
      selectedNodes,
      kinds,
      issues,
      readOnly,
    ],
  );
  const flowEdges = useMemo<Edge[]>(
    () =>
      workflow.edges.map((edge) => ({
        id: edge.id,
        source: edge.source,
        sourceHandle: edge.sourcePort,
        target: edge.target,
        targetHandle: edge.targetPort,
        selected: selectedEdges.has(edge.id),
        ariaLabel: `Connection from ${nameOf(edge.source)} to ${nameOf(edge.target)}`,
      })),
    // eslint-disable-next-line react-hooks/exhaustive-deps -- nameOf reads workflow.nodes and kinds
    [workflow.edges, workflow.nodes, selectedEdges, kinds],
  );
  function nameOf(id: string) {
    const node = workflow.nodes.find((item) => item.id === id);
    return node ? nodeName(node, kinds) : id;
  }

  const select = useCallback((nodeId: string | null, edgeId?: string) => {
    setSelectedNodes(nodeId ? new Set([nodeId]) : new Set());
    setSelectedEdges(edgeId ? new Set([edgeId]) : new Set());
  }, []);

  function applySelect(
    current: ReadonlySet<string>,
    changes: readonly (NodeChange<FlowNode> | EdgeChange<Edge>)[],
  ) {
    let next = current;
    for (const item of changes) {
      if (item.type !== "select" || item.selected === next.has(item.id))
        continue;
      const copy = new Set(next);
      if (item.selected) copy.add(item.id);
      else copy.delete(item.id);
      next = copy;
    }
    return next;
  }

  function onNodesChange(changes: NodeChange<FlowNode>[]) {
    setSelectedNodes((current) => applySelect(current, changes));
    if (changes.some((item) => item.type === "dimensions" && item.dimensions))
      setMeasured((current) => {
        const next = new Map(current);
        for (const item of changes)
          if (item.type === "dimensions" && item.dimensions)
            next.set(item.id, item.dimensions);
        return next;
      });
    // A drag moves nodes in the renderer and commits when it ends; a
    // keyboard move commits at once (a burst of arrows folds into one step).
    const moving = new Map<string, Point>();
    const moved = new Map<string, Point>();
    for (const item of changes) {
      if (item.type !== "position" || !item.position) continue;
      (item.dragging ? moving : moved).set(item.id, item.position);
    }
    if (moving.size) setDragging((current) => new Map([...current, ...moving]));
    if (moved.size) {
      setDragging((current) =>
        current.size
          ? new Map([...current].filter(([id]) => !moved.has(id)))
          : current,
      );
      const actual = [...moved].some(([id, at]) => {
        const node = workflow.nodes.find((item) => item.id === id);
        return node && (node.position.x !== at.x || node.position.y !== at.y);
      });
      if (actual)
        change(moveNodes(workflow, moved), `move:${[...moved.keys()].join()}`);
    }
  }

  function onEdgesChange(changes: EdgeChange<Edge>[]) {
    setSelectedEdges((current) => applySelect(current, changes));
  }

  const toConnection = (value: {
    source: string | null;
    target: string | null;
    sourceHandle?: string | null;
    targetHandle?: string | null;
  }): Connection | null =>
    value.source && value.target && value.sourceHandle && value.targetHandle
      ? {
          source: value.source,
          sourcePort: value.sourceHandle,
          target: value.target,
          targetPort: value.targetHandle,
        }
      : null;

  function join(connection: Connection) {
    const result = connect(
      workflow,
      kinds,
      connection,
      nextId("e", workflow.edges),
      rules,
    );
    if (result.refusal) {
      setMessage(REFUSALS[result.refusal]);
      return;
    }
    change(result.workflow);
    setMessage(
      `Connected ${nameOf(connection.source)} to ${nameOf(connection.target)}.`,
    );
  }

  function add(kind: string, position: Point) {
    const id = nextId("n", workflow.nodes);
    const next = addNode(workflow, kinds, kind, position, id);
    if (!next) {
      setMessage(`Only ${kinds.get(kind)?.max ?? 0} of those per workflow.`);
      return;
    }
    change(next);
    select(id);
    setMessage(`Added ${kinds.get(kind)?.label ?? kind}.`);
  }

  /** Adds in the middle of the view, nudged so repeated adds don't stack. */
  function addInView(kind: string) {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;
    const centre = flow.screenToFlowPosition({
      x: rect.left + rect.width / 2,
      y: rect.top + rect.height / 2,
    });
    const nudge = (workflow.nodes.length % 5) * 24;
    add(kind, {
      x: Math.round(centre.x - 110 + nudge),
      y: Math.round(centre.y - 48 + nudge),
    });
  }

  function remove(nodeIds: Iterable<string>, edgeIds: Iterable<string>) {
    const nodes = [...nodeIds];
    const edges = [...edgeIds];
    if (!nodes.length && !edges.length) return;
    change(removeNodes(removeEdges(workflow, edges), nodes));
    select(null);
    setMessage(
      nodes.length
        ? `Removed ${nodes.length === 1 ? "a step" : `${nodes.length} steps`}.`
        : `Removed ${edges.length === 1 ? "a connection" : `${edges.length} connections`}.`,
    );
  }

  function tidy() {
    change(moveNodes(workflow, autoLayout(workflow, measured)));
    setMessage("Tidied the layout.");
    // After the moved nodes render.
    requestAnimationFrame(() => flow.fitView({ padding: 0.2, duration: 300 }));
  }

  function step(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (readOnly || isTyping(event.target)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      step(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      step("redo");
    }
  }

  const selectedNode =
    selectedNodes.size === 1
      ? (workflow.nodes.find((node) => selectedNodes.has(node.id)) ?? null)
      : null;
  const selectedEdge =
    !selectedNode && selectedEdges.size === 1
      ? (workflow.edges.find((edge) => selectedEdges.has(edge.id)) ?? null)
      : null;

  return (
    <div className={cn(styles.editor, className)} onKeyDown={onKeyDown}>
      <div className={cn(styles.frame, readOnly && styles.readOnly)}>
        <div className={styles.toolbar} role="toolbar" aria-label="Workflow">
          {readOnly ? null : (
            <>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Undo"
                title="Undo (⌘Z)"
                disabled={!canUndo(history)}
                onClick={() => step("undo")}
              >
                <Undo2 aria-hidden="true" />
              </Button>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Redo"
                title="Redo (⇧⌘Z)"
                disabled={!canRedo(history)}
                onClick={() => step("redo")}
              >
                <Redo2 aria-hidden="true" />
              </Button>
              <span className={styles.divider} aria-hidden="true" />
              <Button
                size="sm"
                variant="quiet"
                disabled={workflow.nodes.length < 2}
                onClick={tidy}
              >
                <WandSparkles aria-hidden="true" />
                Tidy
              </Button>
            </>
          )}
          <Button
            size="sm"
            variant="quiet"
            disabled={workflow.nodes.length === 0}
            onClick={() => flow.fitView({ padding: 0.2, duration: 300 })}
          >
            <Maximize2 aria-hidden="true" />
            Fit
          </Button>
          <span className={styles.toolbarEnd}>
            {workflow.nodes.length ? (
              issues.length ? (
                <Badge tone="warn" dot>
                  {issues.length} to finish
                </Badge>
              ) : (
                <Badge tone="accent" dot>
                  Ready
                </Badge>
              )
            ) : null}
          </span>
        </div>

        {readOnly ? null : (
          <Palette kinds={kindList} workflow={workflow} onAdd={addInView} />
        )}

        <div
          ref={canvasRef}
          className={styles.canvas}
          role="region"
          aria-label={label}
          // The engine keeps a press on the pane or a node from moving focus,
          // so undo's keys would go to the page; the canvas takes it instead.
          tabIndex={-1}
          onPointerDownCapture={(event) => {
            const target = event.target as HTMLElement;
            if (!target.closest("button, input, select, textarea, a"))
              canvasRef.current?.focus({ preventScroll: true });
          }}
          onDragOver={(event) => {
            if (readOnly || !event.dataTransfer.types.includes(DRAG_TYPE))
              return;
            event.preventDefault();
            event.dataTransfer.dropEffect = "copy";
          }}
          onDrop={(event) => {
            const kind = event.dataTransfer.getData(DRAG_TYPE);
            if (readOnly || !kind) return;
            event.preventDefault();
            const at = flow.screenToFlowPosition({
              x: event.clientX,
              y: event.clientY,
            });
            // The pointer holds the node by its header, not its corner.
            add(kind, { x: Math.round(at.x - 24), y: Math.round(at.y - 20) });
          }}
        >
          <ReactFlow<FlowNode, Edge>
            nodes={flowNodes}
            edges={flowEdges}
            nodeTypes={nodeTypes}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={(value) => {
              const connection = toConnection(value);
              if (connection) join(connection);
            }}
            isValidConnection={(value) => {
              const connection = toConnection(value);
              return (
                !!connection &&
                !checkConnection(workflow, kinds, connection, rules)
              );
            }}
            onConnectEnd={(_, state) => {
              // A drop on a port that refused: say why.
              if (
                state.isValid !== false ||
                !state.fromHandle ||
                !state.toHandle
              )
                return;
              const [out, into] =
                state.fromHandle.type === "source"
                  ? [state.fromHandle, state.toHandle]
                  : [state.toHandle, state.fromHandle];
              const refusal = checkConnection(
                workflow,
                kinds,
                {
                  source: out.nodeId,
                  sourcePort: out.id ?? "",
                  target: into.nodeId,
                  targetPort: into.id ?? "",
                },
                rules,
              );
              if (refusal) setMessage(REFUSALS[refusal]);
            }}
            onDelete={({ nodes, edges }) =>
              remove(
                nodes.map((node) => node.id),
                edges.map((edge) => edge.id),
              )
            }
            onPaneClick={() => select(null)}
            nodesDraggable={!readOnly}
            nodesConnectable={!readOnly}
            nodesFocusable
            edgesFocusable
            elementsSelectable
            deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
            fitView
            fitViewOptions={{ padding: 0.2 }}
            minZoom={0.25}
            maxZoom={2}
            proOptions={{ hideAttribution: true }}
            ariaLabelConfig={{
              "node.a11yDescription.default": readOnly
                ? "Press Enter to select this step. Press Escape to clear the selection."
                : "Press Enter to select this step. Use the arrow keys to move it, and Delete to remove it.",
            }}
          >
            <Background gap={20} />
            <Controls showInteractive={false} showFitView={false} />
          </ReactFlow>
          {workflow.nodes.length === 0 ? (
            <div className={styles.empty}>{empty}</div>
          ) : null}
        </div>

        <Inspector
          workflow={workflow}
          kinds={kinds}
          rules={rules}
          issues={issues}
          selectedNode={selectedNode}
          selectedEdge={selectedEdge}
          readOnly={readOnly}
          onUpdate={(id, value, key) =>
            change(updateNode(workflow, id, value), key)
          }
          onConnect={join}
          onDisconnect={(id) => remove([], [id])}
          onDelete={(id) => remove([id], [])}
          onSelect={(id) => select(id)}
        />
      </div>
      <p className={styles.status} role="status">
        {message}
      </p>
    </div>
  );
}

components/workspaces/workflow-editor/workflow-node.tsx

"use client";

import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";

import { TriangleAlert } from "@/components/utility/icon";
import type { Issue, NodeKind } from "../_kernel/workflow";
import { KindIcon } from "./icons";
import styles from "./workflow-editor.module.css";

export type WorkflowNodeData = {
  spec: NodeKind | undefined;
  kind: string;
  name: string;
  issues: readonly Issue[];
  connectable: boolean;
};

export type FlowNode = Node<WorkflowNodeData, "step">;

/** One step on the canvas: what it is, and its ports, inputs on the left
 *  and outputs on the right, each a handle on the node's edge. */
export function WorkflowNode({ data, selected }: NodeProps<FlowNode>) {
  const { spec, name, issues, connectable } = data;
  const inputs = spec?.inputs ?? [];
  const outputs = spec?.outputs ?? [];
  const rows = Math.max(inputs.length, outputs.length);

  return (
    <div
      className={styles.node}
      data-tone={spec?.tone ?? "neutral"}
      data-selected={selected || undefined}
      data-invalid={issues.length > 0 || undefined}
    >
      <div className={styles.nodeHead}>
        <span className={styles.nodeIcon} aria-hidden="true">
          <KindIcon name={spec?.icon} />
        </span>
        <span className={styles.nodeTitle}>
          <span className={styles.nodeName}>{name}</span>
          <span className={styles.nodeKind}>
            {spec?.label ?? `Unknown: ${data.kind}`}
          </span>
        </span>
        {issues.length ? (
          <span
            className={styles.nodeIssue}
            title={issues.map((issue) => issue.message).join("\n")}
          >
            <TriangleAlert aria-hidden="true" />
          </span>
        ) : null}
      </div>
      {rows ? (
        <div className={styles.ports}>
          {Array.from({ length: rows }, (_, row) => {
            const input = inputs[row];
            const output = outputs[row];
            return (
              <div key={row} className={styles.portRow}>
                <span className={styles.portIn}>
                  {input ? (
                    <>
                      <Handle
                        type="target"
                        position={Position.Left}
                        id={input.id}
                        isConnectable={connectable}
                        className={styles.handle}
                        data-type={input.type}
                        title={`${input.label} · ${input.type}`}
                      />
                      {input.label}
                      {input.required ? (
                        <span className={styles.portRequired} aria-hidden>
                          *
                        </span>
                      ) : null}
                    </>
                  ) : null}
                </span>
                <span className={styles.portOut}>
                  {output ? (
                    <>
                      {output.label}
                      <Handle
                        type="source"
                        position={Position.Right}
                        id={output.id}
                        isConnectable={connectable}
                        className={styles.handle}
                        data-type={output.type}
                        title={`${output.label} · ${output.type}`}
                      />
                    </>
                  ) : null}
                </span>
              </div>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

components/workspaces/workflow-editor/inspector.tsx

"use client";

import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input, Textarea } from "@/components/forms/input";
import { NumberInput } from "@/components/forms/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/forms/select";
import { Switch } from "@/components/forms/switch";
import { ArrowRight, Trash2, Unlink } from "@/components/utility/icon";
import {
  compatibleTargets,
  nodeName,
  type FieldSpec,
  type Issue,
  type Kinds,
  type Rules,
  type Workflow,
  type WorkflowEdge,
  type WorkflowNode,
} from "../_kernel/workflow";
import styles from "./workflow-editor.module.css";

export type InspectorProps = {
  workflow: Workflow;
  kinds: Kinds;
  rules: Rules;
  issues: readonly Issue[];
  selectedNode: WorkflowNode | null;
  selectedEdge: WorkflowEdge | null;
  readOnly: boolean;
  /** A change to a setting or a name: `key` folds a burst of typing into
   *  one undo step. */
  onUpdate: (
    id: string,
    change: { label?: string; data?: Record<string, unknown> },
    key: string,
  ) => void;
  onConnect: (edge: Omit<WorkflowEdge, "id">) => void;
  onDisconnect: (edgeId: string) => void;
  onDelete: (nodeId: string) => void;
  onSelect: (nodeId: string | null) => void;
};

/** The selected step's name, settings and connections; with nothing
 *  selected, what keeps the workflow from being complete. */
export function Inspector(props: InspectorProps) {
  const { selectedNode, selectedEdge } = props;
  return (
    <aside className={styles.inspector} aria-label="Inspector">
      {selectedNode ? (
        <NodePanel {...props} node={selectedNode} />
      ) : selectedEdge ? (
        <EdgePanel {...props} edge={selectedEdge} />
      ) : (
        <Overview {...props} />
      )}
    </aside>
  );
}

const endName = (
  workflow: Workflow,
  kinds: Kinds,
  nodeId: string,
  portId: string,
  side: "inputs" | "outputs",
) => {
  const node = workflow.nodes.find((item) => item.id === nodeId);
  if (!node) return nodeId;
  const port = kinds.get(node.kind)?.[side].find((item) => item.id === portId);
  const ports = kinds.get(node.kind)?.[side].length ?? 0;
  // A port's name only helps when the step has more than one.
  return ports > 1 && port
    ? `${nodeName(node, kinds)} · ${port.label}`
    : nodeName(node, kinds);
};

function NodePanel({
  workflow,
  kinds,
  rules,
  issues,
  node,
  readOnly,
  onUpdate,
  onConnect,
  onDisconnect,
  onDelete,
}: InspectorProps & { node: WorkflowNode }) {
  const spec = kinds.get(node.kind);
  const mine = issues.filter((issue) => issue.nodeId === node.id);
  const errorFor = (field: string) =>
    mine.find((issue) => issue.field === field)?.message;

  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>{spec?.label ?? node.kind}</p>
        <h3 className={styles.panelTitle}>{nodeName(node, kinds)}</h3>
        {spec?.description ? (
          <p className={styles.panelNote}>{spec.description}</p>
        ) : null}
      </header>

      <Field
        label="Name"
        hint={`Shown on the canvas. Empty uses “${spec?.label ?? node.kind}”.`}
      >
        {(control) => (
          <Input
            {...control}
            size="sm"
            value={node.label ?? ""}
            placeholder={spec?.label}
            readOnly={readOnly}
            onChange={(event) =>
              onUpdate(
                node.id,
                { label: event.target.value },
                `label:${node.id}`,
              )
            }
          />
        )}
      </Field>

      {spec?.fields.map((field) => (
        <SettingField
          key={field.key}
          field={field}
          value={node.data[field.key]}
          error={errorFor(field.key)}
          readOnly={readOnly}
          onChange={(value) =>
            onUpdate(
              node.id,
              { data: { [field.key]: value } },
              `field:${node.id}:${field.key}`,
            )
          }
        />
      ))}

      {spec?.inputs.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Inputs</h4>
          {spec.inputs.map((port) => {
            const edges = workflow.edges.filter(
              (edge) => edge.target === node.id && edge.targetPort === port.id,
            );
            const issue = mine.find((item) => item.port === port.id);
            return (
              <div key={port.id} className={styles.port}>
                <p className={styles.portName}>
                  {port.label}
                  <span className={styles.portType}>{port.type}</span>
                </p>
                {edges.length ? (
                  <ul className={styles.links}>
                    {edges.map((edge) => (
                      <li key={edge.id} className={styles.link}>
                        <span>
                          From{" "}
                          {endName(
                            workflow,
                            kinds,
                            edge.source,
                            edge.sourcePort,
                            "outputs",
                          )}
                        </span>
                        {readOnly ? null : (
                          <Button
                            size="icon"
                            variant="quiet"
                            aria-label={`Disconnect from ${endName(workflow, kinds, edge.source, edge.sourcePort, "outputs")}`}
                            onClick={() => onDisconnect(edge.id)}
                          >
                            <Unlink aria-hidden="true" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={issue ? styles.linkMissing : styles.linkNone}>
                    {issue ? issue.message : "Not connected."}
                  </p>
                )}
              </div>
            );
          })}
        </section>
      ) : null}

      {spec?.outputs.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Outputs</h4>
          {spec.outputs.map((port) => {
            const edges = workflow.edges.filter(
              (edge) => edge.source === node.id && edge.sourcePort === port.id,
            );
            const targets = readOnly
              ? []
              : compatibleTargets(workflow, kinds, node.id, port.id, rules);
            return (
              <div key={port.id} className={styles.port}>
                <p className={styles.portName}>
                  {port.label}
                  <span className={styles.portType}>{port.type}</span>
                </p>
                {edges.length ? (
                  <ul className={styles.links}>
                    {edges.map((edge) => (
                      <li key={edge.id} className={styles.link}>
                        <span>
                          To{" "}
                          {endName(
                            workflow,
                            kinds,
                            edge.target,
                            edge.targetPort,
                            "inputs",
                          )}
                        </span>
                        {readOnly ? null : (
                          <Button
                            size="icon"
                            variant="quiet"
                            aria-label={`Disconnect from ${endName(workflow, kinds, edge.target, edge.targetPort, "inputs")}`}
                            onClick={() => onDisconnect(edge.id)}
                          >
                            <Unlink aria-hidden="true" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={styles.linkNone}>Not connected.</p>
                )}
                {readOnly ? null : (
                  // Keyed by the count, so it resets to its placeholder
                  // after each connection.
                  <Select
                    key={edges.length}
                    disabled={targets.length === 0}
                    onValueChange={(value) => {
                      const [target, targetPort] = value.split("\u0000");
                      onConnect({
                        source: node.id,
                        sourcePort: port.id,
                        target,
                        targetPort,
                      });
                    }}
                  >
                    <SelectTrigger
                      size="sm"
                      aria-label={`Connect ${port.label} to`}
                      placeholder={
                        targets.length ? "Connect to…" : "Nothing to connect to"
                      }
                    />
                    <SelectContent>
                      {targets.map((target) => (
                        <SelectItem
                          key={`${target.node.id}:${target.port.id}`}
                          value={`${target.node.id}\u0000${target.port.id}`}
                        >
                          {endName(
                            workflow,
                            kinds,
                            target.node.id,
                            target.port.id,
                            "inputs",
                          )}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                )}
              </div>
            );
          })}
        </section>
      ) : null}

      {readOnly ? null : (
        <Button
          variant="danger"
          size="sm"
          className={styles.panelAction}
          onClick={() => onDelete(node.id)}
        >
          <Trash2 aria-hidden="true" />
          Delete step
        </Button>
      )}
    </div>
  );
}

function SettingField({
  field,
  value,
  error,
  readOnly,
  onChange,
}: {
  field: FieldSpec;
  value: unknown;
  error: string | undefined;
  readOnly: boolean;
  onChange: (value: unknown) => void;
}) {
  return (
    <Field
      label={field.label}
      hint={field.hint}
      error={error}
      required={field.required}
    >
      {(control) => {
        switch (field.kind) {
          case "text":
            return field.multiline ? (
              <Textarea
                {...control}
                size="sm"
                rows={3}
                value={typeof value === "string" ? value : ""}
                placeholder={field.placeholder}
                readOnly={readOnly}
                onChange={(event) => onChange(event.target.value)}
              />
            ) : (
              <Input
                {...control}
                size="sm"
                value={typeof value === "string" ? value : ""}
                placeholder={field.placeholder}
                readOnly={readOnly}
                onChange={(event) => onChange(event.target.value)}
              />
            );
          case "number":
            return (
              <NumberInput
                {...control}
                size="sm"
                value={typeof value === "number" ? value : null}
                min={field.min}
                max={field.max}
                step={field.step}
                readOnly={readOnly}
                onValueChange={onChange}
              />
            );
          case "select":
            return (
              <Select
                value={typeof value === "string" ? value : ""}
                disabled={readOnly}
                onValueChange={onChange}
              >
                <SelectTrigger {...control} size="sm" placeholder="Choose…" />
                <SelectContent>
                  {field.options.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            );
          case "toggle":
            return (
              <Switch
                {...control}
                checked={value === true}
                disabled={readOnly}
                onCheckedChange={onChange}
              />
            );
        }
      }}
    </Field>
  );
}

function EdgePanel({
  workflow,
  kinds,
  edge,
  readOnly,
  onDisconnect,
  onSelect,
}: InspectorProps & { edge: WorkflowEdge }) {
  const from = endName(
    workflow,
    kinds,
    edge.source,
    edge.sourcePort,
    "outputs",
  );
  const to = endName(workflow, kinds, edge.target, edge.targetPort, "inputs");
  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>Connection</p>
        <h3 className={styles.panelTitle}>
          {from} <ArrowRight className={styles.inlineIcon} aria-label="to" />{" "}
          {to}
        </h3>
      </header>
      <div className={styles.panelButtons}>
        <Button size="sm" variant="quiet" onClick={() => onSelect(edge.source)}>
          Go to {from}
        </Button>
        <Button size="sm" variant="quiet" onClick={() => onSelect(edge.target)}>
          Go to {to}
        </Button>
      </div>
      {readOnly ? null : (
        <Button
          variant="danger"
          size="sm"
          className={styles.panelAction}
          onClick={() => onDisconnect(edge.id)}
        >
          <Unlink aria-hidden="true" />
          Remove connection
        </Button>
      )}
    </div>
  );
}

function Overview({
  workflow,
  kinds,
  issues,
  readOnly,
  onSelect,
}: InspectorProps) {
  const steps = workflow.nodes.length;
  const links = workflow.edges.length;
  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>Workflow</p>
        <h3 className={styles.panelTitle}>
          {steps} {steps === 1 ? "step" : "steps"} · {links}{" "}
          {links === 1 ? "connection" : "connections"}
        </h3>
      </header>
      {issues.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>
            {issues.length} {issues.length === 1 ? "thing" : "things"} to finish
          </h4>
          <ul className={styles.issues}>
            {issues.map((issue, index) => {
              const node = workflow.nodes.find(
                (item) => item.id === issue.nodeId,
              );
              return (
                <li key={index}>
                  <button
                    type="button"
                    className={styles.issue}
                    onClick={() => onSelect(issue.nodeId)}
                  >
                    <span className={styles.issueNode}>
                      {node ? nodeName(node, kinds) : issue.nodeId}
                    </span>
                    {issue.message}
                  </button>
                </li>
              );
            })}
          </ul>
        </section>
      ) : steps ? (
        <p className={styles.panelNote}>Every step is set up and connected.</p>
      ) : null}
      {readOnly ? null : (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Keys</h4>
          <dl className={styles.keys}>
            <dt>Tab, Enter</dt>
            <dd>Reach and select a step</dd>
            <dt>Arrows</dt>
            <dd>Move the selected step</dd>
            <dt>Delete</dt>
            <dd>Remove what is selected</dd>
            <dt>⌘Z, ⇧⌘Z</dt>
            <dd>Undo, redo</dd>
          </dl>
        </section>
      )}
    </div>
  );
}

components/workspaces/workflow-editor/palette.tsx

"use client";

import { useId } from "react";

import { count, type NodeKind, type Workflow } from "../_kernel/workflow";
import { KindIcon } from "./icons";
import styles from "./workflow-editor.module.css";

/** The drag type a palette item carries; the canvas accepts only it. */
export const DRAG_TYPE = "application/x-bento-step";

export type PaletteProps = {
  kinds: readonly NodeKind[];
  workflow: Workflow;
  /** Adds the kind in view: the keyboard's (and a click's) way in. */
  onAdd: (kind: string) => void;
};

/** Every kind of step, by category. Drag one onto the canvas, or press it
 *  to add it in the middle of the view. */
export function Palette({ kinds, workflow, onAdd }: PaletteProps) {
  const id = useId();
  const categories = [...new Set(kinds.map((kind) => kind.category))];
  return (
    <nav className={styles.palette} aria-label="Steps">
      {categories.map((category) => (
        <section key={category} className={styles.paletteGroup}>
          <h3 className={styles.paletteHeading}>{category}</h3>
          <ul className={styles.paletteList}>
            {kinds
              .filter((kind) => kind.category === category)
              .map((kind) => {
                const full =
                  kind.max !== undefined &&
                  count(workflow, kind.kind) >= kind.max;
                return (
                  <li key={kind.kind}>
                    <button
                      type="button"
                      className={styles.paletteItem}
                      data-tone={kind.tone ?? "neutral"}
                      draggable={!full}
                      disabled={full}
                      onDragStart={(event) => {
                        event.dataTransfer.setData(DRAG_TYPE, kind.kind);
                        event.dataTransfer.effectAllowed = "copy";
                      }}
                      onClick={() => onAdd(kind.kind)}
                      aria-describedby={`${id}-${kind.kind}`}
                    >
                      <span className={styles.nodeIcon} aria-hidden="true">
                        <KindIcon name={kind.icon} />
                      </span>
                      <span className={styles.paletteText}>
                        <span className={styles.paletteLabel}>
                          {kind.label}
                        </span>
                        <span
                          id={`${id}-${kind.kind}`}
                          className={styles.paletteNote}
                          // Read as the description, not as part of the name.
                          aria-hidden="true"
                        >
                          {full
                            ? kind.max === 1
                              ? "Only one per workflow"
                              : `At most ${kind.max} per workflow`
                            : kind.description}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
          </ul>
        </section>
      ))}
    </nav>
  );
}

components/workspaces/workflow-editor/workflow-editor.module.css

/* Unlayered on purpose: the engine's stylesheet is unlayered, and an
   override in a layer would lose to it whatever its specificity. Every
   selector here is scoped to this module's classes. */

.editor {
  container-type: inline-size;
  min-inline-size: 0;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-3);
  background: var(--surface-panel);
  color: var(--ink);
  overflow: hidden;
}
/* The grid sits inside the container, so the container query can change it. */
.frame {
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr) 288px;
  grid-template-areas:
    "toolbar toolbar toolbar"
    "palette canvas inspector";
}
.readOnly {
  grid-template-columns: minmax(0, 1fr) 288px;
  grid-template-areas:
    "toolbar toolbar"
    "canvas inspector";
}

/* ── Toolbar ── */
.toolbar {
  grid-area: toolbar;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--line);
}
.toolbar svg {
  inline-size: 15px;
  block-size: 15px;
}
.divider {
  inline-size: 1px;
  block-size: 18px;
  margin-inline: var(--space-1);
  background: var(--line);
}
.toolbarEnd {
  margin-inline-start: auto;
}

/* ── Palette ── */
.palette {
  grid-area: palette;
  display: grid;
  align-content: start;
  gap: var(--space-6);
  max-block-size: 600px;
  padding: var(--space-5) var(--space-4);
  border-right: 1px solid var(--line);
  overflow-y: auto;
}
.paletteGroup {
  display: grid;
  align-content: start;
  gap: var(--space-2);
}
.paletteHeading {
  margin: 0;
  padding-inline: var(--space-2);
  color: var(--ink-3);
  font-size: var(--text-11);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.paletteList {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.paletteItem {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: var(--space-3);
  inline-size: 100%;
  padding: var(--space-2);
  border: 1px solid transparent;
  border-radius: var(--radius-2);
  background: none;
  color: var(--ink);
  font: inherit;
  text-align: start;
  cursor: grab;
}
.paletteItem:hover:not(:disabled) {
  border-color: var(--line);
  background: var(--surface-hover);
}
.paletteItem:active:not(:disabled) {
  cursor: grabbing;
}
.paletteItem:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.paletteItem:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}
.paletteText {
  display: grid;
  gap: 1px;
  min-inline-size: 0;
}
.paletteLabel {
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
}
.paletteNote {
  color: var(--ink-3);
  font-size: var(--text-12);
  line-height: var(--leading-snug);
}

/* ── Canvas ── */
.canvas {
  grid-area: canvas;
  position: relative;
  block-size: 600px;
  min-inline-size: 0;
  background: var(--surface-sunk);
  --xy-background-color: var(--surface-sunk);
  --xy-background-pattern-color: var(--line-strong);
  --xy-edge-stroke: var(--ink-3);
  --xy-edge-stroke-width: 1.5;
  --xy-edge-stroke-selected: var(--accent);
  --xy-connectionline-stroke: var(--accent);
  --xy-connectionline-stroke-width: 1.5;
  --xy-handle-background-color: var(--surface-panel);
  --xy-handle-border-color: var(--ink-3);
  --xy-node-border-radius: var(--radius-3);
  --xy-controls-button-background-color: var(--surface-panel);
  --xy-controls-button-background-color-hover: var(--surface-raised);
  --xy-controls-button-color: var(--ink);
  --xy-controls-button-color-hover: var(--ink);
  --xy-controls-button-border-color: var(--line-strong);
  --xy-controls-box-shadow: none;
  --xy-selection-background-color: var(--accent-tint);
  --xy-selection-border: 1px dashed var(--accent);
}
/* Focus lands here after a press on the pane, so keys reach the editor;
   it is not a stop in the tab order, so it shows no ring. */
.canvas:focus {
  outline: none;
}
/* The image reset caps an SVG's width at its parent. Edge SVGs overflow a
   zero-width container on purpose, so the cap stops them painting. */
.canvas :global(.react-flow__edges svg) {
  max-inline-size: none;
}
.canvas :global(.react-flow__node) {
  border-radius: var(--radius-3);
}
.canvas :global(.react-flow__node:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.canvas :global(.react-flow__edge:focus-visible .react-flow__edge-path) {
  stroke: var(--accent);
  stroke-width: 3;
}
.canvas :global(.react-flow__controls) {
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-2);
  overflow: hidden;
}
.canvas :global(.react-flow__controls-button) {
  inline-size: 30px;
  block-size: 30px;
}
.canvas :global(.react-flow__controls-button:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: var(--space-7);
  color: var(--ink-3);
  font-size: var(--text-13);
  text-align: center;
  pointer-events: none;
}

/* ── A step ── */
.node {
  min-inline-size: 200px;
  max-inline-size: 260px;
  border: 1px solid var(--line-heavy);
  border-radius: var(--radius-3);
  background: var(--surface-panel);
  color: var(--ink);
  font-size: var(--text-12);
  box-shadow: 0 1px 2px var(--alpha-b-08);
}
.node[data-selected] {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}
.node[data-invalid]:not([data-selected]) {
  border-color: var(--warn-line);
}
.nodeHead {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
}
.nodeIcon {
  display: grid;
  place-items: center;
  inline-size: 26px;
  block-size: 26px;
  border-radius: var(--radius-2);
  background: var(--surface-sunk);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
}
.nodeIcon svg {
  inline-size: 14px;
  block-size: 14px;
}
[data-tone] .nodeIcon {
  box-shadow: none;
}
[data-tone="neutral"] .nodeIcon {
  box-shadow: inset 0 0 0 1px var(--line);
}
[data-tone="accent"] .nodeIcon {
  background: var(--accent-tint);
  color: var(--accent);
}
[data-tone="info"] .nodeIcon {
  background: var(--info-tint);
  color: var(--info);
}
[data-tone="warn"] .nodeIcon {
  background: var(--warn-tint);
  color: var(--warn);
}
[data-tone="crit"] .nodeIcon {
  background: var(--crit-tint);
  color: var(--crit);
}
.nodeTitle {
  display: grid;
  min-inline-size: 0;
}
.nodeName {
  overflow: hidden;
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nodeKind {
  color: var(--ink-3);
  font-size: var(--text-11);
}
.nodeIssue {
  display: grid;
  color: var(--warn);
}
.nodeIssue svg {
  inline-size: 14px;
  block-size: 14px;
}
.ports {
  display: grid;
  gap: var(--space-1);
  padding-block: var(--space-2);
  border-top: 1px solid var(--line);
}
.portRow {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: center;
  min-block-size: 20px;
}
.portIn,
.portOut {
  position: relative;
  display: flex;
  align-items: center;
  gap: 2px;
  block-size: 100%;
  color: var(--ink-2);
  white-space: nowrap;
}
.portIn {
  padding-inline-start: var(--space-4);
}
.portOut {
  justify-content: flex-end;
  padding-inline-end: var(--space-4);
}
/* Each handle sits on the node's edge, level with its label. */
.portOut .handle {
  right: 0;
}
.portIn .handle,
.portOut .handle {
  position: absolute;
  inline-size: 10px;
  block-size: 10px;
  border-width: 1.5px;
}
.portIn .handle {
  left: 0;
}
.handle[data-type="any"] {
  border-style: dashed;
}
.handle:global(.connectingto),
.handle:global(.connectionindicator):hover {
  border-color: var(--accent);
}
.handle:global(.valid) {
  background: var(--accent);
  border-color: var(--accent);
}
.portRequired {
  color: var(--ink-3);
}

/* ── Inspector ── */
.inspector {
  grid-area: inspector;
  max-block-size: 600px;
  border-left: 1px solid var(--line);
  overflow-y: auto;
}
.panel {
  display: grid;
  align-content: start;
  gap: var(--space-5);
  padding: var(--space-5);
}
.panelHead {
  display: grid;
  gap: var(--space-1);
}
.panelEyebrow {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-11);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.panelTitle {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-15);
  font-weight: var(--weight-medium);
}
.inlineIcon {
  inline-size: 14px;
  block-size: 14px;
  color: var(--ink-3);
}
.panelNote {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-12);
  line-height: var(--leading-snug);
}
.panelSection {
  display: grid;
  gap: var(--space-3);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line);
}
.panelHeading {
  margin: 0;
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
}
.panelButtons {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}
.panelAction {
  justify-self: start;
}
.port {
  display: grid;
  gap: var(--space-2);
}
.portName {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-12);
  font-weight: var(--weight-medium);
}
.portType {
  color: var(--ink-3);
  font-family: var(--font-mono);
  font-size: var(--text-11);
  font-weight: var(--weight-regular);
}
.links {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding-inline-start: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-2);
  color: var(--ink-2);
  font-size: var(--text-12);
}
.link svg {
  inline-size: 14px;
  block-size: 14px;
}
.linkNone,
.linkMissing {
  margin: 0;
  font-size: var(--text-12);
}
.linkNone {
  color: var(--ink-3);
}
.linkMissing {
  color: var(--warn);
}
.issues {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.issue {
  display: grid;
  gap: 1px;
  inline-size: 100%;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-left: 2px solid var(--warn);
  border-radius: var(--radius-2);
  background: none;
  color: var(--ink-2);
  font: inherit;
  font-size: var(--text-12);
  text-align: start;
  cursor: pointer;
}
.issue:hover {
  background: var(--surface-hover);
}
.issue:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.issueNode {
  color: var(--ink);
  font-weight: var(--weight-medium);
}
.keys {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  font-size: var(--text-12);
}
.keys dt {
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: var(--text-11);
}
.keys dd {
  margin: 0;
  color: var(--ink-3);
}

/* Announced, not shown: the canvas itself shows what changed. */
.status {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Medium: the inspector moves below the palette and canvas. */
@container (max-width: 1040px) {
  .frame {
    grid-template-columns: 200px minmax(0, 1fr);
    grid-template-areas:
      "toolbar toolbar"
      "palette canvas"
      "inspector inspector";
  }
  .readOnly {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "toolbar"
      "canvas"
      "inspector";
  }
  .inspector {
    max-block-size: none;
    border-left: 0;
    border-top: 1px solid var(--line);
  }
}

/* Narrow: the canvas first, then the palette as a strip, then the
   inspector. */
@container (max-width: 640px) {
  .frame,
  .readOnly {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "toolbar"
      "canvas"
      "palette"
      "inspector";
  }
  .canvas {
    block-size: 440px;
  }
  .palette {
    grid-auto-flow: column;
    grid-auto-columns: minmax(200px, 1fr);
    max-block-size: none;
    overflow-x: auto;
    border-right: 0;
    border-top: 1px solid var(--line);
  }
  .inspector {
    max-block-size: none;
    border-left: 0;
    border-top: 1px solid var(--line);
  }
}

components/workspaces/_kernel/workflow.ts

/* A workflow: nodes of declared kinds, joined port to port. Plain data and
   pure functions, framework-free and identical in both apps; the canvas,
   palette and inspector only render it and call these. Every change returns
   a new workflow, so history can keep the old one. */

/** What a port carries. "any" connects to every type. */
export type PortType = string;

export type PortSpec = Readonly<{
  id: string;
  label: string;
  type: PortType;
  /** An input needs a connection before the workflow is complete. */
  required?: boolean;
  /** How many connections the port takes: inputs one by default, outputs
   *  many. */
  multiple?: boolean;
}>;

type FieldBase = Readonly<{
  key: string;
  label: string;
  hint?: string;
  required?: boolean;
}>;

/** A setting on a node, edited in the inspector. */
export type FieldSpec =
  | (FieldBase &
      Readonly<{ kind: "text"; placeholder?: string; multiline?: boolean }>)
  | (FieldBase &
      Readonly<{ kind: "number"; min?: number; max?: number; step?: number }>)
  | (FieldBase &
      Readonly<{
        kind: "select";
        options: readonly Readonly<{ value: string; label: string }>[];
      }>)
  | (FieldBase & Readonly<{ kind: "toggle" }>);

/** One kind of node the palette offers: its ports and its settings. */
export type NodeKind = Readonly<{
  kind: string;
  label: string;
  /** Groups the palette: "Triggers", "Logic", "Actions"… */
  category: string;
  description?: string;
  /** A name from the icon set, drawn by the renderer. */
  icon?: string;
  tone?: "accent" | "info" | "warn" | "crit" | "neutral";
  inputs: readonly PortSpec[];
  outputs: readonly PortSpec[];
  fields: readonly FieldSpec[];
  defaults?: Readonly<Record<string, unknown>>;
  /** At most this many in one workflow (say, one trigger). */
  max?: number;
}>;

export type Point = Readonly<{ x: number; y: number }>;

export type WorkflowNode = Readonly<{
  id: string;
  kind: string;
  position: Point;
  /** A name the author gave it; the kind's label otherwise. */
  label?: string;
  data: Readonly<Record<string, unknown>>;
}>;

export type WorkflowEdge = Readonly<{
  id: string;
  source: string;
  sourcePort: string;
  target: string;
  targetPort: string;
}>;

export type Workflow = Readonly<{
  nodes: readonly WorkflowNode[];
  edges: readonly WorkflowEdge[];
}>;

export type Connection = Omit<WorkflowEdge, "id">;

export type Rules = Readonly<{
  /** Allow an edge that closes a loop. Off by default: most workflows
   *  run once, start to end. */
  allowCycles?: boolean;
}>;

export const EMPTY: Workflow = { nodes: [], edges: [] };

export type Kinds = ReadonlyMap<string, NodeKind>;

export const kindMap = (kinds: readonly NodeKind[]): Kinds =>
  new Map(kinds.map((kind) => [kind.kind, kind]));

/** A node's name as a person reads it. */
export const nodeName = (node: WorkflowNode, kinds: Kinds) =>
  node.label?.trim() || kinds.get(node.kind)?.label || node.kind;

// ── Changes ──────────────────────────────────────────────────────────────

/** Adds a node of `kind` at `position`, with the kind's defaults. Null when
 *  the kind is unknown or already at its maximum. */
export function addNode(
  workflow: Workflow,
  kinds: Kinds,
  kind: string,
  position: Point,
  id: string,
): Workflow | null {
  const spec = kinds.get(kind);
  if (!spec) return null;
  if (spec.max !== undefined && count(workflow, kind) >= spec.max) return null;
  return {
    ...workflow,
    nodes: [
      ...workflow.nodes,
      { id, kind, position, data: { ...(spec.defaults ?? {}) } },
    ],
  };
}

/** How many nodes of `kind` the workflow has. */
export const count = (workflow: Workflow, kind: string) =>
  workflow.nodes.filter((node) => node.kind === kind).length;

/** Removes nodes, and every edge touching them. */
export function removeNodes(
  workflow: Workflow,
  ids: Iterable<string>,
): Workflow {
  const gone = new Set(ids);
  return {
    nodes: workflow.nodes.filter((node) => !gone.has(node.id)),
    edges: workflow.edges.filter(
      (edge) => !gone.has(edge.source) && !gone.has(edge.target),
    ),
  };
}

export function removeEdges(
  workflow: Workflow,
  ids: Iterable<string>,
): Workflow {
  const gone = new Set(ids);
  return {
    ...workflow,
    edges: workflow.edges.filter((edge) => !gone.has(edge.id)),
  };
}

/** Moves nodes to new positions; unknown ids are ignored. */
export function moveNodes(
  workflow: Workflow,
  positions: ReadonlyMap<string, Point>,
): Workflow {
  return {
    ...workflow,
    nodes: workflow.nodes.map((node) => {
      const to = positions.get(node.id);
      return to ? { ...node, position: { x: to.x, y: to.y } } : node;
    }),
  };
}

/** Changes a node's name or settings. */
export function updateNode(
  workflow: Workflow,
  id: string,
  change: Readonly<{
    label?: string;
    data?: Readonly<Record<string, unknown>>;
  }>,
): Workflow {
  return {
    ...workflow,
    nodes: workflow.nodes.map((node) =>
      node.id === id
        ? {
            ...node,
            ...(change.label !== undefined ? { label: change.label } : {}),
            ...(change.data ? { data: { ...node.data, ...change.data } } : {}),
          }
        : node,
    ),
  };
}

// ── Connections ──────────────────────────────────────────────────────────

/** Why a connection is refused. */
export type Refusal =
  | "same_node"
  | "unknown_port"
  | "type_mismatch"
  | "input_taken"
  | "output_taken"
  | "duplicate"
  | "cycle";

export const REFUSALS: Readonly<Record<Refusal, string>> = {
  same_node: "A node cannot connect to itself.",
  unknown_port: "That port does not exist.",
  type_mismatch: "Those ports carry different kinds of data.",
  input_taken: "That input already has a connection.",
  output_taken: "That output already has a connection.",
  duplicate: "Those ports are already connected.",
  cycle: "That connection would make a loop.",
};

const portOf = (
  workflow: Workflow,
  kinds: Kinds,
  nodeId: string,
  portId: string,
  side: "inputs" | "outputs",
) => {
  const node = workflow.nodes.find((item) => item.id === nodeId);
  const spec = node ? kinds.get(node.kind) : undefined;
  return spec?.[side].find((port) => port.id === portId);
};

const typesMatch = (a: PortType, b: PortType) =>
  a === "any" || b === "any" || a === b;

/** Whether `to` can be reached from `from` along edges. */
function reaches(workflow: Workflow, from: string, to: string) {
  const next = new Map<string, string[]>();
  for (const edge of workflow.edges)
    next.set(edge.source, [...(next.get(edge.source) ?? []), edge.target]);
  const seen = new Set<string>();
  const stack = [from];
  while (stack.length) {
    const id = stack.pop()!;
    if (id === to) return true;
    if (seen.has(id)) continue;
    seen.add(id);
    stack.push(...(next.get(id) ?? []));
  }
  return false;
}

/** Why `connection` would be refused, or null when it is allowed. Always
 *  from an output to an input. */
export function checkConnection(
  workflow: Workflow,
  kinds: Kinds,
  connection: Connection,
  rules: Rules = {},
): Refusal | null {
  const { source, sourcePort, target, targetPort } = connection;
  if (source === target) return "same_node";
  const out = portOf(workflow, kinds, source, sourcePort, "outputs");
  const into = portOf(workflow, kinds, target, targetPort, "inputs");
  if (!out || !into) return "unknown_port";
  if (!typesMatch(out.type, into.type)) return "type_mismatch";
  const same = (edge: WorkflowEdge) =>
    edge.source === source &&
    edge.sourcePort === sourcePort &&
    edge.target === target &&
    edge.targetPort === targetPort;
  if (workflow.edges.some(same)) return "duplicate";
  if (
    !into.multiple &&
    workflow.edges.some(
      (edge) => edge.target === target && edge.targetPort === targetPort,
    )
  )
    return "input_taken";
  if (
    out.multiple === false &&
    workflow.edges.some(
      (edge) => edge.source === source && edge.sourcePort === sourcePort,
    )
  )
    return "output_taken";
  if (!rules.allowCycles && reaches(workflow, target, source)) return "cycle";
  return null;
}

/** Joins two ports, or says why not. */
export function connect(
  workflow: Workflow,
  kinds: Kinds,
  connection: Connection,
  id: string,
  rules: Rules = {},
):
  { workflow: Workflow; refusal: null } | { workflow: null; refusal: Refusal } {
  const refusal = checkConnection(workflow, kinds, connection, rules);
  return refusal
    ? { workflow: null, refusal }
    : {
        workflow: {
          ...workflow,
          edges: [...workflow.edges, { id, ...connection }],
        },
        refusal: null,
      };
}

/** Every input an output could connect to now: the keyboard's way to draw
 *  an edge, and the canvas's way to highlight valid drops. */
export function compatibleTargets(
  workflow: Workflow,
  kinds: Kinds,
  source: string,
  sourcePort: string,
  rules: Rules = {},
) {
  const targets: { node: WorkflowNode; port: PortSpec }[] = [];
  for (const node of workflow.nodes) {
    for (const port of kinds.get(node.kind)?.inputs ?? []) {
      const connection = {
        source,
        sourcePort,
        target: node.id,
        targetPort: port.id,
      };
      if (!checkConnection(workflow, kinds, connection, rules))
        targets.push({ node, port });
    }
  }
  return targets;
}

// ── Checking ─────────────────────────────────────────────────────────────

export type Issue = Readonly<{
  nodeId: string;
  /** The field or input the issue is about, when it is one. */
  field?: string;
  port?: string;
  message: string;
}>;

const empty = (value: unknown) =>
  value === undefined ||
  value === null ||
  (typeof value === "string" && value.trim() === "");

/** What keeps the workflow from being complete: required settings left
 *  empty, required inputs left unconnected, kinds this build does not know. */
export function issues(workflow: Workflow, kinds: Kinds): Issue[] {
  const found: Issue[] = [];
  for (const node of workflow.nodes) {
    const spec = kinds.get(node.kind);
    if (!spec) {
      found.push({
        nodeId: node.id,
        message: `Unknown kind of node: ${node.kind}.`,
      });
      continue;
    }
    for (const field of spec.fields)
      if (field.required && empty(node.data[field.key]))
        found.push({
          nodeId: node.id,
          field: field.key,
          message: `${field.label} is required.`,
        });
    for (const port of spec.inputs)
      if (
        port.required &&
        !workflow.edges.some(
          (edge) => edge.target === node.id && edge.targetPort === port.id,
        )
      )
        found.push({
          nodeId: node.id,
          port: port.id,
          message: `${port.label} needs a connection.`,
        });
  }
  return found;
}

components/workspaces/_kernel/history.ts

/* Undo and redo for any value kept whole: each change is a new value, and
   the history keeps the ones before it. A burst of the same change (one
   drag, one field being typed in) is kept as one step. */

export type History<T> = Readonly<{
  past: readonly T[];
  present: T;
  future: readonly T[];
  /** What the last step was, and when: a same-key change soon after is
   *  folded into it. */
  last: Readonly<{ key: string; at: number }> | null;
}>;

/** Steps kept; the oldest go first. */
export const LIMIT = 100;
/** How close in time a same-key change must be to fold into the last. */
export const WINDOW_MS = 800;

export const start = <T>(present: T): History<T> => ({
  past: [],
  present,
  future: [],
  last: null,
});

/** Records `next` as the present. With a `key` (say, "move:n1"), a change
 *  within the window after one with the same key replaces it rather than
 *  adding a step. A change drops anything that was undone. */
export function commit<T>(
  history: History<T>,
  next: T,
  key: string | null = null,
  now = Date.now(),
): History<T> {
  if (next === history.present) return history;
  const fold =
    key !== null &&
    history.last?.key === key &&
    now - history.last.at <= WINDOW_MS &&
    history.past.length > 0;
  return {
    past: fold
      ? history.past
      : [...history.past, history.present].slice(-LIMIT),
    present: next,
    future: [],
    last: key === null ? null : { key, at: now },
  };
}

export const canUndo = <T>(history: History<T>) => history.past.length > 0;
export const canRedo = <T>(history: History<T>) => history.future.length > 0;

export function undo<T>(history: History<T>): History<T> {
  if (!canUndo(history)) return history;
  return {
    past: history.past.slice(0, -1),
    present: history.past[history.past.length - 1],
    future: [history.present, ...history.future],
    last: null,
  };
}

export function redo<T>(history: History<T>): History<T> {
  if (!canRedo(history)) return history;
  return {
    past: [...history.past, history.present],
    present: history.future[0],
    future: history.future.slice(1),
    last: null,
  };
}

components/workspaces/_kernel/layout.ts

import { longestPath } from "../../charts/_kernel/graph";
import type { Point, Workflow } from "./workflow";

/* Tidy: lays a workflow out left to right. Each node goes in the column of
   its longest path from a start (loops broken first, as for the flow chart);
   within a column, nodes are ordered to sit near what they connect to, which
   uncrosses most edges; columns are centred on each other. */

export type Size = Readonly<{ width: number; height: number }>;

export type LayoutOptions = Readonly<{
  /** Space between columns, and between nodes in a column. */
  gapX?: number;
  gapY?: number;
  origin?: Point;
  /** A node without a measured size is taken to be this big. */
  fallback?: Size;
}>;

/** Ordering passes, alternating down and up the columns. */
const SWEEPS = 4;

export function autoLayout(
  workflow: Workflow,
  sizes: ReadonlyMap<string, Size>,
  options: LayoutOptions = {},
): Map<string, Point> {
  const { gapX = 80, gapY = 32, origin = { x: 0, y: 0 } } = options;
  const fallback = options.fallback ?? { width: 220, height: 96 };
  const size = (id: string) => sizes.get(id) ?? fallback;
  const nodes = workflow.nodes;
  if (nodes.length === 0) return new Map();

  const depth = longestPath(
    nodes,
    workflow.edges.map((edge) => ({ from: edge.source, to: edge.target })),
  );
  const columns: string[][] = [];
  // Start from the current top-to-bottom order: tidy stays close to it.
  const byY = [...nodes].sort(
    (a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
  );
  for (const node of byY)
    (columns[depth.get(node.id) ?? 0] ??= []).push(node.id);
  const filled = columns.filter((column) => column && column.length);

  const before = new Map<string, string[]>();
  const after = new Map<string, string[]>();
  for (const edge of workflow.edges) {
    after.set(edge.source, [...(after.get(edge.source) ?? []), edge.target]);
    before.set(edge.target, [...(before.get(edge.target) ?? []), edge.source]);
  }
  const index = new Map<string, number>();
  const reindex = () =>
    filled.forEach((column) => column.forEach((id, i) => index.set(id, i)));
  reindex();
  // Each node's place in its column: the average place of its neighbours in
  // the column before (sweeping down) or after (sweeping up).
  const order = (column: string[], neighbours: Map<string, string[]>) => {
    const weight = (id: string) => {
      const near = (neighbours.get(id) ?? []).filter((n) => index.has(n));
      return near.length
        ? near.reduce((sum, n) => sum + index.get(n)!, 0) / near.length
        : index.get(id)!;
    };
    const weights = new Map(column.map((id) => [id, weight(id)]));
    column.sort(
      (a, b) =>
        weights.get(a)! - weights.get(b)! || index.get(a)! - index.get(b)!,
    );
    column.forEach((id, i) => index.set(id, i));
  };
  for (let pass = 0; pass < SWEEPS; pass += 1) {
    if (pass % 2 === 0)
      for (let c = 1; c < filled.length; c += 1) order(filled[c], before);
    else
      for (let c = filled.length - 2; c >= 0; c -= 1) order(filled[c], after);
  }

  const heights = filled.map(
    (column) =>
      column.reduce((sum, id) => sum + size(id).height, 0) +
      gapY * (column.length - 1),
  );
  const tallest = Math.max(...heights);
  const positions = new Map<string, Point>();
  let x = origin.x;
  filled.forEach((column, c) => {
    const width = Math.max(...column.map((id) => size(id).width));
    let y = origin.y + (tallest - heights[c]) / 2;
    for (const id of column) {
      positions.set(id, { x: Math.round(x), y: Math.round(y) });
      y += size(id).height + gapY;
    }
    x += width + gapX;
  });
  return positions;
}

Empty

the first thing a new workflow shows

Drag a step here from the palette, or choose one to add it.

Sourcecomponents/workspaces/workflow-editor/doc.ts · components/workspaces/workflow-editor/workflow-editor.tsx · components/workspaces/workflow-editor/workflow-node.tsx · components/workspaces/workflow-editor/inspector.tsx · components/workspaces/workflow-editor/palette.tsx · components/workspaces/workflow-editor/workflow-editor.module.css · components/workspaces/_kernel/workflow.ts · components/workspaces/_kernel/history.ts · components/workspaces/_kernel/layout.ts

components/workspaces/workflow-editor/doc.ts

/**
 * WorkflowEditor — build a workflow by placing steps and joining their ports.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the canvas
 *     kinds          REQUIRED   { kind, label, category, description?, icon?,
 *                               tone?, inputs, outputs, fields, defaults?,
 *                               max? }[]
 *                      ports    { id, label, type, required?, multiple? }
 *                      fields   text | number | select | toggle, each with
 *                               key, label, hint?, required?
 *     defaultValue?  { nodes, edges }: where editing starts (empty)
 *     onChange?      every committed change, to persist
 *     rules?         { allowCycles? } (loops refused by default)
 *     readOnly?      look, select and inspect; nothing changes
 *     empty?         what an empty canvas says
 *
 * # Behaviour
 *
 * R1  Three panes: a palette of kinds by category, the canvas, and an
 *     inspector. A toolbar holds undo, redo, tidy and fit, and says whether
 *     the workflow is complete.
 * R2  A kind is added by dragging it onto the canvas (it lands under the
 *     pointer) or by pressing it (it lands in the middle of the view). A
 *     kind at its `max` is disabled, and says why.
 * R3  A connection runs from an output to an input. One is refused — the
 *     drop does nothing and the reason is announced — when it joins a node
 *     to itself, the port types differ ("any" matches every type), the
 *     input is taken (unless `multiple`), it exists already, or it would
 *     close a loop (unless `allowCycles`). While dragging, only ports that
 *     would accept are lit.
 * R4  Everything the pointer does has a keyboard way: Tab reaches steps and
 *     connections, Enter selects, arrows move the selected step, Delete or
 *     Backspace removes the selection, and the inspector connects an output
 *     to any input that would accept it ("Connect to…") and disconnects.
 * R5  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change. A
 *     burst of one change — a drag, arrow presses on one step, typing in
 *     one field — is one step. Keys in a text field keep their own undo.
 * R6  Tidy lays steps out left to right by longest path from a start, orders
 *     each column to uncross edges, and fits the view. It is one undo step.
 * R7  The inspector shows the selected step's name and settings (with each
 *     missing required value as the field's error) and its connections;
 *     the selected connection, with a way to remove it; or, with nothing
 *     selected, a list of what keeps the workflow from being complete,
 *     each item selecting its step.
 * R8  A step with an issue is marked on the canvas, and its accessible name
 *     says it needs attention. Every change is announced politely.
 * R9  onChange receives committed changes only: not the starting value, not
 *     positions mid-drag, not selection.
 * R10 Below about 860px wide the panes stack: canvas, palette strip,
 *     inspector.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The workflow, its rules, history and tidy are the framework-free kernel
 * in ../_kernel (identical in both apps, tested there); this renderer only
 * draws it and calls it. The canvas is xyflow, fully controlled: positions
 * mid-drag, selection and measured sizes live in renderer state, and each
 * node is passed its measured size back — without it the engine loses its
 * handle bounds on every update and the edges vanish. The engine's CSS is
 * unlayered, so this module is too, and themes it through the --xy-*
 * variables; the image reset's max-width cap is lifted from edge SVGs.
 */
export {};

components/workspaces/workflow-editor/workflow-editor.tsx

"use client";

import {
  Background,
  Controls,
  ReactFlow,
  ReactFlowProvider,
  useReactFlow,
  type Edge,
  type EdgeChange,
  type NodeChange,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { Badge } from "@/components/display/badge";
import { Button } from "@/components/forms/button";
import {
  Maximize2,
  Redo2,
  Undo2,
  WandSparkles,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import { autoLayout, type Size } from "../_kernel/layout";
import {
  addNode,
  checkConnection,
  connect,
  EMPTY,
  issues as findIssues,
  kindMap,
  moveNodes,
  nodeName,
  REFUSALS,
  removeEdges,
  removeNodes,
  updateNode,
  type Connection,
  type NodeKind,
  type Point,
  type Rules,
  type Workflow,
} from "../_kernel/workflow";
import { Inspector } from "./inspector";
import { DRAG_TYPE, Palette } from "./palette";
import { WorkflowNode, type FlowNode } from "./workflow-node";
import styles from "./workflow-editor.module.css";

export type WorkflowEditorProps = {
  /** Names the canvas for assistive technology. */
  label: string;
  /** The kinds of step the palette offers. */
  kinds: readonly NodeKind[];
  /** Where editing starts. The editor keeps its own history from here. */
  defaultValue?: Workflow;
  /** Every committed change: a drop, a connection, a finished drag, a
   *  setting. Persist here. */
  onChange?: (workflow: Workflow) => void;
  rules?: Rules;
  /** Look, select and inspect; no palette, no changes. */
  readOnly?: boolean;
  /** Shown on an empty canvas. */
  empty?: ReactNode;
  className?: string;
};

const nodeTypes = { step: WorkflowNode };

/** Builds workflows by dragging steps from a palette onto a canvas and
 *  joining their ports; see doc.ts. */
export function WorkflowEditor(props: WorkflowEditorProps) {
  // The engine's viewport helpers are needed outside the canvas (the
  // palette adds in view), so the provider wraps the whole editor.
  return (
    <ReactFlowProvider>
      <Editor {...props} />
    </ReactFlowProvider>
  );
}

/** An id not yet used in the workflow: n1, n2… (e1, e2… for edges). */
function nextId(prefix: string, taken: readonly { id: string }[]) {
  const ids = new Set(taken.map((item) => item.id));
  let n = taken.length + 1;
  while (ids.has(`${prefix}${n}`)) n += 1;
  return `${prefix}${n}`;
}

const isTyping = (target: EventTarget | null) =>
  target instanceof HTMLElement &&
  (target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));

function Editor({
  label,
  kinds: kindList,
  defaultValue = EMPTY,
  onChange,
  rules = {},
  readOnly = false,
  empty = "Drag a step here from the palette, or choose one to add it.",
  className,
}: WorkflowEditorProps) {
  const kinds = useMemo(() => kindMap(kindList), [kindList]);
  const flow = useReactFlow<FlowNode, Edge>();
  const canvasRef = useRef<HTMLDivElement>(null);

  const [history, setHistory] = useState<History<Workflow>>(() =>
    start(defaultValue),
  );
  const workflow = history.present;
  const change = useCallback(
    (next: Workflow | null, key: string | null = null) => {
      if (next) setHistory((current) => commit(current, next, key));
    },
    [],
  );
  // Tell the caller about committed changes, not the starting value.
  const reported = useRef(workflow);
  useEffect(() => {
    if (reported.current === workflow) return;
    reported.current = workflow;
    onChange?.(workflow);
  }, [workflow, onChange]);

  // Selection, drag positions and measurements belong to the renderer,
  // not to the workflow (and not to its history).
  const [selectedNodes, setSelectedNodes] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [selectedEdges, setSelectedEdges] = useState<ReadonlySet<string>>(
    () => new Set(),
  );
  const [dragging, setDragging] = useState<ReadonlyMap<string, Point>>(
    () => new Map(),
  );
  const [measured, setMeasured] = useState<ReadonlyMap<string, Size>>(
    () => new Map(),
  );
  const [message, setMessage] = useState("");

  const issues = useMemo(() => findIssues(workflow, kinds), [workflow, kinds]);

  const flowNodes = useMemo<FlowNode[]>(
    () =>
      workflow.nodes.map((node) => ({
        id: node.id,
        type: "step",
        position: dragging.get(node.id) ?? node.position,
        // Controlled nodes must carry their measurements back, or the
        // engine loses its handle bounds and the edges disappear.
        measured: measured.get(node.id),
        selected: selectedNodes.has(node.id),
        ariaLabel: `${nodeName(node, kinds)}, ${kinds.get(node.kind)?.label ?? node.kind}${
          issues.some((issue) => issue.nodeId === node.id)
            ? ", needs attention"
            : ""
        }`,
        data: {
          spec: kinds.get(node.kind),
          kind: node.kind,
          name: nodeName(node, kinds),
          issues: issues.filter((issue) => issue.nodeId === node.id),
          connectable: !readOnly,
        },
      })),
    [
      workflow.nodes,
      dragging,
      measured,
      selectedNodes,
      kinds,
      issues,
      readOnly,
    ],
  );
  const flowEdges = useMemo<Edge[]>(
    () =>
      workflow.edges.map((edge) => ({
        id: edge.id,
        source: edge.source,
        sourceHandle: edge.sourcePort,
        target: edge.target,
        targetHandle: edge.targetPort,
        selected: selectedEdges.has(edge.id),
        ariaLabel: `Connection from ${nameOf(edge.source)} to ${nameOf(edge.target)}`,
      })),
    // eslint-disable-next-line react-hooks/exhaustive-deps -- nameOf reads workflow.nodes and kinds
    [workflow.edges, workflow.nodes, selectedEdges, kinds],
  );
  function nameOf(id: string) {
    const node = workflow.nodes.find((item) => item.id === id);
    return node ? nodeName(node, kinds) : id;
  }

  const select = useCallback((nodeId: string | null, edgeId?: string) => {
    setSelectedNodes(nodeId ? new Set([nodeId]) : new Set());
    setSelectedEdges(edgeId ? new Set([edgeId]) : new Set());
  }, []);

  function applySelect(
    current: ReadonlySet<string>,
    changes: readonly (NodeChange<FlowNode> | EdgeChange<Edge>)[],
  ) {
    let next = current;
    for (const item of changes) {
      if (item.type !== "select" || item.selected === next.has(item.id))
        continue;
      const copy = new Set(next);
      if (item.selected) copy.add(item.id);
      else copy.delete(item.id);
      next = copy;
    }
    return next;
  }

  function onNodesChange(changes: NodeChange<FlowNode>[]) {
    setSelectedNodes((current) => applySelect(current, changes));
    if (changes.some((item) => item.type === "dimensions" && item.dimensions))
      setMeasured((current) => {
        const next = new Map(current);
        for (const item of changes)
          if (item.type === "dimensions" && item.dimensions)
            next.set(item.id, item.dimensions);
        return next;
      });
    // A drag moves nodes in the renderer and commits when it ends; a
    // keyboard move commits at once (a burst of arrows folds into one step).
    const moving = new Map<string, Point>();
    const moved = new Map<string, Point>();
    for (const item of changes) {
      if (item.type !== "position" || !item.position) continue;
      (item.dragging ? moving : moved).set(item.id, item.position);
    }
    if (moving.size) setDragging((current) => new Map([...current, ...moving]));
    if (moved.size) {
      setDragging((current) =>
        current.size
          ? new Map([...current].filter(([id]) => !moved.has(id)))
          : current,
      );
      const actual = [...moved].some(([id, at]) => {
        const node = workflow.nodes.find((item) => item.id === id);
        return node && (node.position.x !== at.x || node.position.y !== at.y);
      });
      if (actual)
        change(moveNodes(workflow, moved), `move:${[...moved.keys()].join()}`);
    }
  }

  function onEdgesChange(changes: EdgeChange<Edge>[]) {
    setSelectedEdges((current) => applySelect(current, changes));
  }

  const toConnection = (value: {
    source: string | null;
    target: string | null;
    sourceHandle?: string | null;
    targetHandle?: string | null;
  }): Connection | null =>
    value.source && value.target && value.sourceHandle && value.targetHandle
      ? {
          source: value.source,
          sourcePort: value.sourceHandle,
          target: value.target,
          targetPort: value.targetHandle,
        }
      : null;

  function join(connection: Connection) {
    const result = connect(
      workflow,
      kinds,
      connection,
      nextId("e", workflow.edges),
      rules,
    );
    if (result.refusal) {
      setMessage(REFUSALS[result.refusal]);
      return;
    }
    change(result.workflow);
    setMessage(
      `Connected ${nameOf(connection.source)} to ${nameOf(connection.target)}.`,
    );
  }

  function add(kind: string, position: Point) {
    const id = nextId("n", workflow.nodes);
    const next = addNode(workflow, kinds, kind, position, id);
    if (!next) {
      setMessage(`Only ${kinds.get(kind)?.max ?? 0} of those per workflow.`);
      return;
    }
    change(next);
    select(id);
    setMessage(`Added ${kinds.get(kind)?.label ?? kind}.`);
  }

  /** Adds in the middle of the view, nudged so repeated adds don't stack. */
  function addInView(kind: string) {
    const rect = canvasRef.current?.getBoundingClientRect();
    if (!rect) return;
    const centre = flow.screenToFlowPosition({
      x: rect.left + rect.width / 2,
      y: rect.top + rect.height / 2,
    });
    const nudge = (workflow.nodes.length % 5) * 24;
    add(kind, {
      x: Math.round(centre.x - 110 + nudge),
      y: Math.round(centre.y - 48 + nudge),
    });
  }

  function remove(nodeIds: Iterable<string>, edgeIds: Iterable<string>) {
    const nodes = [...nodeIds];
    const edges = [...edgeIds];
    if (!nodes.length && !edges.length) return;
    change(removeNodes(removeEdges(workflow, edges), nodes));
    select(null);
    setMessage(
      nodes.length
        ? `Removed ${nodes.length === 1 ? "a step" : `${nodes.length} steps`}.`
        : `Removed ${edges.length === 1 ? "a connection" : `${edges.length} connections`}.`,
    );
  }

  function tidy() {
    change(moveNodes(workflow, autoLayout(workflow, measured)));
    setMessage("Tidied the layout.");
    // After the moved nodes render.
    requestAnimationFrame(() => flow.fitView({ padding: 0.2, duration: 300 }));
  }

  function step(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (readOnly || isTyping(event.target)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      step(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      step("redo");
    }
  }

  const selectedNode =
    selectedNodes.size === 1
      ? (workflow.nodes.find((node) => selectedNodes.has(node.id)) ?? null)
      : null;
  const selectedEdge =
    !selectedNode && selectedEdges.size === 1
      ? (workflow.edges.find((edge) => selectedEdges.has(edge.id)) ?? null)
      : null;

  return (
    <div className={cn(styles.editor, className)} onKeyDown={onKeyDown}>
      <div className={cn(styles.frame, readOnly && styles.readOnly)}>
        <div className={styles.toolbar} role="toolbar" aria-label="Workflow">
          {readOnly ? null : (
            <>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Undo"
                title="Undo (⌘Z)"
                disabled={!canUndo(history)}
                onClick={() => step("undo")}
              >
                <Undo2 aria-hidden="true" />
              </Button>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Redo"
                title="Redo (⇧⌘Z)"
                disabled={!canRedo(history)}
                onClick={() => step("redo")}
              >
                <Redo2 aria-hidden="true" />
              </Button>
              <span className={styles.divider} aria-hidden="true" />
              <Button
                size="sm"
                variant="quiet"
                disabled={workflow.nodes.length < 2}
                onClick={tidy}
              >
                <WandSparkles aria-hidden="true" />
                Tidy
              </Button>
            </>
          )}
          <Button
            size="sm"
            variant="quiet"
            disabled={workflow.nodes.length === 0}
            onClick={() => flow.fitView({ padding: 0.2, duration: 300 })}
          >
            <Maximize2 aria-hidden="true" />
            Fit
          </Button>
          <span className={styles.toolbarEnd}>
            {workflow.nodes.length ? (
              issues.length ? (
                <Badge tone="warn" dot>
                  {issues.length} to finish
                </Badge>
              ) : (
                <Badge tone="accent" dot>
                  Ready
                </Badge>
              )
            ) : null}
          </span>
        </div>

        {readOnly ? null : (
          <Palette kinds={kindList} workflow={workflow} onAdd={addInView} />
        )}

        <div
          ref={canvasRef}
          className={styles.canvas}
          role="region"
          aria-label={label}
          // The engine keeps a press on the pane or a node from moving focus,
          // so undo's keys would go to the page; the canvas takes it instead.
          tabIndex={-1}
          onPointerDownCapture={(event) => {
            const target = event.target as HTMLElement;
            if (!target.closest("button, input, select, textarea, a"))
              canvasRef.current?.focus({ preventScroll: true });
          }}
          onDragOver={(event) => {
            if (readOnly || !event.dataTransfer.types.includes(DRAG_TYPE))
              return;
            event.preventDefault();
            event.dataTransfer.dropEffect = "copy";
          }}
          onDrop={(event) => {
            const kind = event.dataTransfer.getData(DRAG_TYPE);
            if (readOnly || !kind) return;
            event.preventDefault();
            const at = flow.screenToFlowPosition({
              x: event.clientX,
              y: event.clientY,
            });
            // The pointer holds the node by its header, not its corner.
            add(kind, { x: Math.round(at.x - 24), y: Math.round(at.y - 20) });
          }}
        >
          <ReactFlow<FlowNode, Edge>
            nodes={flowNodes}
            edges={flowEdges}
            nodeTypes={nodeTypes}
            onNodesChange={onNodesChange}
            onEdgesChange={onEdgesChange}
            onConnect={(value) => {
              const connection = toConnection(value);
              if (connection) join(connection);
            }}
            isValidConnection={(value) => {
              const connection = toConnection(value);
              return (
                !!connection &&
                !checkConnection(workflow, kinds, connection, rules)
              );
            }}
            onConnectEnd={(_, state) => {
              // A drop on a port that refused: say why.
              if (
                state.isValid !== false ||
                !state.fromHandle ||
                !state.toHandle
              )
                return;
              const [out, into] =
                state.fromHandle.type === "source"
                  ? [state.fromHandle, state.toHandle]
                  : [state.toHandle, state.fromHandle];
              const refusal = checkConnection(
                workflow,
                kinds,
                {
                  source: out.nodeId,
                  sourcePort: out.id ?? "",
                  target: into.nodeId,
                  targetPort: into.id ?? "",
                },
                rules,
              );
              if (refusal) setMessage(REFUSALS[refusal]);
            }}
            onDelete={({ nodes, edges }) =>
              remove(
                nodes.map((node) => node.id),
                edges.map((edge) => edge.id),
              )
            }
            onPaneClick={() => select(null)}
            nodesDraggable={!readOnly}
            nodesConnectable={!readOnly}
            nodesFocusable
            edgesFocusable
            elementsSelectable
            deleteKeyCode={readOnly ? null : ["Backspace", "Delete"]}
            fitView
            fitViewOptions={{ padding: 0.2 }}
            minZoom={0.25}
            maxZoom={2}
            proOptions={{ hideAttribution: true }}
            ariaLabelConfig={{
              "node.a11yDescription.default": readOnly
                ? "Press Enter to select this step. Press Escape to clear the selection."
                : "Press Enter to select this step. Use the arrow keys to move it, and Delete to remove it.",
            }}
          >
            <Background gap={20} />
            <Controls showInteractive={false} showFitView={false} />
          </ReactFlow>
          {workflow.nodes.length === 0 ? (
            <div className={styles.empty}>{empty}</div>
          ) : null}
        </div>

        <Inspector
          workflow={workflow}
          kinds={kinds}
          rules={rules}
          issues={issues}
          selectedNode={selectedNode}
          selectedEdge={selectedEdge}
          readOnly={readOnly}
          onUpdate={(id, value, key) =>
            change(updateNode(workflow, id, value), key)
          }
          onConnect={join}
          onDisconnect={(id) => remove([], [id])}
          onDelete={(id) => remove([id], [])}
          onSelect={(id) => select(id)}
        />
      </div>
      <p className={styles.status} role="status">
        {message}
      </p>
    </div>
  );
}

components/workspaces/workflow-editor/workflow-node.tsx

"use client";

import { Handle, Position, type Node, type NodeProps } from "@xyflow/react";

import { TriangleAlert } from "@/components/utility/icon";
import type { Issue, NodeKind } from "../_kernel/workflow";
import { KindIcon } from "./icons";
import styles from "./workflow-editor.module.css";

export type WorkflowNodeData = {
  spec: NodeKind | undefined;
  kind: string;
  name: string;
  issues: readonly Issue[];
  connectable: boolean;
};

export type FlowNode = Node<WorkflowNodeData, "step">;

/** One step on the canvas: what it is, and its ports, inputs on the left
 *  and outputs on the right, each a handle on the node's edge. */
export function WorkflowNode({ data, selected }: NodeProps<FlowNode>) {
  const { spec, name, issues, connectable } = data;
  const inputs = spec?.inputs ?? [];
  const outputs = spec?.outputs ?? [];
  const rows = Math.max(inputs.length, outputs.length);

  return (
    <div
      className={styles.node}
      data-tone={spec?.tone ?? "neutral"}
      data-selected={selected || undefined}
      data-invalid={issues.length > 0 || undefined}
    >
      <div className={styles.nodeHead}>
        <span className={styles.nodeIcon} aria-hidden="true">
          <KindIcon name={spec?.icon} />
        </span>
        <span className={styles.nodeTitle}>
          <span className={styles.nodeName}>{name}</span>
          <span className={styles.nodeKind}>
            {spec?.label ?? `Unknown: ${data.kind}`}
          </span>
        </span>
        {issues.length ? (
          <span
            className={styles.nodeIssue}
            title={issues.map((issue) => issue.message).join("\n")}
          >
            <TriangleAlert aria-hidden="true" />
          </span>
        ) : null}
      </div>
      {rows ? (
        <div className={styles.ports}>
          {Array.from({ length: rows }, (_, row) => {
            const input = inputs[row];
            const output = outputs[row];
            return (
              <div key={row} className={styles.portRow}>
                <span className={styles.portIn}>
                  {input ? (
                    <>
                      <Handle
                        type="target"
                        position={Position.Left}
                        id={input.id}
                        isConnectable={connectable}
                        className={styles.handle}
                        data-type={input.type}
                        title={`${input.label} · ${input.type}`}
                      />
                      {input.label}
                      {input.required ? (
                        <span className={styles.portRequired} aria-hidden>
                          *
                        </span>
                      ) : null}
                    </>
                  ) : null}
                </span>
                <span className={styles.portOut}>
                  {output ? (
                    <>
                      {output.label}
                      <Handle
                        type="source"
                        position={Position.Right}
                        id={output.id}
                        isConnectable={connectable}
                        className={styles.handle}
                        data-type={output.type}
                        title={`${output.label} · ${output.type}`}
                      />
                    </>
                  ) : null}
                </span>
              </div>
            );
          })}
        </div>
      ) : null}
    </div>
  );
}

components/workspaces/workflow-editor/inspector.tsx

"use client";

import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input, Textarea } from "@/components/forms/input";
import { NumberInput } from "@/components/forms/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/forms/select";
import { Switch } from "@/components/forms/switch";
import { ArrowRight, Trash2, Unlink } from "@/components/utility/icon";
import {
  compatibleTargets,
  nodeName,
  type FieldSpec,
  type Issue,
  type Kinds,
  type Rules,
  type Workflow,
  type WorkflowEdge,
  type WorkflowNode,
} from "../_kernel/workflow";
import styles from "./workflow-editor.module.css";

export type InspectorProps = {
  workflow: Workflow;
  kinds: Kinds;
  rules: Rules;
  issues: readonly Issue[];
  selectedNode: WorkflowNode | null;
  selectedEdge: WorkflowEdge | null;
  readOnly: boolean;
  /** A change to a setting or a name: `key` folds a burst of typing into
   *  one undo step. */
  onUpdate: (
    id: string,
    change: { label?: string; data?: Record<string, unknown> },
    key: string,
  ) => void;
  onConnect: (edge: Omit<WorkflowEdge, "id">) => void;
  onDisconnect: (edgeId: string) => void;
  onDelete: (nodeId: string) => void;
  onSelect: (nodeId: string | null) => void;
};

/** The selected step's name, settings and connections; with nothing
 *  selected, what keeps the workflow from being complete. */
export function Inspector(props: InspectorProps) {
  const { selectedNode, selectedEdge } = props;
  return (
    <aside className={styles.inspector} aria-label="Inspector">
      {selectedNode ? (
        <NodePanel {...props} node={selectedNode} />
      ) : selectedEdge ? (
        <EdgePanel {...props} edge={selectedEdge} />
      ) : (
        <Overview {...props} />
      )}
    </aside>
  );
}

const endName = (
  workflow: Workflow,
  kinds: Kinds,
  nodeId: string,
  portId: string,
  side: "inputs" | "outputs",
) => {
  const node = workflow.nodes.find((item) => item.id === nodeId);
  if (!node) return nodeId;
  const port = kinds.get(node.kind)?.[side].find((item) => item.id === portId);
  const ports = kinds.get(node.kind)?.[side].length ?? 0;
  // A port's name only helps when the step has more than one.
  return ports > 1 && port
    ? `${nodeName(node, kinds)} · ${port.label}`
    : nodeName(node, kinds);
};

function NodePanel({
  workflow,
  kinds,
  rules,
  issues,
  node,
  readOnly,
  onUpdate,
  onConnect,
  onDisconnect,
  onDelete,
}: InspectorProps & { node: WorkflowNode }) {
  const spec = kinds.get(node.kind);
  const mine = issues.filter((issue) => issue.nodeId === node.id);
  const errorFor = (field: string) =>
    mine.find((issue) => issue.field === field)?.message;

  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>{spec?.label ?? node.kind}</p>
        <h3 className={styles.panelTitle}>{nodeName(node, kinds)}</h3>
        {spec?.description ? (
          <p className={styles.panelNote}>{spec.description}</p>
        ) : null}
      </header>

      <Field
        label="Name"
        hint={`Shown on the canvas. Empty uses “${spec?.label ?? node.kind}”.`}
      >
        {(control) => (
          <Input
            {...control}
            size="sm"
            value={node.label ?? ""}
            placeholder={spec?.label}
            readOnly={readOnly}
            onChange={(event) =>
              onUpdate(
                node.id,
                { label: event.target.value },
                `label:${node.id}`,
              )
            }
          />
        )}
      </Field>

      {spec?.fields.map((field) => (
        <SettingField
          key={field.key}
          field={field}
          value={node.data[field.key]}
          error={errorFor(field.key)}
          readOnly={readOnly}
          onChange={(value) =>
            onUpdate(
              node.id,
              { data: { [field.key]: value } },
              `field:${node.id}:${field.key}`,
            )
          }
        />
      ))}

      {spec?.inputs.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Inputs</h4>
          {spec.inputs.map((port) => {
            const edges = workflow.edges.filter(
              (edge) => edge.target === node.id && edge.targetPort === port.id,
            );
            const issue = mine.find((item) => item.port === port.id);
            return (
              <div key={port.id} className={styles.port}>
                <p className={styles.portName}>
                  {port.label}
                  <span className={styles.portType}>{port.type}</span>
                </p>
                {edges.length ? (
                  <ul className={styles.links}>
                    {edges.map((edge) => (
                      <li key={edge.id} className={styles.link}>
                        <span>
                          From{" "}
                          {endName(
                            workflow,
                            kinds,
                            edge.source,
                            edge.sourcePort,
                            "outputs",
                          )}
                        </span>
                        {readOnly ? null : (
                          <Button
                            size="icon"
                            variant="quiet"
                            aria-label={`Disconnect from ${endName(workflow, kinds, edge.source, edge.sourcePort, "outputs")}`}
                            onClick={() => onDisconnect(edge.id)}
                          >
                            <Unlink aria-hidden="true" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={issue ? styles.linkMissing : styles.linkNone}>
                    {issue ? issue.message : "Not connected."}
                  </p>
                )}
              </div>
            );
          })}
        </section>
      ) : null}

      {spec?.outputs.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Outputs</h4>
          {spec.outputs.map((port) => {
            const edges = workflow.edges.filter(
              (edge) => edge.source === node.id && edge.sourcePort === port.id,
            );
            const targets = readOnly
              ? []
              : compatibleTargets(workflow, kinds, node.id, port.id, rules);
            return (
              <div key={port.id} className={styles.port}>
                <p className={styles.portName}>
                  {port.label}
                  <span className={styles.portType}>{port.type}</span>
                </p>
                {edges.length ? (
                  <ul className={styles.links}>
                    {edges.map((edge) => (
                      <li key={edge.id} className={styles.link}>
                        <span>
                          To{" "}
                          {endName(
                            workflow,
                            kinds,
                            edge.target,
                            edge.targetPort,
                            "inputs",
                          )}
                        </span>
                        {readOnly ? null : (
                          <Button
                            size="icon"
                            variant="quiet"
                            aria-label={`Disconnect from ${endName(workflow, kinds, edge.target, edge.targetPort, "inputs")}`}
                            onClick={() => onDisconnect(edge.id)}
                          >
                            <Unlink aria-hidden="true" />
                          </Button>
                        )}
                      </li>
                    ))}
                  </ul>
                ) : (
                  <p className={styles.linkNone}>Not connected.</p>
                )}
                {readOnly ? null : (
                  // Keyed by the count, so it resets to its placeholder
                  // after each connection.
                  <Select
                    key={edges.length}
                    disabled={targets.length === 0}
                    onValueChange={(value) => {
                      const [target, targetPort] = value.split("\u0000");
                      onConnect({
                        source: node.id,
                        sourcePort: port.id,
                        target,
                        targetPort,
                      });
                    }}
                  >
                    <SelectTrigger
                      size="sm"
                      aria-label={`Connect ${port.label} to`}
                      placeholder={
                        targets.length ? "Connect to…" : "Nothing to connect to"
                      }
                    />
                    <SelectContent>
                      {targets.map((target) => (
                        <SelectItem
                          key={`${target.node.id}:${target.port.id}`}
                          value={`${target.node.id}\u0000${target.port.id}`}
                        >
                          {endName(
                            workflow,
                            kinds,
                            target.node.id,
                            target.port.id,
                            "inputs",
                          )}
                        </SelectItem>
                      ))}
                    </SelectContent>
                  </Select>
                )}
              </div>
            );
          })}
        </section>
      ) : null}

      {readOnly ? null : (
        <Button
          variant="danger"
          size="sm"
          className={styles.panelAction}
          onClick={() => onDelete(node.id)}
        >
          <Trash2 aria-hidden="true" />
          Delete step
        </Button>
      )}
    </div>
  );
}

function SettingField({
  field,
  value,
  error,
  readOnly,
  onChange,
}: {
  field: FieldSpec;
  value: unknown;
  error: string | undefined;
  readOnly: boolean;
  onChange: (value: unknown) => void;
}) {
  return (
    <Field
      label={field.label}
      hint={field.hint}
      error={error}
      required={field.required}
    >
      {(control) => {
        switch (field.kind) {
          case "text":
            return field.multiline ? (
              <Textarea
                {...control}
                size="sm"
                rows={3}
                value={typeof value === "string" ? value : ""}
                placeholder={field.placeholder}
                readOnly={readOnly}
                onChange={(event) => onChange(event.target.value)}
              />
            ) : (
              <Input
                {...control}
                size="sm"
                value={typeof value === "string" ? value : ""}
                placeholder={field.placeholder}
                readOnly={readOnly}
                onChange={(event) => onChange(event.target.value)}
              />
            );
          case "number":
            return (
              <NumberInput
                {...control}
                size="sm"
                value={typeof value === "number" ? value : null}
                min={field.min}
                max={field.max}
                step={field.step}
                readOnly={readOnly}
                onValueChange={onChange}
              />
            );
          case "select":
            return (
              <Select
                value={typeof value === "string" ? value : ""}
                disabled={readOnly}
                onValueChange={onChange}
              >
                <SelectTrigger {...control} size="sm" placeholder="Choose…" />
                <SelectContent>
                  {field.options.map((option) => (
                    <SelectItem key={option.value} value={option.value}>
                      {option.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            );
          case "toggle":
            return (
              <Switch
                {...control}
                checked={value === true}
                disabled={readOnly}
                onCheckedChange={onChange}
              />
            );
        }
      }}
    </Field>
  );
}

function EdgePanel({
  workflow,
  kinds,
  edge,
  readOnly,
  onDisconnect,
  onSelect,
}: InspectorProps & { edge: WorkflowEdge }) {
  const from = endName(
    workflow,
    kinds,
    edge.source,
    edge.sourcePort,
    "outputs",
  );
  const to = endName(workflow, kinds, edge.target, edge.targetPort, "inputs");
  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>Connection</p>
        <h3 className={styles.panelTitle}>
          {from} <ArrowRight className={styles.inlineIcon} aria-label="to" />{" "}
          {to}
        </h3>
      </header>
      <div className={styles.panelButtons}>
        <Button size="sm" variant="quiet" onClick={() => onSelect(edge.source)}>
          Go to {from}
        </Button>
        <Button size="sm" variant="quiet" onClick={() => onSelect(edge.target)}>
          Go to {to}
        </Button>
      </div>
      {readOnly ? null : (
        <Button
          variant="danger"
          size="sm"
          className={styles.panelAction}
          onClick={() => onDisconnect(edge.id)}
        >
          <Unlink aria-hidden="true" />
          Remove connection
        </Button>
      )}
    </div>
  );
}

function Overview({
  workflow,
  kinds,
  issues,
  readOnly,
  onSelect,
}: InspectorProps) {
  const steps = workflow.nodes.length;
  const links = workflow.edges.length;
  return (
    <div className={styles.panel}>
      <header className={styles.panelHead}>
        <p className={styles.panelEyebrow}>Workflow</p>
        <h3 className={styles.panelTitle}>
          {steps} {steps === 1 ? "step" : "steps"} · {links}{" "}
          {links === 1 ? "connection" : "connections"}
        </h3>
      </header>
      {issues.length ? (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>
            {issues.length} {issues.length === 1 ? "thing" : "things"} to finish
          </h4>
          <ul className={styles.issues}>
            {issues.map((issue, index) => {
              const node = workflow.nodes.find(
                (item) => item.id === issue.nodeId,
              );
              return (
                <li key={index}>
                  <button
                    type="button"
                    className={styles.issue}
                    onClick={() => onSelect(issue.nodeId)}
                  >
                    <span className={styles.issueNode}>
                      {node ? nodeName(node, kinds) : issue.nodeId}
                    </span>
                    {issue.message}
                  </button>
                </li>
              );
            })}
          </ul>
        </section>
      ) : steps ? (
        <p className={styles.panelNote}>Every step is set up and connected.</p>
      ) : null}
      {readOnly ? null : (
        <section className={styles.panelSection}>
          <h4 className={styles.panelHeading}>Keys</h4>
          <dl className={styles.keys}>
            <dt>Tab, Enter</dt>
            <dd>Reach and select a step</dd>
            <dt>Arrows</dt>
            <dd>Move the selected step</dd>
            <dt>Delete</dt>
            <dd>Remove what is selected</dd>
            <dt>⌘Z, ⇧⌘Z</dt>
            <dd>Undo, redo</dd>
          </dl>
        </section>
      )}
    </div>
  );
}

components/workspaces/workflow-editor/palette.tsx

"use client";

import { useId } from "react";

import { count, type NodeKind, type Workflow } from "../_kernel/workflow";
import { KindIcon } from "./icons";
import styles from "./workflow-editor.module.css";

/** The drag type a palette item carries; the canvas accepts only it. */
export const DRAG_TYPE = "application/x-bento-step";

export type PaletteProps = {
  kinds: readonly NodeKind[];
  workflow: Workflow;
  /** Adds the kind in view: the keyboard's (and a click's) way in. */
  onAdd: (kind: string) => void;
};

/** Every kind of step, by category. Drag one onto the canvas, or press it
 *  to add it in the middle of the view. */
export function Palette({ kinds, workflow, onAdd }: PaletteProps) {
  const id = useId();
  const categories = [...new Set(kinds.map((kind) => kind.category))];
  return (
    <nav className={styles.palette} aria-label="Steps">
      {categories.map((category) => (
        <section key={category} className={styles.paletteGroup}>
          <h3 className={styles.paletteHeading}>{category}</h3>
          <ul className={styles.paletteList}>
            {kinds
              .filter((kind) => kind.category === category)
              .map((kind) => {
                const full =
                  kind.max !== undefined &&
                  count(workflow, kind.kind) >= kind.max;
                return (
                  <li key={kind.kind}>
                    <button
                      type="button"
                      className={styles.paletteItem}
                      data-tone={kind.tone ?? "neutral"}
                      draggable={!full}
                      disabled={full}
                      onDragStart={(event) => {
                        event.dataTransfer.setData(DRAG_TYPE, kind.kind);
                        event.dataTransfer.effectAllowed = "copy";
                      }}
                      onClick={() => onAdd(kind.kind)}
                      aria-describedby={`${id}-${kind.kind}`}
                    >
                      <span className={styles.nodeIcon} aria-hidden="true">
                        <KindIcon name={kind.icon} />
                      </span>
                      <span className={styles.paletteText}>
                        <span className={styles.paletteLabel}>
                          {kind.label}
                        </span>
                        <span
                          id={`${id}-${kind.kind}`}
                          className={styles.paletteNote}
                          // Read as the description, not as part of the name.
                          aria-hidden="true"
                        >
                          {full
                            ? kind.max === 1
                              ? "Only one per workflow"
                              : `At most ${kind.max} per workflow`
                            : kind.description}
                        </span>
                      </span>
                    </button>
                  </li>
                );
              })}
          </ul>
        </section>
      ))}
    </nav>
  );
}

components/workspaces/workflow-editor/workflow-editor.module.css

/* Unlayered on purpose: the engine's stylesheet is unlayered, and an
   override in a layer would lose to it whatever its specificity. Every
   selector here is scoped to this module's classes. */

.editor {
  container-type: inline-size;
  min-inline-size: 0;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-3);
  background: var(--surface-panel);
  color: var(--ink);
  overflow: hidden;
}
/* The grid sits inside the container, so the container query can change it. */
.frame {
  display: grid;
  grid-template-columns: 220px minmax(0, 1fr) 288px;
  grid-template-areas:
    "toolbar toolbar toolbar"
    "palette canvas inspector";
}
.readOnly {
  grid-template-columns: minmax(0, 1fr) 288px;
  grid-template-areas:
    "toolbar toolbar"
    "canvas inspector";
}

/* ── Toolbar ── */
.toolbar {
  grid-area: toolbar;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-3);
  border-bottom: 1px solid var(--line);
}
.toolbar svg {
  inline-size: 15px;
  block-size: 15px;
}
.divider {
  inline-size: 1px;
  block-size: 18px;
  margin-inline: var(--space-1);
  background: var(--line);
}
.toolbarEnd {
  margin-inline-start: auto;
}

/* ── Palette ── */
.palette {
  grid-area: palette;
  display: grid;
  align-content: start;
  gap: var(--space-6);
  max-block-size: 600px;
  padding: var(--space-5) var(--space-4);
  border-right: 1px solid var(--line);
  overflow-y: auto;
}
.paletteGroup {
  display: grid;
  align-content: start;
  gap: var(--space-2);
}
.paletteHeading {
  margin: 0;
  padding-inline: var(--space-2);
  color: var(--ink-3);
  font-size: var(--text-11);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.paletteList {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.paletteItem {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: start;
  gap: var(--space-3);
  inline-size: 100%;
  padding: var(--space-2);
  border: 1px solid transparent;
  border-radius: var(--radius-2);
  background: none;
  color: var(--ink);
  font: inherit;
  text-align: start;
  cursor: grab;
}
.paletteItem:hover:not(:disabled) {
  border-color: var(--line);
  background: var(--surface-hover);
}
.paletteItem:active:not(:disabled) {
  cursor: grabbing;
}
.paletteItem:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.paletteItem:disabled {
  cursor: not-allowed;
  opacity: 0.55;
}
.paletteText {
  display: grid;
  gap: 1px;
  min-inline-size: 0;
}
.paletteLabel {
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
}
.paletteNote {
  color: var(--ink-3);
  font-size: var(--text-12);
  line-height: var(--leading-snug);
}

/* ── Canvas ── */
.canvas {
  grid-area: canvas;
  position: relative;
  block-size: 600px;
  min-inline-size: 0;
  background: var(--surface-sunk);
  --xy-background-color: var(--surface-sunk);
  --xy-background-pattern-color: var(--line-strong);
  --xy-edge-stroke: var(--ink-3);
  --xy-edge-stroke-width: 1.5;
  --xy-edge-stroke-selected: var(--accent);
  --xy-connectionline-stroke: var(--accent);
  --xy-connectionline-stroke-width: 1.5;
  --xy-handle-background-color: var(--surface-panel);
  --xy-handle-border-color: var(--ink-3);
  --xy-node-border-radius: var(--radius-3);
  --xy-controls-button-background-color: var(--surface-panel);
  --xy-controls-button-background-color-hover: var(--surface-raised);
  --xy-controls-button-color: var(--ink);
  --xy-controls-button-color-hover: var(--ink);
  --xy-controls-button-border-color: var(--line-strong);
  --xy-controls-box-shadow: none;
  --xy-selection-background-color: var(--accent-tint);
  --xy-selection-border: 1px dashed var(--accent);
}
/* Focus lands here after a press on the pane, so keys reach the editor;
   it is not a stop in the tab order, so it shows no ring. */
.canvas:focus {
  outline: none;
}
/* The image reset caps an SVG's width at its parent. Edge SVGs overflow a
   zero-width container on purpose, so the cap stops them painting. */
.canvas :global(.react-flow__edges svg) {
  max-inline-size: none;
}
.canvas :global(.react-flow__node) {
  border-radius: var(--radius-3);
}
.canvas :global(.react-flow__node:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}
.canvas :global(.react-flow__edge:focus-visible .react-flow__edge-path) {
  stroke: var(--accent);
  stroke-width: 3;
}
.canvas :global(.react-flow__controls) {
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-2);
  overflow: hidden;
}
.canvas :global(.react-flow__controls-button) {
  inline-size: 30px;
  block-size: 30px;
}
.canvas :global(.react-flow__controls-button:focus-visible) {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}
.empty {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: var(--space-7);
  color: var(--ink-3);
  font-size: var(--text-13);
  text-align: center;
  pointer-events: none;
}

/* ── A step ── */
.node {
  min-inline-size: 200px;
  max-inline-size: 260px;
  border: 1px solid var(--line-heavy);
  border-radius: var(--radius-3);
  background: var(--surface-panel);
  color: var(--ink);
  font-size: var(--text-12);
  box-shadow: 0 1px 2px var(--alpha-b-08);
}
.node[data-selected] {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}
.node[data-invalid]:not([data-selected]) {
  border-color: var(--warn-line);
}
.nodeHead {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-3);
}
.nodeIcon {
  display: grid;
  place-items: center;
  inline-size: 26px;
  block-size: 26px;
  border-radius: var(--radius-2);
  background: var(--surface-sunk);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink-2);
}
.nodeIcon svg {
  inline-size: 14px;
  block-size: 14px;
}
[data-tone] .nodeIcon {
  box-shadow: none;
}
[data-tone="neutral"] .nodeIcon {
  box-shadow: inset 0 0 0 1px var(--line);
}
[data-tone="accent"] .nodeIcon {
  background: var(--accent-tint);
  color: var(--accent);
}
[data-tone="info"] .nodeIcon {
  background: var(--info-tint);
  color: var(--info);
}
[data-tone="warn"] .nodeIcon {
  background: var(--warn-tint);
  color: var(--warn);
}
[data-tone="crit"] .nodeIcon {
  background: var(--crit-tint);
  color: var(--crit);
}
.nodeTitle {
  display: grid;
  min-inline-size: 0;
}
.nodeName {
  overflow: hidden;
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nodeKind {
  color: var(--ink-3);
  font-size: var(--text-11);
}
.nodeIssue {
  display: grid;
  color: var(--warn);
}
.nodeIssue svg {
  inline-size: 14px;
  block-size: 14px;
}
.ports {
  display: grid;
  gap: var(--space-1);
  padding-block: var(--space-2);
  border-top: 1px solid var(--line);
}
.portRow {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: center;
  min-block-size: 20px;
}
.portIn,
.portOut {
  position: relative;
  display: flex;
  align-items: center;
  gap: 2px;
  block-size: 100%;
  color: var(--ink-2);
  white-space: nowrap;
}
.portIn {
  padding-inline-start: var(--space-4);
}
.portOut {
  justify-content: flex-end;
  padding-inline-end: var(--space-4);
}
/* Each handle sits on the node's edge, level with its label. */
.portOut .handle {
  right: 0;
}
.portIn .handle,
.portOut .handle {
  position: absolute;
  inline-size: 10px;
  block-size: 10px;
  border-width: 1.5px;
}
.portIn .handle {
  left: 0;
}
.handle[data-type="any"] {
  border-style: dashed;
}
.handle:global(.connectingto),
.handle:global(.connectionindicator):hover {
  border-color: var(--accent);
}
.handle:global(.valid) {
  background: var(--accent);
  border-color: var(--accent);
}
.portRequired {
  color: var(--ink-3);
}

/* ── Inspector ── */
.inspector {
  grid-area: inspector;
  max-block-size: 600px;
  border-left: 1px solid var(--line);
  overflow-y: auto;
}
.panel {
  display: grid;
  align-content: start;
  gap: var(--space-5);
  padding: var(--space-5);
}
.panelHead {
  display: grid;
  gap: var(--space-1);
}
.panelEyebrow {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-11);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
}
.panelTitle {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-15);
  font-weight: var(--weight-medium);
}
.inlineIcon {
  inline-size: 14px;
  block-size: 14px;
  color: var(--ink-3);
}
.panelNote {
  margin: 0;
  color: var(--ink-3);
  font-size: var(--text-12);
  line-height: var(--leading-snug);
}
.panelSection {
  display: grid;
  gap: var(--space-3);
  padding-top: var(--space-4);
  border-top: 1px solid var(--line);
}
.panelHeading {
  margin: 0;
  font-size: var(--text-13);
  font-weight: var(--weight-medium);
}
.panelButtons {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
}
.panelAction {
  justify-self: start;
}
.port {
  display: grid;
  gap: var(--space-2);
}
.portName {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  font-size: var(--text-12);
  font-weight: var(--weight-medium);
}
.portType {
  color: var(--ink-3);
  font-family: var(--font-mono);
  font-size: var(--text-11);
  font-weight: var(--weight-regular);
}
.links {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  padding-inline-start: var(--space-3);
  border: 1px solid var(--line);
  border-radius: var(--radius-2);
  color: var(--ink-2);
  font-size: var(--text-12);
}
.link svg {
  inline-size: 14px;
  block-size: 14px;
}
.linkNone,
.linkMissing {
  margin: 0;
  font-size: var(--text-12);
}
.linkNone {
  color: var(--ink-3);
}
.linkMissing {
  color: var(--warn);
}
.issues {
  display: grid;
  gap: var(--space-1);
  margin: 0;
  padding: 0;
  list-style: none;
}
.issue {
  display: grid;
  gap: 1px;
  inline-size: 100%;
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--line);
  border-left: 2px solid var(--warn);
  border-radius: var(--radius-2);
  background: none;
  color: var(--ink-2);
  font: inherit;
  font-size: var(--text-12);
  text-align: start;
  cursor: pointer;
}
.issue:hover {
  background: var(--surface-hover);
}
.issue:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}
.issueNode {
  color: var(--ink);
  font-weight: var(--weight-medium);
}
.keys {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-2) var(--space-4);
  margin: 0;
  font-size: var(--text-12);
}
.keys dt {
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: var(--text-11);
}
.keys dd {
  margin: 0;
  color: var(--ink-3);
}

/* Announced, not shown: the canvas itself shows what changed. */
.status {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* Medium: the inspector moves below the palette and canvas. */
@container (max-width: 1040px) {
  .frame {
    grid-template-columns: 200px minmax(0, 1fr);
    grid-template-areas:
      "toolbar toolbar"
      "palette canvas"
      "inspector inspector";
  }
  .readOnly {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "toolbar"
      "canvas"
      "inspector";
  }
  .inspector {
    max-block-size: none;
    border-left: 0;
    border-top: 1px solid var(--line);
  }
}

/* Narrow: the canvas first, then the palette as a strip, then the
   inspector. */
@container (max-width: 640px) {
  .frame,
  .readOnly {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "toolbar"
      "canvas"
      "palette"
      "inspector";
  }
  .canvas {
    block-size: 440px;
  }
  .palette {
    grid-auto-flow: column;
    grid-auto-columns: minmax(200px, 1fr);
    max-block-size: none;
    overflow-x: auto;
    border-right: 0;
    border-top: 1px solid var(--line);
  }
  .inspector {
    max-block-size: none;
    border-left: 0;
    border-top: 1px solid var(--line);
  }
}

components/workspaces/_kernel/workflow.ts

/* A workflow: nodes of declared kinds, joined port to port. Plain data and
   pure functions, framework-free and identical in both apps; the canvas,
   palette and inspector only render it and call these. Every change returns
   a new workflow, so history can keep the old one. */

/** What a port carries. "any" connects to every type. */
export type PortType = string;

export type PortSpec = Readonly<{
  id: string;
  label: string;
  type: PortType;
  /** An input needs a connection before the workflow is complete. */
  required?: boolean;
  /** How many connections the port takes: inputs one by default, outputs
   *  many. */
  multiple?: boolean;
}>;

type FieldBase = Readonly<{
  key: string;
  label: string;
  hint?: string;
  required?: boolean;
}>;

/** A setting on a node, edited in the inspector. */
export type FieldSpec =
  | (FieldBase &
      Readonly<{ kind: "text"; placeholder?: string; multiline?: boolean }>)
  | (FieldBase &
      Readonly<{ kind: "number"; min?: number; max?: number; step?: number }>)
  | (FieldBase &
      Readonly<{
        kind: "select";
        options: readonly Readonly<{ value: string; label: string }>[];
      }>)
  | (FieldBase & Readonly<{ kind: "toggle" }>);

/** One kind of node the palette offers: its ports and its settings. */
export type NodeKind = Readonly<{
  kind: string;
  label: string;
  /** Groups the palette: "Triggers", "Logic", "Actions"… */
  category: string;
  description?: string;
  /** A name from the icon set, drawn by the renderer. */
  icon?: string;
  tone?: "accent" | "info" | "warn" | "crit" | "neutral";
  inputs: readonly PortSpec[];
  outputs: readonly PortSpec[];
  fields: readonly FieldSpec[];
  defaults?: Readonly<Record<string, unknown>>;
  /** At most this many in one workflow (say, one trigger). */
  max?: number;
}>;

export type Point = Readonly<{ x: number; y: number }>;

export type WorkflowNode = Readonly<{
  id: string;
  kind: string;
  position: Point;
  /** A name the author gave it; the kind's label otherwise. */
  label?: string;
  data: Readonly<Record<string, unknown>>;
}>;

export type WorkflowEdge = Readonly<{
  id: string;
  source: string;
  sourcePort: string;
  target: string;
  targetPort: string;
}>;

export type Workflow = Readonly<{
  nodes: readonly WorkflowNode[];
  edges: readonly WorkflowEdge[];
}>;

export type Connection = Omit<WorkflowEdge, "id">;

export type Rules = Readonly<{
  /** Allow an edge that closes a loop. Off by default: most workflows
   *  run once, start to end. */
  allowCycles?: boolean;
}>;

export const EMPTY: Workflow = { nodes: [], edges: [] };

export type Kinds = ReadonlyMap<string, NodeKind>;

export const kindMap = (kinds: readonly NodeKind[]): Kinds =>
  new Map(kinds.map((kind) => [kind.kind, kind]));

/** A node's name as a person reads it. */
export const nodeName = (node: WorkflowNode, kinds: Kinds) =>
  node.label?.trim() || kinds.get(node.kind)?.label || node.kind;

// ── Changes ──────────────────────────────────────────────────────────────

/** Adds a node of `kind` at `position`, with the kind's defaults. Null when
 *  the kind is unknown or already at its maximum. */
export function addNode(
  workflow: Workflow,
  kinds: Kinds,
  kind: string,
  position: Point,
  id: string,
): Workflow | null {
  const spec = kinds.get(kind);
  if (!spec) return null;
  if (spec.max !== undefined && count(workflow, kind) >= spec.max) return null;
  return {
    ...workflow,
    nodes: [
      ...workflow.nodes,
      { id, kind, position, data: { ...(spec.defaults ?? {}) } },
    ],
  };
}

/** How many nodes of `kind` the workflow has. */
export const count = (workflow: Workflow, kind: string) =>
  workflow.nodes.filter((node) => node.kind === kind).length;

/** Removes nodes, and every edge touching them. */
export function removeNodes(
  workflow: Workflow,
  ids: Iterable<string>,
): Workflow {
  const gone = new Set(ids);
  return {
    nodes: workflow.nodes.filter((node) => !gone.has(node.id)),
    edges: workflow.edges.filter(
      (edge) => !gone.has(edge.source) && !gone.has(edge.target),
    ),
  };
}

export function removeEdges(
  workflow: Workflow,
  ids: Iterable<string>,
): Workflow {
  const gone = new Set(ids);
  return {
    ...workflow,
    edges: workflow.edges.filter((edge) => !gone.has(edge.id)),
  };
}

/** Moves nodes to new positions; unknown ids are ignored. */
export function moveNodes(
  workflow: Workflow,
  positions: ReadonlyMap<string, Point>,
): Workflow {
  return {
    ...workflow,
    nodes: workflow.nodes.map((node) => {
      const to = positions.get(node.id);
      return to ? { ...node, position: { x: to.x, y: to.y } } : node;
    }),
  };
}

/** Changes a node's name or settings. */
export function updateNode(
  workflow: Workflow,
  id: string,
  change: Readonly<{
    label?: string;
    data?: Readonly<Record<string, unknown>>;
  }>,
): Workflow {
  return {
    ...workflow,
    nodes: workflow.nodes.map((node) =>
      node.id === id
        ? {
            ...node,
            ...(change.label !== undefined ? { label: change.label } : {}),
            ...(change.data ? { data: { ...node.data, ...change.data } } : {}),
          }
        : node,
    ),
  };
}

// ── Connections ──────────────────────────────────────────────────────────

/** Why a connection is refused. */
export type Refusal =
  | "same_node"
  | "unknown_port"
  | "type_mismatch"
  | "input_taken"
  | "output_taken"
  | "duplicate"
  | "cycle";

export const REFUSALS: Readonly<Record<Refusal, string>> = {
  same_node: "A node cannot connect to itself.",
  unknown_port: "That port does not exist.",
  type_mismatch: "Those ports carry different kinds of data.",
  input_taken: "That input already has a connection.",
  output_taken: "That output already has a connection.",
  duplicate: "Those ports are already connected.",
  cycle: "That connection would make a loop.",
};

const portOf = (
  workflow: Workflow,
  kinds: Kinds,
  nodeId: string,
  portId: string,
  side: "inputs" | "outputs",
) => {
  const node = workflow.nodes.find((item) => item.id === nodeId);
  const spec = node ? kinds.get(node.kind) : undefined;
  return spec?.[side].find((port) => port.id === portId);
};

const typesMatch = (a: PortType, b: PortType) =>
  a === "any" || b === "any" || a === b;

/** Whether `to` can be reached from `from` along edges. */
function reaches(workflow: Workflow, from: string, to: string) {
  const next = new Map<string, string[]>();
  for (const edge of workflow.edges)
    next.set(edge.source, [...(next.get(edge.source) ?? []), edge.target]);
  const seen = new Set<string>();
  const stack = [from];
  while (stack.length) {
    const id = stack.pop()!;
    if (id === to) return true;
    if (seen.has(id)) continue;
    seen.add(id);
    stack.push(...(next.get(id) ?? []));
  }
  return false;
}

/** Why `connection` would be refused, or null when it is allowed. Always
 *  from an output to an input. */
export function checkConnection(
  workflow: Workflow,
  kinds: Kinds,
  connection: Connection,
  rules: Rules = {},
): Refusal | null {
  const { source, sourcePort, target, targetPort } = connection;
  if (source === target) return "same_node";
  const out = portOf(workflow, kinds, source, sourcePort, "outputs");
  const into = portOf(workflow, kinds, target, targetPort, "inputs");
  if (!out || !into) return "unknown_port";
  if (!typesMatch(out.type, into.type)) return "type_mismatch";
  const same = (edge: WorkflowEdge) =>
    edge.source === source &&
    edge.sourcePort === sourcePort &&
    edge.target === target &&
    edge.targetPort === targetPort;
  if (workflow.edges.some(same)) return "duplicate";
  if (
    !into.multiple &&
    workflow.edges.some(
      (edge) => edge.target === target && edge.targetPort === targetPort,
    )
  )
    return "input_taken";
  if (
    out.multiple === false &&
    workflow.edges.some(
      (edge) => edge.source === source && edge.sourcePort === sourcePort,
    )
  )
    return "output_taken";
  if (!rules.allowCycles && reaches(workflow, target, source)) return "cycle";
  return null;
}

/** Joins two ports, or says why not. */
export function connect(
  workflow: Workflow,
  kinds: Kinds,
  connection: Connection,
  id: string,
  rules: Rules = {},
):
  { workflow: Workflow; refusal: null } | { workflow: null; refusal: Refusal } {
  const refusal = checkConnection(workflow, kinds, connection, rules);
  return refusal
    ? { workflow: null, refusal }
    : {
        workflow: {
          ...workflow,
          edges: [...workflow.edges, { id, ...connection }],
        },
        refusal: null,
      };
}

/** Every input an output could connect to now: the keyboard's way to draw
 *  an edge, and the canvas's way to highlight valid drops. */
export function compatibleTargets(
  workflow: Workflow,
  kinds: Kinds,
  source: string,
  sourcePort: string,
  rules: Rules = {},
) {
  const targets: { node: WorkflowNode; port: PortSpec }[] = [];
  for (const node of workflow.nodes) {
    for (const port of kinds.get(node.kind)?.inputs ?? []) {
      const connection = {
        source,
        sourcePort,
        target: node.id,
        targetPort: port.id,
      };
      if (!checkConnection(workflow, kinds, connection, rules))
        targets.push({ node, port });
    }
  }
  return targets;
}

// ── Checking ─────────────────────────────────────────────────────────────

export type Issue = Readonly<{
  nodeId: string;
  /** The field or input the issue is about, when it is one. */
  field?: string;
  port?: string;
  message: string;
}>;

const empty = (value: unknown) =>
  value === undefined ||
  value === null ||
  (typeof value === "string" && value.trim() === "");

/** What keeps the workflow from being complete: required settings left
 *  empty, required inputs left unconnected, kinds this build does not know. */
export function issues(workflow: Workflow, kinds: Kinds): Issue[] {
  const found: Issue[] = [];
  for (const node of workflow.nodes) {
    const spec = kinds.get(node.kind);
    if (!spec) {
      found.push({
        nodeId: node.id,
        message: `Unknown kind of node: ${node.kind}.`,
      });
      continue;
    }
    for (const field of spec.fields)
      if (field.required && empty(node.data[field.key]))
        found.push({
          nodeId: node.id,
          field: field.key,
          message: `${field.label} is required.`,
        });
    for (const port of spec.inputs)
      if (
        port.required &&
        !workflow.edges.some(
          (edge) => edge.target === node.id && edge.targetPort === port.id,
        )
      )
        found.push({
          nodeId: node.id,
          port: port.id,
          message: `${port.label} needs a connection.`,
        });
  }
  return found;
}

components/workspaces/_kernel/history.ts

/* Undo and redo for any value kept whole: each change is a new value, and
   the history keeps the ones before it. A burst of the same change (one
   drag, one field being typed in) is kept as one step. */

export type History<T> = Readonly<{
  past: readonly T[];
  present: T;
  future: readonly T[];
  /** What the last step was, and when: a same-key change soon after is
   *  folded into it. */
  last: Readonly<{ key: string; at: number }> | null;
}>;

/** Steps kept; the oldest go first. */
export const LIMIT = 100;
/** How close in time a same-key change must be to fold into the last. */
export const WINDOW_MS = 800;

export const start = <T>(present: T): History<T> => ({
  past: [],
  present,
  future: [],
  last: null,
});

/** Records `next` as the present. With a `key` (say, "move:n1"), a change
 *  within the window after one with the same key replaces it rather than
 *  adding a step. A change drops anything that was undone. */
export function commit<T>(
  history: History<T>,
  next: T,
  key: string | null = null,
  now = Date.now(),
): History<T> {
  if (next === history.present) return history;
  const fold =
    key !== null &&
    history.last?.key === key &&
    now - history.last.at <= WINDOW_MS &&
    history.past.length > 0;
  return {
    past: fold
      ? history.past
      : [...history.past, history.present].slice(-LIMIT),
    present: next,
    future: [],
    last: key === null ? null : { key, at: now },
  };
}

export const canUndo = <T>(history: History<T>) => history.past.length > 0;
export const canRedo = <T>(history: History<T>) => history.future.length > 0;

export function undo<T>(history: History<T>): History<T> {
  if (!canUndo(history)) return history;
  return {
    past: history.past.slice(0, -1),
    present: history.past[history.past.length - 1],
    future: [history.present, ...history.future],
    last: null,
  };
}

export function redo<T>(history: History<T>): History<T> {
  if (!canRedo(history)) return history;
  return {
    past: [...history.past, history.present],
    present: history.future[0],
    future: history.future.slice(1),
    last: null,
  };
}

components/workspaces/_kernel/layout.ts

import { longestPath } from "../../charts/_kernel/graph";
import type { Point, Workflow } from "./workflow";

/* Tidy: lays a workflow out left to right. Each node goes in the column of
   its longest path from a start (loops broken first, as for the flow chart);
   within a column, nodes are ordered to sit near what they connect to, which
   uncrosses most edges; columns are centred on each other. */

export type Size = Readonly<{ width: number; height: number }>;

export type LayoutOptions = Readonly<{
  /** Space between columns, and between nodes in a column. */
  gapX?: number;
  gapY?: number;
  origin?: Point;
  /** A node without a measured size is taken to be this big. */
  fallback?: Size;
}>;

/** Ordering passes, alternating down and up the columns. */
const SWEEPS = 4;

export function autoLayout(
  workflow: Workflow,
  sizes: ReadonlyMap<string, Size>,
  options: LayoutOptions = {},
): Map<string, Point> {
  const { gapX = 80, gapY = 32, origin = { x: 0, y: 0 } } = options;
  const fallback = options.fallback ?? { width: 220, height: 96 };
  const size = (id: string) => sizes.get(id) ?? fallback;
  const nodes = workflow.nodes;
  if (nodes.length === 0) return new Map();

  const depth = longestPath(
    nodes,
    workflow.edges.map((edge) => ({ from: edge.source, to: edge.target })),
  );
  const columns: string[][] = [];
  // Start from the current top-to-bottom order: tidy stays close to it.
  const byY = [...nodes].sort(
    (a, b) => a.position.y - b.position.y || a.position.x - b.position.x,
  );
  for (const node of byY)
    (columns[depth.get(node.id) ?? 0] ??= []).push(node.id);
  const filled = columns.filter((column) => column && column.length);

  const before = new Map<string, string[]>();
  const after = new Map<string, string[]>();
  for (const edge of workflow.edges) {
    after.set(edge.source, [...(after.get(edge.source) ?? []), edge.target]);
    before.set(edge.target, [...(before.get(edge.target) ?? []), edge.source]);
  }
  const index = new Map<string, number>();
  const reindex = () =>
    filled.forEach((column) => column.forEach((id, i) => index.set(id, i)));
  reindex();
  // Each node's place in its column: the average place of its neighbours in
  // the column before (sweeping down) or after (sweeping up).
  const order = (column: string[], neighbours: Map<string, string[]>) => {
    const weight = (id: string) => {
      const near = (neighbours.get(id) ?? []).filter((n) => index.has(n));
      return near.length
        ? near.reduce((sum, n) => sum + index.get(n)!, 0) / near.length
        : index.get(id)!;
    };
    const weights = new Map(column.map((id) => [id, weight(id)]));
    column.sort(
      (a, b) =>
        weights.get(a)! - weights.get(b)! || index.get(a)! - index.get(b)!,
    );
    column.forEach((id, i) => index.set(id, i));
  };
  for (let pass = 0; pass < SWEEPS; pass += 1) {
    if (pass % 2 === 0)
      for (let c = 1; c < filled.length; c += 1) order(filled[c], before);
    else
      for (let c = filled.length - 2; c >= 0; c -= 1) order(filled[c], after);
  }

  const heights = filled.map(
    (column) =>
      column.reduce((sum, id) => sum + size(id).height, 0) +
      gapY * (column.length - 1),
  );
  const tallest = Math.max(...heights);
  const positions = new Map<string, Point>();
  let x = origin.x;
  filled.forEach((column, c) => {
    const width = Math.max(...column.map((id) => size(id).width));
    let y = origin.y + (tallest - heights[c]) / 2;
    for (const id of column) {
      positions.set(id, { x: Math.round(x), y: Math.round(y) });
      y += size(id).height + gapY;
    }
    x += width + gapX;
  });
  return positions;
}

KanbanBoard

drag cards · drag columns · limits · keyboard moves

Backlog

3 cards
  1. Usage alerts at 80% of plan

    Email owners before a workspace hits its limit.

    • Billing
    Medium
    Medium priority, tagged Billing
  2. SAML sign-in

    • Auth
    • Enterprise
    Low
    Low priority, tagged Auth, Enterprise
  3. Dark mode for the docs site

    • Docs
    tagged Docs

Ready

2 cards
  1. Invitation emails show the inviter

    Name the workspace and who invited, not just a link.

    • Invites
    HighOct 9Ivy Invitee
    High priority, assigned to Ivy Invitee, overdue, was due Oct 9, tagged Invites
  2. Audit log: export as JSON

    • Audit
    LowMax Member
    Low priority, assigned to Max Member, tagged Audit

In progress

3 / 3 cards
  1. Resumable uploads

    Large files continue after a dropped connection.

    • Files
    HighOct 2Ada Admin
    High priority, assigned to Ada Admin, overdue, was due Oct 2, tagged Files
  2. Rotate webhook signing secrets

    • Security
    UrgentSep 24Olivia Owner
    Urgent priority, assigned to Olivia Owner, overdue, was due Sep 24, tagged Security
  3. Trash empties after 30 days

    • Files
    Max Member
    assigned to Max Member, tagged Files

Review

1 / 2 card
  1. Two-step sign-in recovery codes

    • Auth
    MediumSep 30Ada Admin
    Medium priority, assigned to Ada Admin, overdue, was due Sep 30, tagged Auth

Done

1 card
  1. Workspace switcher remembers the last one

    Olivia Owner
    assigned to Olivia Owner

Press Enter to open. Press Space to pick up, then use the arrow keys to move and Space to drop.

Stages have rules. Cards reach Done only through Review, and In progress is at its limit of three: drag a Ready card there and the column steps back and refuses it. The reason is announced, as it is for every refusal.

Moving without a pointer. Tab to a card and press Space to pick it up; the arrows move it, announcing column and position; Space drops it as one undo step and Escape puts it back. Enter opens the card, where its column can also be changed.

Filtering pauses moving. A filtered column has no honest place to drop into, so cards stay put until the filter is cleared.

Sourcecomponents/workspaces/kanban-board/doc.ts · components/workspaces/kanban-board/kanban-board.tsx · components/workspaces/kanban-board/board-column.tsx · components/workspaces/kanban-board/board-card.tsx · components/workspaces/kanban-board/board-dialogs.tsx · components/workspaces/kanban-board/kanban-board.module.css · components/workspaces/_kernel/board.ts

components/workspaces/kanban-board/doc.ts

/**
 * KanbanBoard — cards moved between stages, and stages put in order.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the board
 *     defaultValue?  { columns: { id, title, limit?, cards }[] }  (empty)
 *                      card     { id, title, description?, tags?, assignee?,
 *                               due? (YYYY-MM-DD), priority? }
 *     onChange?      every committed change, to persist
 *     rules?         { transitions? (column → columns it may send cards to),
 *                    enforceLimits? }
 *     readOnly?      look and filter; nothing moves or changes
 *     empty?         what a board with no columns says
 *
 * # Behaviour
 *
 * R1  Columns sit side by side and the board scrolls sideways inside
 *     itself, never the page; a long column scrolls on its own. Dragging
 *     near an edge scrolls.
 * R2  A card is dragged within its column or into another, landing above or
 *     below the card it is dropped on, or at the end of a column. A line
 *     shows where. A column is dragged by its header, landing left or right
 *     of another.
 * R3  A move is refused — the drop does nothing and the reason is
 *     announced — when the card's column may not send cards there
 *     (transitions), or the column is at its limit and limits are enforced.
 *     While a card is dragged, columns that would refuse it step back. Moving
 *     within a column is always allowed.
 * R4  A column shows its count, and its limit when it has one; over the
 *     limit it is marked (in words as well as colour).
 * R5  Every move has a keyboard way. A card is one tab stop: Enter opens it;
 *     Space picks it up, the arrows move it (up and down within the column;
 *     left and right to the nearest column that takes it), Space or Enter
 *     drops it as one change, Escape puts it back. Each step is announced
 *     as column and position. Column settings move a column left or right.
 * R6  A card opens in a dialog that edits title, description, column,
 *     priority, assignee, due date and tags in place; columns that would
 *     refuse it are disabled there, with the reason. A column's dialog edits
 *     its name and limit, and deletes it only once it is empty.
 * R7  Cards are added at the end of a column, one after another, with Enter.
 * R8  The filter matches title, description, tags and assignee, and shows
 *     how many of how many match. While it is on, cards do not move (a
 *     partial column has no honest place to drop into).
 * R9  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change; typing
 *     in one field is one step. onChange receives committed changes only:
 *     not a card mid keyboard move, not the starting value.
 * R10 A card's facts are one sentence for assistive technology ("High
 *     priority, assigned to Ada, overdue, was due 3 Oct"); an overdue date
 *     is marked in words as well as colour. Due dates are calendar days and
 *     never shift with time zone.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Moves, refusals, drop places, keyboard steps and the other changes are
 * the framework-free kernel in ../_kernel/board.ts (identical in both apps,
 * tested there), with history from ../_kernel/history.ts; this renderer only
 * draws the board and calls it. Dragging is Atlassian's pragmatic drag and
 * drop — plain DOM, so both apps use the same engine: each card and column
 * registers as a draggable and a drop target, with the closest edge
 * attached, and one monitor on the board draws the landing line and
 * commits the drop. Handlers read the board through a ref, so registrations
 * are not redone on every change. A keyboard move previews the board
 * without touching history, and keeps focus on the card as it changes
 * column (where it is a new element).
 */
export {};

components/workspaces/kanban-board/kanban-board.tsx

"use client";

import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
  extractClosestEdge,
  type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { Button } from "@/components/forms/button";
import { Input } from "@/components/forms/input";
import { Plus, Redo2, Search, Undo2 } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  addCard,
  addColumn,
  BOARD_REFUSALS,
  cardCount,
  checkMove,
  columnDropIndex,
  dropPlace,
  EMPTY_BOARD,
  locate,
  matches,
  moveCard,
  moveColumn,
  nextBoardId,
  removeCard,
  removeColumn,
  step,
  updateCard,
  updateColumn,
  type Board,
  type BoardRules,
  type Direction,
} from "../_kernel/board";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import { CardDialog, ColumnDialog } from "./board-dialogs";
import { BoardColumnView, type Indicator } from "./board-column";
import { EngineContext, isCard, isColumn, type Engine } from "./engine";
import styles from "./kanban-board.module.css";

export type KanbanBoardProps = {
  /** Names the board for assistive technology. */
  label: string;
  /** Where editing starts. The board keeps its own history from here. */
  defaultValue?: Board;
  /** Every committed change: a drop, an edit, a new card. Persist here. */
  onChange?: (board: Board) => void;
  rules?: BoardRules;
  /** Look and filter; nothing moves or changes. */
  readOnly?: boolean;
  /** Shown when the board has no columns. */
  empty?: ReactNode;
  className?: string;
};

const DIRECTIONS: Readonly<Record<string, Direction>> = {
  ArrowUp: "up",
  ArrowDown: "down",
  ArrowLeft: "left",
  ArrowRight: "right",
};

const isTyping = (target: EventTarget | null) =>
  target instanceof HTMLElement &&
  (target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));

/** Where a card is, as a person hears it. */
function whereIs(board: Board, cardId: string) {
  const found = locate(board, cardId);
  return found
    ? `${found.column.title}, position ${found.index + 1} of ${found.column.cards.length}`
    : "";
}

/** A board of stages: cards dragged between and within columns, columns
 *  dragged into order; see doc.ts. */
export function KanbanBoard({
  label,
  defaultValue = EMPTY_BOARD,
  onChange,
  rules = {},
  readOnly = false,
  empty = "No columns yet. Add one to start.",
  className,
}: KanbanBoardProps) {
  const id = useId();
  const scroller = useRef<HTMLDivElement>(null);
  const root = useRef<HTMLDivElement>(null);

  const [history, setHistory] = useState<History<Board>>(() =>
    start(defaultValue),
  );
  const change = useCallback(
    (next: Board | null, key: string | null = null) => {
      if (next) setHistory((current) => commit(current, next, key));
    },
    [],
  );
  // Tell the caller about committed changes, not the starting value.
  const reported = useRef(history.present);
  useEffect(() => {
    if (reported.current === history.present) return;
    reported.current = history.present;
    onChange?.(history.present);
  }, [history.present, onChange]);

  // A keyboard move shows the card travelling without committing; the
  // drop commits once, as one undo step.
  const [grab, setGrab] = useState<{ cardId: string; preview: Board } | null>(
    null,
  );
  const board = grab?.preview ?? history.present;

  const [query, setQuery] = useState("");
  const filtered = query.trim() !== "";
  const [dragging, setDragging] = useState<string | null>(null);
  const [indicator, setIndicator] = useState<Indicator>(null);
  const [editing, setEditing] = useState<{
    kind: "card" | "column";
    id: string;
  } | null>(null);
  const [message, setMessage] = useState("");

  // What drag handlers read when a drag happens; see engine.ts.
  const engine = useRef<Engine>({
    board,
    rules,
    movable: false,
    arrangeable: false,
  });
  useLayoutEffect(() => {
    engine.current = {
      board,
      rules,
      movable: !readOnly && !filtered && !grab,
      arrangeable: !readOnly && !grab,
    };
  });

  // One monitor for the whole board: it draws where a drop would land and
  // commits the drop.
  useEffect(() => {
    const element = scroller.current;
    if (readOnly || !element) return;
    const edgeOf = (data: Record<string | symbol, unknown>) =>
      extractClosestEdge(data) as Edge | null;

    return combine(
      monitorForElements({
        canMonitor: ({ source }) =>
          isCard(source.data) || isColumn(source.data),
        onDragStart: ({ source }) => {
          if (isCard(source.data)) setDragging(source.data.cardId as string);
        },
        // Every move, not only a change of target: the edge a card or
        // column would land on changes within one target.
        onDrag: ({ location, source }) => {
          const target = location.current.dropTargets[0];
          let next: Indicator = null;
          if (target && isColumn(source.data)) {
            const edge = edgeOf(target.data);
            if (
              isColumn(target.data) &&
              target.data.columnId !== source.data.columnId &&
              (edge === "left" || edge === "right")
            )
              next = {
                kind: "column",
                columnId: target.data.columnId as string,
                edge,
              };
          } else if (target && isCard(target.data)) {
            const edge = edgeOf(target.data);
            if (
              target.data.cardId !== source.data.cardId &&
              (edge === "top" || edge === "bottom")
            )
              next = {
                kind: "card",
                cardId: target.data.cardId as string,
                edge,
              };
          } else if (target && isColumn(target.data)) {
            next = { kind: "end", columnId: target.data.columnId as string };
          }
          setIndicator((current) =>
            JSON.stringify(current) === JSON.stringify(next) ? current : next,
          );
        },
        onDrop: ({ location, source }) => {
          setDragging(null);
          setIndicator(null);
          const target = location.current.dropTargets[0];
          if (!target) return;
          const current = engine.current.board;
          if (isColumn(source.data)) {
            if (!isColumn(target.data)) return;
            const columnId = source.data.columnId as string;
            const index = columnDropIndex(
              current,
              columnId,
              target.data.columnId as string,
              edgeOf(target.data) as "left" | "right" | null,
            );
            const next = moveColumn(current, columnId, index);
            if (next !== current) {
              change(next);
              const title = next.columns[index]?.title;
              setMessage(
                `Moved ${title} to position ${index + 1} of ${next.columns.length}.`,
              );
            }
            return;
          }
          const cardId = source.data.cardId as string;
          const place = isCard(target.data)
            ? dropPlace(
                current,
                cardId,
                target.data.columnId as string,
                target.data.index as number,
                edgeOf(target.data) as "top" | "bottom" | null,
              )
            : dropPlace(
                current,
                cardId,
                target.data.columnId as string,
                null,
                null,
              );
          const before = locate(current, cardId);
          if (
            before &&
            before.column.id === place.columnId &&
            before.index === place.index
          )
            return;
          const result = moveCard(current, cardId, place, engine.current.rules);
          if (result.refusal) {
            setMessage(BOARD_REFUSALS[result.refusal]);
            return;
          }
          change(result.board);
          setMessage(
            `Moved ${before?.card.title} to ${whereIs(result.board, cardId)}.`,
          );
        },
      }),
      autoScrollForElements({ element }),
    );
  }, [engine, readOnly, change]);

  // A card moved by keyboard may land in another column, where it is a new
  // element: keep focus on it.
  useEffect(() => {
    if (!grab) return;
    root.current
      ?.querySelector<HTMLElement>(
        `[data-card-id="${CSS.escape(grab.cardId)}"]`,
      )
      ?.focus();
  }, [grab]);

  function onCardKey(cardId: string, event: KeyboardEvent<HTMLDivElement>) {
    if (!grab) {
      if (event.key === "Enter") {
        event.preventDefault();
        setEditing({ kind: "card", id: cardId });
      } else if (event.key === " ") {
        event.preventDefault();
        if (filtered) {
          setMessage("Clear the filter to move cards.");
          return;
        }
        setGrab({ cardId, preview: history.present });
        setMessage(
          `Picked up ${locate(history.present, cardId)?.card.title}. ${whereIs(history.present, cardId)}. Use the arrow keys to move it, Space to drop it, Escape to cancel.`,
        );
      }
      return;
    }
    if (grab.cardId !== cardId) return;
    const direction = DIRECTIONS[event.key];
    if (direction) {
      event.preventDefault();
      const next = step(grab.preview, cardId, direction, rules);
      if (next.place) {
        const moved = moveCard(grab.preview, cardId, next.place, rules);
        if (moved.board) {
          setGrab({ cardId, preview: moved.board });
          setMessage(whereIs(moved.board, cardId));
        }
      } else {
        setMessage(
          next.refusal
            ? BOARD_REFUSALS[next.refusal]
            : `${whereIs(grab.preview, cardId)}. It cannot go further that way.`,
        );
      }
    } else if (event.key === " " || event.key === "Enter") {
      event.preventDefault();
      change(grab.preview === history.present ? null : grab.preview);
      setGrab(null);
      setMessage(
        `Dropped ${locate(grab.preview, cardId)?.card.title} in ${whereIs(grab.preview, cardId)}.`,
      );
    } else if (event.key === "Escape") {
      event.preventDefault();
      setGrab(null);
      setMessage(
        `Cancelled. ${locate(history.present, cardId)?.card.title} is back in ${whereIs(history.present, cardId)}.`,
      );
    }
  }

  function historyStep(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (readOnly || grab || editing || isTyping(event.target)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      historyStep(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      historyStep("redo");
    }
  }

  const present = history.present;
  const editingColumn =
    editing?.kind === "column"
      ? present.columns.find((column) => column.id === editing.id)
      : undefined;
  const total = cardCount(present);
  const shown = present.columns.reduce(
    (sum, column) =>
      sum + column.cards.filter((card) => matches(card, query)).length,
    0,
  );

  return (
    <EngineContext.Provider value={engine}>
      <div
        ref={root}
        className={cn(styles.board, className)}
        onKeyDown={onKeyDown}
        // A press anywhere else ends a keyboard move, as Escape does.
        onPointerDown={() => grab && setGrab(null)}
      >
        <div
          className={styles.toolbar}
          role="toolbar"
          aria-label={`${label} tools`}
        >
          <div className={styles.search}>
            <Search className={styles.searchIcon} aria-hidden="true" />
            <Input
              type="search"
              size="sm"
              aria-label="Filter cards"
              placeholder="Filter cards…"
              value={query}
              onChange={(event) => setQuery(event.target.value)}
            />
          </div>
          <span className={styles.summary} role="status">
            {filtered
              ? `${shown} of ${total} ${total === 1 ? "card" : "cards"}`
              : `${total} ${total === 1 ? "card" : "cards"}`}
          </span>
          {readOnly ? null : (
            <span className={styles.toolbarEnd}>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Undo"
                title="Undo (⌘Z)"
                disabled={!canUndo(history) || !!grab}
                onClick={() => historyStep("undo")}
              >
                <Undo2 aria-hidden="true" />
              </Button>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Redo"
                title="Redo (⇧⌘Z)"
                disabled={!canRedo(history) || !!grab}
                onClick={() => historyStep("redo")}
              >
                <Redo2 aria-hidden="true" />
              </Button>
              <Button
                size="sm"
                variant="quiet"
                onClick={() => {
                  const columnId = nextBoardId(present, "s");
                  change(
                    addColumn(present, {
                      id: columnId,
                      title: "New column",
                      cards: [],
                    }),
                  );
                  setEditing({ kind: "column", id: columnId });
                  setMessage("Added a column.");
                }}
              >
                <Plus aria-hidden="true" />
                Add column
              </Button>
            </span>
          )}
        </div>
        {filtered && !readOnly ? (
          <p className={styles.filterNote}>Clear the filter to move cards.</p>
        ) : null}

        <div
          ref={scroller}
          className={styles.scroller}
          role="region"
          aria-label={label}
          data-dragging={dragging !== null || undefined}
        >
          {board.columns.length ? (
            <div className={styles.columns}>
              {board.columns.map((column) => (
                <BoardColumnView
                  key={column.id}
                  column={column}
                  cards={
                    filtered
                      ? column.cards.filter((card) => matches(card, query))
                      : column.cards
                  }
                  readOnly={readOnly}
                  filtered={filtered}
                  refused={
                    dragging !== null &&
                    checkMove(board, dragging, column.id, rules) !== null
                  }
                  grabbedCard={grab?.cardId ?? null}
                  indicator={indicator}
                  hintId={`${id}-hint`}
                  onOpenCard={(cardId) =>
                    setEditing({ kind: "card", id: cardId })
                  }
                  onCardKey={onCardKey}
                  onAddCard={(title) => {
                    const cardId = nextBoardId(present, "c");
                    change(addCard(present, column.id, { id: cardId, title }));
                    setMessage(`Added ${title} to ${column.title}.`);
                  }}
                  onSettings={() =>
                    setEditing({ kind: "column", id: column.id })
                  }
                />
              ))}
            </div>
          ) : (
            <div className={styles.empty}>{empty}</div>
          )}
        </div>

        <p id={`${id}-hint`} className={styles.hidden}>
          Press Enter to open. Press Space to pick up, then use the arrow keys
          to move and Space to drop.
        </p>
        <p className={styles.hidden} role="status">
          {message}
        </p>

        {editing?.kind === "card" ? (
          <CardDialog
            key={editing.id}
            board={present}
            cardId={editing.id}
            rules={rules}
            onChange={(value, key) =>
              change(updateCard(present, editing.id, value), key)
            }
            onMove={(columnId) => {
              const target = present.columns.find(
                (column) => column.id === columnId,
              );
              const result = moveCard(
                present,
                editing.id,
                { columnId, index: target?.cards.length ?? 0 },
                rules,
              );
              if (result.refusal) setMessage(BOARD_REFUSALS[result.refusal]);
              else {
                change(result.board);
                setMessage(`Moved to ${whereIs(result.board, editing.id)}.`);
              }
            }}
            onDelete={() => {
              const title = locate(present, editing.id)?.card.title;
              change(removeCard(present, editing.id));
              setEditing(null);
              setMessage(`Deleted ${title}. Undo brings it back.`);
            }}
            onClose={() => setEditing(null)}
          />
        ) : null}
        {editingColumn ? (
          <ColumnDialog
            board={present}
            column={editingColumn}
            onChange={(value, key) =>
              change(updateColumn(present, editingColumn.id, value), key)
            }
            onMove={(direction) => {
              const index = present.columns.indexOf(editingColumn) + direction;
              change(moveColumn(present, editingColumn.id, index));
              setMessage(
                `Moved ${editingColumn.title} to position ${index + 1} of ${present.columns.length}.`,
              );
            }}
            onDelete={() => {
              change(removeColumn(present, editingColumn.id));
              setEditing(null);
              setMessage(`Deleted the ${editingColumn.title} column.`);
            }}
            onClose={() => setEditing(null)}
          />
        ) : null}
      </div>
    </EngineContext.Provider>
  );
}

components/workspaces/kanban-board/board-column.tsx

"use client";

import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

import { Button } from "@/components/forms/button";
import { Input } from "@/components/forms/input";
import { GripVertical, Plus, Settings } from "@/components/utility/icon";
import {
  checkMove,
  overLimit,
  type BoardCard,
  type BoardColumn,
} from "../_kernel/board";
import { BoardCardView } from "./board-card";
import { isCard, isColumn, useEngine } from "./engine";
import styles from "./kanban-board.module.css";

/** Where something dragged over the board would land, as drawn. */
export type Indicator =
  | { kind: "card"; cardId: string; edge: "top" | "bottom" }
  | { kind: "end"; columnId: string }
  | { kind: "column"; columnId: string; edge: "left" | "right" }
  | null;

export type BoardColumnViewProps = {
  column: BoardColumn;
  /** Cards shown: all of them, or those the filter matches. */
  cards: readonly BoardCard[];
  readOnly: boolean;
  filtered: boolean;
  /** A card is being dragged that this column would refuse. */
  refused: boolean;
  grabbedCard: string | null;
  indicator: Indicator;
  hintId: string;
  onOpenCard: (cardId: string) => void;
  onCardKey: (cardId: string, event: KeyboardEvent<HTMLDivElement>) => void;
  onAddCard: (title: string) => void;
  onSettings: () => void;
};

/** One stage: its header (the handle for dragging the column), its cards,
 *  and a way to add one. */
export function BoardColumnView({
  column,
  cards,
  readOnly,
  filtered,
  refused,
  grabbedCard,
  indicator,
  hintId,
  onOpenCard,
  onCardKey,
  onAddCard,
  onSettings,
}: BoardColumnViewProps) {
  const engine = useEngine();
  const id = useId();
  const ref = useRef<HTMLElement>(null);
  const head = useRef<HTMLDivElement>(null);
  const list = useRef<HTMLOListElement>(null);
  const [adding, setAdding] = useState(false);
  const [draft, setDraft] = useState("");

  useEffect(() => {
    const element = ref.current;
    if (!element || !head.current || !list.current || readOnly) return;
    return combine(
      draggable({
        element,
        dragHandle: head.current,
        canDrag: () => engine.current.arrangeable,
        getInitialData: () => ({ type: "column", columnId: column.id }),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) =>
          isColumn(source.data) ||
          (isCard(source.data) &&
            !checkMove(
              engine.current.board,
              source.data.cardId as string,
              column.id,
              engine.current.rules,
            )),
        getData: ({ input, source }) => {
          const data = { type: "column", columnId: column.id };
          return isColumn(source.data)
            ? attachClosestEdge(data, {
                input,
                element,
                allowedEdges: ["left", "right"],
              })
            : data;
        },
        getIsSticky: () => true,
      }),
      autoScrollForElements({ element: list.current }),
    );
  }, [engine, column.id, readOnly]);

  const over = overLimit(column);
  const count = filtered
    ? `${cards.length} of ${column.cards.length}`
    : String(column.cards.length);

  function add() {
    const title = draft.trim();
    if (!title) return;
    onAddCard(title);
    setDraft("");
  }

  return (
    <section
      ref={ref}
      className={styles.column}
      aria-labelledby={`${id}-title`}
      data-refused={refused || undefined}
      data-indicator={
        indicator?.kind === "column" && indicator.columnId === column.id
          ? indicator.edge
          : undefined
      }
    >
      <div
        ref={head}
        className={styles.columnHead}
        data-handle={!readOnly || undefined}
      >
        {readOnly ? null : (
          <GripVertical className={styles.grip} aria-hidden="true" />
        )}
        <h3 id={`${id}-title`} className={styles.columnTitle}>
          {column.title}
        </h3>
        <span
          className={styles.count}
          data-over={over || undefined}
          title={
            column.limit !== undefined
              ? `${column.cards.length} of a limit of ${column.limit}`
              : undefined
          }
        >
          {count}
          {column.limit !== undefined ? ` / ${column.limit}` : ""}
          <span className={styles.hidden}>
            {column.cards.length === 1 ? " card" : " cards"}
            {over ? ", over the limit" : ""}
          </span>
        </span>
        {readOnly ? null : (
          <Button
            size="icon"
            variant="quiet"
            className={styles.columnMenu}
            aria-label={`${column.title} settings`}
            onClick={onSettings}
          >
            <Settings aria-hidden="true" />
          </Button>
        )}
      </div>

      <ol ref={list} className={styles.cards}>
        {cards.map((card, index) => (
          <li key={card.id}>
            <BoardCardView
              card={card}
              columnId={column.id}
              index={index}
              readOnly={readOnly}
              grabbed={grabbedCard === card.id}
              indicator={
                indicator?.kind === "card" && indicator.cardId === card.id
                  ? indicator.edge
                  : null
              }
              hintId={hintId}
              onOpen={() => onOpenCard(card.id)}
              onKey={(event) => onCardKey(card.id, event)}
            />
          </li>
        ))}
        {indicator?.kind === "end" && indicator.columnId === column.id ? (
          <li className={styles.endLine} aria-hidden="true" />
        ) : null}
        {cards.length === 0 ? (
          <li className={styles.columnEmpty}>
            {filtered && column.cards.length ? "No matching cards" : "No cards"}
          </li>
        ) : null}
      </ol>

      {readOnly ? null : adding ? (
        <div className={styles.composer}>
          <Input
            size="sm"
            autoFocus
            aria-label={`New card in ${column.title}`}
            placeholder="What needs doing?"
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") {
                event.preventDefault();
                add();
              } else if (event.key === "Escape") {
                setAdding(false);
                setDraft("");
              }
            }}
          />
          <div className={styles.composerButtons}>
            <Button
              size="sm"
              variant="primary"
              disabled={!draft.trim()}
              onClick={add}
            >
              Add card
            </Button>
            <Button
              size="sm"
              variant="quiet"
              onClick={() => {
                setAdding(false);
                setDraft("");
              }}
            >
              Done
            </Button>
          </div>
        </div>
      ) : (
        <Button
          size="sm"
          variant="quiet"
          className={styles.addCard}
          onClick={() => setAdding(true)}
        >
          <Plus aria-hidden="true" />
          Add card
        </Button>
      )}
    </section>
  );
}

components/workspaces/kanban-board/board-card.tsx

"use client";

import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { CalendarDays } from "@/components/utility/icon";
import { checkMove, type BoardCard } from "../_kernel/board";
import { isCard, useEngine } from "./engine";
import {
  describeCard,
  formatDue,
  overdue,
  PRIORITY_LABEL,
  PRIORITY_TONE,
} from "./format";
import styles from "./kanban-board.module.css";

export type BoardCardViewProps = {
  card: BoardCard;
  columnId: string;
  index: number;
  readOnly: boolean;
  /** Picked up with the keyboard. */
  grabbed: boolean;
  /** Where a dragged card would land against this one. */
  indicator: "top" | "bottom" | null;
  /** The id of the hidden text telling how to open and move a card. */
  hintId: string;
  onOpen: () => void;
  onKey: (event: KeyboardEvent<HTMLDivElement>) => void;
};

/** One card: a drag source and a drop target (above or below it). */
export function BoardCardView({
  card,
  columnId,
  index,
  readOnly,
  grabbed,
  indicator,
  hintId,
  onOpen,
  onKey,
}: BoardCardViewProps) {
  const engine = useEngine();
  const ref = useRef<HTMLDivElement>(null);
  const id = useId();
  const [dragging, setDragging] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element || readOnly) return;
    return combine(
      draggable({
        element,
        canDrag: () => engine.current.movable,
        getInitialData: () => ({ type: "card", cardId: card.id }),
        onDragStart: () => setDragging(true),
        onDrop: () => setDragging(false),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) =>
          isCard(source.data) &&
          !checkMove(
            engine.current.board,
            source.data.cardId as string,
            columnId,
            engine.current.rules,
          ),
        getData: ({ input }) =>
          attachClosestEdge(
            { type: "card", cardId: card.id, columnId, index },
            { input, element, allowedEdges: ["top", "bottom"] },
          ),
        getIsSticky: () => true,
      }),
    );
  }, [engine, card.id, columnId, index, readOnly]);

  const due = card.due ? formatDue(card.due) : null;
  const late = overdue(card);
  const description = describeCard(card);

  return (
    <div
      ref={ref}
      className={styles.card}
      data-card-id={card.id}
      data-dragging={dragging || undefined}
      data-grabbed={grabbed || undefined}
      data-indicator={indicator ?? undefined}
      {...(readOnly
        ? {}
        : {
            role: "button",
            tabIndex: 0,
            // Named by its title alone; the facts and the keys describe it.
            "aria-labelledby": `${id}-title`,
            "aria-describedby": description ? `${id}-facts ${hintId}` : hintId,
            "aria-pressed": grabbed ? true : undefined,
            onClick: onOpen,
            onKeyDown: onKey,
          })}
    >
      <p id={`${id}-title`} className={styles.cardTitle}>
        {card.title}
      </p>
      {card.description ? (
        <p className={styles.cardText}>{card.description}</p>
      ) : null}
      {card.tags?.length ? (
        <ul className={styles.tags} aria-label="Tags">
          {card.tags.map((tag) => (
            <li key={tag} className={styles.tag}>
              {tag}
            </li>
          ))}
        </ul>
      ) : null}
      {card.priority || due || card.assignee ? (
        <div className={styles.cardFoot}>
          {card.priority ? (
            <Badge tone={PRIORITY_TONE[card.priority]} dot>
              {PRIORITY_LABEL[card.priority]}
            </Badge>
          ) : null}
          {due ? (
            <span className={styles.due} data-late={late || undefined}>
              <CalendarDays aria-hidden="true" />
              {due}
            </span>
          ) : null}
          {card.assignee ? (
            <Avatar
              name={card.assignee}
              size="sm"
              className={styles.assignee}
            />
          ) : null}
        </div>
      ) : null}
      {/* The facts as a sentence, for a reader that cannot see the badges. */}
      {description ? (
        <span id={`${id}-facts`} className={styles.hidden}>
          {description}
        </span>
      ) : null}
    </div>
  );
}

components/workspaces/kanban-board/board-dialogs.tsx

"use client";

import { useState } from "react";

import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input, Textarea } from "@/components/forms/input";
import { NumberInput } from "@/components/forms/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/forms/select";
import {
  Dialog,
  DialogContent,
  DialogFooter,
} from "@/components/overlays/dialog";
import { ArrowLeft, ArrowRight, Trash2 } from "@/components/utility/icon";
import {
  checkMove,
  locate,
  PRIORITIES,
  type Board,
  type BoardCard,
  type BoardColumn,
  type BoardRules,
} from "../_kernel/board";
import { parseTags, PRIORITY_LABEL } from "./format";
import styles from "./kanban-board.module.css";

const NO_PRIORITY = "none";

export type CardDialogProps = {
  board: Board;
  cardId: string;
  rules: BoardRules;
  /** A change to the card: `key` folds a burst of typing into one undo
   *  step. */
  onChange: (change: Partial<Omit<BoardCard, "id">>, key: string) => void;
  onMove: (columnId: string) => void;
  onDelete: () => void;
  onClose: () => void;
};

/** A card's details, edited in place: every change applies at once and
 *  undoes like any other. Also the pointer-free, drag-free way to move a
 *  card to another column. */
export function CardDialog({
  board,
  cardId,
  rules,
  onChange,
  onMove,
  onDelete,
  onClose,
}: CardDialogProps) {
  const found = locate(board, cardId);
  // Tags are typed as text; the draft keeps what is typed ("a, ") while
  // the card keeps the parsed list.
  const [tags, setTags] = useState(() => found?.card.tags?.join(", ") ?? "");
  if (!found) return null;
  const { card, column } = found;

  return (
    <Dialog open onOpenChange={(open) => (open ? null : onClose())}>
      <DialogContent title="Edit card">
        <div className={styles.dialogBody}>
          <Field label="Title" required>
            {(control) => (
              <Input
                {...control}
                value={card.title}
                onChange={(event) =>
                  onChange({ title: event.target.value }, `title:${card.id}`)
                }
              />
            )}
          </Field>
          <Field label="Description">
            {(control) => (
              <Textarea
                {...control}
                rows={3}
                value={card.description ?? ""}
                onChange={(event) =>
                  onChange(
                    { description: event.target.value || undefined },
                    `description:${card.id}`,
                  )
                }
              />
            )}
          </Field>
          <div className={styles.dialogRow}>
            <Field label="Column">
              {(control) => (
                <Select value={column.id} onValueChange={onMove}>
                  <SelectTrigger {...control} />
                  <SelectContent>
                    {board.columns.map((item) => {
                      const refusal = checkMove(board, card.id, item.id, rules);
                      return (
                        <SelectItem
                          key={item.id}
                          value={item.id}
                          disabled={refusal !== null}
                        >
                          {item.title}
                          {refusal === "limit"
                            ? " (full)"
                            : refusal === "transition"
                              ? " (not from here)"
                              : ""}
                        </SelectItem>
                      );
                    })}
                  </SelectContent>
                </Select>
              )}
            </Field>
            <Field label="Priority">
              {(control) => (
                <Select
                  value={card.priority ?? NO_PRIORITY}
                  onValueChange={(value) =>
                    onChange(
                      {
                        priority:
                          value === NO_PRIORITY
                            ? undefined
                            : (value as BoardCard["priority"]),
                      },
                      `priority:${card.id}`,
                    )
                  }
                >
                  <SelectTrigger {...control} />
                  <SelectContent>
                    <SelectItem value={NO_PRIORITY}>None</SelectItem>
                    {PRIORITIES.map((priority) => (
                      <SelectItem key={priority} value={priority}>
                        {PRIORITY_LABEL[priority]}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              )}
            </Field>
          </div>
          <div className={styles.dialogRow}>
            <Field label="Assignee">
              {(control) => (
                <Input
                  {...control}
                  value={card.assignee ?? ""}
                  placeholder="Nobody"
                  onChange={(event) =>
                    onChange(
                      { assignee: event.target.value || undefined },
                      `assignee:${card.id}`,
                    )
                  }
                />
              )}
            </Field>
            <Field label="Due">
              {(control) => (
                <Input
                  {...control}
                  type="date"
                  value={card.due ?? ""}
                  onChange={(event) =>
                    onChange(
                      { due: event.target.value || undefined },
                      `due:${card.id}`,
                    )
                  }
                />
              )}
            </Field>
          </div>
          <Field label="Tags" hint="Separate tags with commas.">
            {(control) => (
              <Input
                {...control}
                value={tags}
                onChange={(event) => {
                  setTags(event.target.value);
                  const list = parseTags(event.target.value);
                  onChange(
                    { tags: list.length ? list : undefined },
                    `tags:${card.id}`,
                  );
                }}
              />
            )}
          </Field>
        </div>
        <DialogFooter>
          <Button variant="danger" size="sm" onClick={onDelete}>
            <Trash2 aria-hidden="true" />
            Delete card
          </Button>
          <Button variant="primary" size="sm" onClick={onClose}>
            Done
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export type ColumnDialogProps = {
  board: Board;
  column: BoardColumn;
  onChange: (
    change: { title?: string; limit?: number | null },
    key: string,
  ) => void;
  onMove: (direction: -1 | 1) => void;
  onDelete: () => void;
  onClose: () => void;
};

/** A column's name, limit and place: the keyboard's way to rearrange
 *  columns, too. */
export function ColumnDialog({
  board,
  column,
  onChange,
  onMove,
  onDelete,
  onClose,
}: ColumnDialogProps) {
  const index = board.columns.findIndex((item) => item.id === column.id);
  return (
    <Dialog open onOpenChange={(open) => (open ? null : onClose())}>
      <DialogContent title="Column settings">
        <div className={styles.dialogBody}>
          <Field label="Name" required>
            {(control) => (
              <Input
                {...control}
                value={column.title}
                onChange={(event) =>
                  onChange({ title: event.target.value }, `column:${column.id}`)
                }
              />
            )}
          </Field>
          <Field
            label="Limit"
            hint="The most cards this stage should hold at once. Empty for no limit."
          >
            {(control) => (
              <NumberInput
                {...control}
                min={1}
                max={99}
                value={column.limit ?? null}
                onValueChange={(value) =>
                  onChange({ limit: value }, `limit:${column.id}`)
                }
              />
            )}
          </Field>
          <div
            className={styles.dialogButtons}
            role="group"
            aria-label="Position"
          >
            <Button
              size="sm"
              variant="secondary"
              disabled={index <= 0}
              onClick={() => onMove(-1)}
            >
              <ArrowLeft aria-hidden="true" />
              Move left
            </Button>
            <Button
              size="sm"
              variant="secondary"
              disabled={index >= board.columns.length - 1}
              onClick={() => onMove(1)}
            >
              Move right
              <ArrowRight aria-hidden="true" />
            </Button>
          </div>
          {column.cards.length ? (
            <p className={styles.dialogNote}>
              Move or delete its {column.cards.length}{" "}
              {column.cards.length === 1 ? "card" : "cards"} to delete this
              column.
            </p>
          ) : null}
        </div>
        <DialogFooter>
          <Button
            variant="danger"
            size="sm"
            disabled={column.cards.length > 0}
            onClick={onDelete}
          >
            <Trash2 aria-hidden="true" />
            Delete column
          </Button>
          <Button variant="primary" size="sm" onClick={onClose}>
            Done
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

components/workspaces/kanban-board/kanban-board.module.css

@layer composition {
  .board {
    display: grid;
    gap: var(--space-3);
    min-inline-size: 0;
  }

  /* ── Toolbar ── */
  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
  }
  .toolbar svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .search {
    position: relative;
    inline-size: min(100%, 260px);
  }
  .search input {
    padding-inline-start: 30px;
  }
  .searchIcon {
    position: absolute;
    top: 50%;
    left: 10px;
    color: var(--ink-3);
    transform: translateY(-50%);
    pointer-events: none;
  }
  .summary {
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .toolbarEnd {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    margin-inline-start: auto;
  }
  .filterNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }

  /* ── Columns ── */
  /* The board scrolls sideways inside itself; the page never does. */
  .scroller {
    min-inline-size: 0;
    padding-block-end: var(--space-3);
    overflow-x: auto;
  }
  .columns {
    display: flex;
    align-items: flex-start;
    gap: var(--space-4);
    inline-size: max-content;
  }
  .column {
    position: relative;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
    inline-size: 280px;
    max-block-size: 640px;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-sunk);
    transition: opacity var(--dur-1) var(--ease);
  }
  /* While a card is dragged, a column that would refuse it steps back. */
  .scroller[data-dragging] .column[data-refused] {
    opacity: 0.45;
  }
  /* A dragged column lands before or after this one. */
  .column[data-indicator]::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    inline-size: 3px;
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .column[data-indicator="left"]::after {
    left: calc(-1 * var(--space-4) / 2 - 2px);
  }
  .column[data-indicator="right"]::after {
    right: calc(-1 * var(--space-4) / 2 - 2px);
  }
  .columnHead {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: 44px;
    padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  }
  .columnHead[data-handle] {
    padding-inline-start: var(--space-2);
    cursor: grab;
  }
  .grip {
    flex: none;
    inline-size: 14px;
    block-size: 14px;
    color: var(--ink-4);
  }
  .columnTitle {
    overflow: hidden;
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .count {
    flex: none;
    padding: 1px var(--space-2);
    border-radius: var(--radius-pill);
    background: var(--surface-hover);
    color: var(--ink-3);
    font-family: var(--font-mono);
    font-size: var(--text-11);
  }
  .count[data-over] {
    background: var(--warn-tint);
    color: var(--warn);
  }
  .columnMenu {
    margin-inline-start: auto;
  }
  .columnMenu svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .cards {
    display: grid;
    align-content: start;
    gap: var(--space-2);
    min-block-size: 48px;
    margin: 0;
    padding: var(--space-1) var(--space-2) var(--space-2);
    overflow-y: auto;
    list-style: none;
  }
  .columnEmpty {
    display: grid;
    place-items: center;
    min-block-size: 64px;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-2);
    color: var(--ink-4);
    font-size: var(--text-12);
  }
  .endLine {
    block-size: 2px;
    margin-block: calc(-1 * var(--space-1));
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .addCard {
    justify-self: stretch;
    justify-content: flex-start;
    margin: 0 var(--space-2) var(--space-2);
    color: var(--ink-3);
  }
  .addCard svg {
    inline-size: 14px;
    block-size: 14px;
  }
  .composer {
    display: grid;
    gap: var(--space-2);
    padding: 0 var(--space-2) var(--space-2);
  }
  .composerButtons {
    display: flex;
    gap: var(--space-2);
  }

  /* ── Cards ── */
  .card {
    position: relative;
    display: grid;
    gap: var(--space-2);
    padding: var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
    color: var(--ink);
    box-shadow: 0 1px 2px var(--alpha-b-06);
    cursor: grab;
  }
  .card[role="button"]:hover {
    border-color: var(--line-strong);
  }
  .card:not([role="button"]) {
    cursor: default;
  }
  .card:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .card[data-dragging] {
    opacity: 0.4;
  }
  /* Picked up with the keyboard: lifted, and marked by more than colour. */
  .card[data-grabbed] {
    border-color: var(--accent);
    box-shadow:
      0 0 0 1px var(--accent),
      0 8px 20px var(--alpha-b-13);
    transform: rotate(-1deg);
  }
  /* A dragged card lands above or below this one. */
  .card[data-indicator]::after {
    content: "";
    position: absolute;
    right: 0;
    left: 0;
    block-size: 2px;
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .card[data-indicator="top"]::after {
    top: calc(-1 * var(--space-2) / 2 - 2px);
  }
  .card[data-indicator="bottom"]::after {
    bottom: calc(-1 * var(--space-2) / 2 - 2px);
  }
  .cardTitle {
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    line-height: var(--leading-snug);
  }
  .cardText {
    display: -webkit-box;
    overflow: hidden;
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
    line-height: var(--leading-snug);
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
  }
  .tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .tag {
    padding: 0 var(--space-2);
    border: 1px solid var(--line);
    border-radius: var(--radius-1);
    color: var(--ink-2);
    font-size: var(--text-11);
    line-height: 18px;
  }
  .cardFoot {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-block-size: 20px;
  }
  .due {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    color: var(--ink-3);
    font-size: var(--text-11);
  }
  .due svg {
    inline-size: 12px;
    block-size: 12px;
  }
  .due[data-late] {
    color: var(--crit);
  }
  .assignee {
    margin-inline-start: auto;
  }

  .empty {
    display: grid;
    place-items: center;
    min-block-size: 200px;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-3);
    color: var(--ink-3);
    font-size: var(--text-13);
  }

  /* ── Dialogs ── */
  .dialogBody {
    display: grid;
    gap: var(--space-5);
  }
  .dialogRow {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: var(--space-4);
  }
  .dialogButtons {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
  }
  .dialogButtons svg {
    inline-size: 14px;
    block-size: 14px;
  }
  .dialogNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }

  /* Read by assistive technology, not shown. */
  .hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

components/workspaces/_kernel/board.ts

/* A board: columns of cards, moved between stages. Plain data and pure
   functions, framework-free and identical in both apps; the board, its drag
   engine and its keyboard mode only render it and call these. Every change
   returns a new board, so history can keep the old one. */

export type Priority = "low" | "medium" | "high" | "urgent";

export type BoardCard = Readonly<{
  id: string;
  title: string;
  description?: string;
  tags?: readonly string[];
  /** A person's name. */
  assignee?: string;
  /** A calendar date, YYYY-MM-DD. */
  due?: string;
  priority?: Priority;
}>;

export type BoardColumn = Readonly<{
  id: string;
  title: string;
  /** How many cards the stage should hold at once (work in progress). */
  limit?: number;
  cards: readonly BoardCard[];
}>;

export type Board = Readonly<{ columns: readonly BoardColumn[] }>;

export type BoardRules = Readonly<{
  /** Where a card may go from each column. A column not listed may send
   *  cards anywhere; moving within a column is always allowed. */
  transitions?: Readonly<Record<string, readonly string[]>>;
  /** Refuse a move into a column at its limit. Off, the column only shows
   *  that it is over. */
  enforceLimits?: boolean;
}>;

/** Where a card sits: its column, and its place counted from the top. */
export type Place = Readonly<{ columnId: string; index: number }>;

export type BoardRefusal = "unknown" | "transition" | "limit";

export const BOARD_REFUSALS: Readonly<Record<BoardRefusal, string>> = {
  unknown: "That card or column does not exist.",
  transition: "Cards cannot move there from this column.",
  limit: "That column is at its limit.",
};

export const PRIORITIES: readonly Priority[] = [
  "low",
  "medium",
  "high",
  "urgent",
];

export const EMPTY_BOARD: Board = { columns: [] };

// ── Reading ──────────────────────────────────────────────────────────────

/** A card's column and place, or null. */
export function locate(board: Board, cardId: string) {
  for (const [columnIndex, column] of board.columns.entries()) {
    const index = column.cards.findIndex((card) => card.id === cardId);
    if (index >= 0)
      return { column, columnIndex, index, card: column.cards[index] };
  }
  return null;
}

export const cardCount = (board: Board) =>
  board.columns.reduce((sum, column) => sum + column.cards.length, 0);

export const overLimit = (column: BoardColumn) =>
  column.limit !== undefined && column.cards.length > column.limit;

/** Whether a card mentions `query` in its title, description, tags or
 *  assignee, ignoring case. An empty query matches everything. */
export function matches(card: BoardCard, query: string) {
  const q = query.trim().toLowerCase();
  if (!q) return true;
  return [card.title, card.description, card.assignee, ...(card.tags ?? [])]
    .filter(Boolean)
    .some((text) => text!.toLowerCase().includes(q));
}

// ── Moving cards ─────────────────────────────────────────────────────────

/** Why moving `cardId` into `columnId` would be refused, or null. */
export function checkMove(
  board: Board,
  cardId: string,
  columnId: string,
  rules: BoardRules = {},
): BoardRefusal | null {
  const from = locate(board, cardId);
  const to = board.columns.find((column) => column.id === columnId);
  if (!from || !to) return "unknown";
  if (from.column.id === columnId) return null;
  const allowed = rules.transitions?.[from.column.id];
  if (allowed && !allowed.includes(columnId)) return "transition";
  if (
    rules.enforceLimits &&
    to.limit !== undefined &&
    to.cards.length >= to.limit
  )
    return "limit";
  return null;
}

/** Moves a card to `to`, its index counted in the destination without the
 *  card (so it is the card's index afterwards); clamped to the column. */
export function moveCard(
  board: Board,
  cardId: string,
  to: Place,
  rules: BoardRules = {},
): { board: Board; refusal: null } | { board: null; refusal: BoardRefusal } {
  const refusal = checkMove(board, cardId, to.columnId, rules);
  if (refusal) return { board: null, refusal };
  const from = locate(board, cardId)!;
  const columns = board.columns.map((column) =>
    column.id === from.column.id
      ? { ...column, cards: column.cards.filter((card) => card.id !== cardId) }
      : column,
  );
  return {
    board: {
      columns: columns.map((column) => {
        if (column.id !== to.columnId) return column;
        const cards = [...column.cards];
        const index = Math.max(0, Math.min(to.index, cards.length));
        cards.splice(index, 0, from.card);
        return { ...column, cards };
      }),
    },
    refusal: null,
  };
}

/** Where a drop lands: over the card at `overIndex` (as drawn, dragged
 *  card included) on its top or bottom edge, or over the column itself
 *  (`overIndex` null), which means its end. */
export function dropPlace(
  board: Board,
  cardId: string,
  columnId: string,
  overIndex: number | null,
  edge: "top" | "bottom" | null,
): Place {
  const column = board.columns.find((item) => item.id === columnId);
  const from = locate(board, cardId);
  const same = from?.column.id === columnId;
  const length = (column?.cards.length ?? 0) - (same ? 1 : 0);
  if (overIndex === null) return { columnId, index: length };
  let index = edge === "bottom" ? overIndex + 1 : overIndex;
  // The card leaves its old place first, so everything after it shifts up.
  if (same && from && from.index < index) index -= 1;
  return { columnId, index: Math.max(0, Math.min(index, length)) };
}

export type Direction = "up" | "down" | "left" | "right";

/** The keyboard's next place for a card: up and down within its column;
 *  left and right to the nearest column in that direction that takes it,
 *  at the same height or the end. Null at an edge; a refusal when no
 *  column that way takes it. */
export function step(
  board: Board,
  cardId: string,
  direction: Direction,
  rules: BoardRules = {},
):
  | { place: Place; refusal: null }
  | { place: null; refusal: BoardRefusal | null } {
  const from = locate(board, cardId);
  if (!from) return { place: null, refusal: "unknown" };
  if (direction === "up" || direction === "down") {
    const index = from.index + (direction === "up" ? -1 : 1);
    if (index < 0 || index >= from.column.cards.length)
      return { place: null, refusal: null };
    return { place: { columnId: from.column.id, index }, refusal: null };
  }
  const delta = direction === "left" ? -1 : 1;
  let first: BoardRefusal | null = null;
  for (
    let c = from.columnIndex + delta;
    c >= 0 && c < board.columns.length;
    c += delta
  ) {
    const column = board.columns[c];
    const refusal = checkMove(board, cardId, column.id, rules);
    if (!refusal)
      return {
        place: {
          columnId: column.id,
          index: Math.min(from.index, column.cards.length),
        },
        refusal: null,
      };
    first ??= refusal;
  }
  return { place: null, refusal: first };
}

// ── Cards and columns ────────────────────────────────────────────────────

export function addCard(
  board: Board,
  columnId: string,
  card: BoardCard,
  at: "start" | "end" = "end",
): Board {
  return {
    columns: board.columns.map((column) =>
      column.id === columnId
        ? {
            ...column,
            cards:
              at === "start"
                ? [card, ...column.cards]
                : [...column.cards, card],
          }
        : column,
    ),
  };
}

export function updateCard(
  board: Board,
  cardId: string,
  change: Partial<Omit<BoardCard, "id">>,
): Board {
  return {
    columns: board.columns.map((column) =>
      column.cards.some((card) => card.id === cardId)
        ? {
            ...column,
            cards: column.cards.map((card) =>
              card.id === cardId ? { ...card, ...change } : card,
            ),
          }
        : column,
    ),
  };
}

export function removeCard(board: Board, cardId: string): Board {
  return {
    columns: board.columns.map((column) => ({
      ...column,
      cards: column.cards.filter((card) => card.id !== cardId),
    })),
  };
}

export function addColumn(board: Board, column: BoardColumn): Board {
  return { columns: [...board.columns, column] };
}

/** Renames a column or changes its limit; a null limit removes it. */
export function updateColumn(
  board: Board,
  columnId: string,
  change: Readonly<{ title?: string; limit?: number | null }>,
): Board {
  return {
    columns: board.columns.map((column) => {
      if (column.id !== columnId) return column;
      const next: { -readonly [K in keyof BoardColumn]: BoardColumn[K] } = {
        ...column,
      };
      if (change.title !== undefined) next.title = change.title;
      if (change.limit === null) delete next.limit;
      else if (change.limit !== undefined) next.limit = change.limit;
      return next;
    }),
  };
}

/** Removes an empty column. Null when it still has cards: they would be
 *  lost, so the caller moves them first. */
export function removeColumn(board: Board, columnId: string): Board | null {
  const column = board.columns.find((item) => item.id === columnId);
  if (!column || column.cards.length) return null;
  return { columns: board.columns.filter((item) => item.id !== columnId) };
}

/** Moves a column to `index`, counted without it. */
export function moveColumn(
  board: Board,
  columnId: string,
  index: number,
): Board {
  const from = board.columns.findIndex((column) => column.id === columnId);
  if (from < 0) return board;
  const columns = board.columns.filter((column) => column.id !== columnId);
  const to = Math.max(0, Math.min(index, columns.length));
  if (to === from) return board;
  columns.splice(to, 0, board.columns[from]);
  return { columns };
}

/** An id not yet used on the board: c1, c2… for cards, s1, s2… for
 *  columns (stages). */
export function nextBoardId(board: Board, prefix: string) {
  const ids = new Set([
    ...board.columns.map((column) => column.id),
    ...board.columns.flatMap((column) => column.cards.map((card) => card.id)),
  ]);
  let n = 1;
  while (ids.has(`${prefix}${n}`)) n += 1;
  return `${prefix}${n}`;
}

/** Where a dragged column lands, dropped on the left or right half of
 *  another; counted without the dragged column, as moveColumn takes it. */
export function columnDropIndex(
  board: Board,
  columnId: string,
  overColumnId: string,
  edge: "left" | "right" | null,
): number {
  const from = board.columns.findIndex((column) => column.id === columnId);
  const over = board.columns.findIndex((column) => column.id === overColumnId);
  if (from < 0 || over < 0) return Math.max(from, 0);
  let index = edge === "right" ? over + 1 : over;
  if (from < index) index -= 1;
  return index;
}

Board, read only

look and filter

Backlog

3 cards
  1. Usage alerts at 80% of plan

    Email owners before a workspace hits its limit.

    • Billing
    Medium
    Medium priority, tagged Billing
  2. SAML sign-in

    • Auth
    • Enterprise
    Low
    Low priority, tagged Auth, Enterprise
  3. Dark mode for the docs site

    • Docs
    tagged Docs

Ready

2 cards
  1. Invitation emails show the inviter

    Name the workspace and who invited, not just a link.

    • Invites
    HighOct 9Ivy Invitee
    High priority, assigned to Ivy Invitee, overdue, was due Oct 9, tagged Invites
  2. Audit log: export as JSON

    • Audit
    LowMax Member
    Low priority, assigned to Max Member, tagged Audit

In progress

3 / 3 cards
  1. Resumable uploads

    Large files continue after a dropped connection.

    • Files
    HighOct 2Ada Admin
    High priority, assigned to Ada Admin, overdue, was due Oct 2, tagged Files
  2. Rotate webhook signing secrets

    • Security
    UrgentSep 24Olivia Owner
    Urgent priority, assigned to Olivia Owner, overdue, was due Sep 24, tagged Security
  3. Trash empties after 30 days

    • Files
    Max Member
    assigned to Max Member, tagged Files

Review

1 / 2 card
  1. Two-step sign-in recovery codes

    • Auth
    MediumSep 30Ada Admin
    Medium priority, assigned to Ada Admin, overdue, was due Sep 30, tagged Auth

Done

1 card
  1. Workspace switcher remembers the last one

    Olivia Owner
    assigned to Olivia Owner

Press Enter to open. Press Space to pick up, then use the arrow keys to move and Space to drop.

Sourcecomponents/workspaces/kanban-board/doc.ts · components/workspaces/kanban-board/kanban-board.tsx · components/workspaces/kanban-board/board-column.tsx · components/workspaces/kanban-board/board-card.tsx · components/workspaces/kanban-board/board-dialogs.tsx · components/workspaces/kanban-board/kanban-board.module.css · components/workspaces/_kernel/board.ts

components/workspaces/kanban-board/doc.ts

/**
 * KanbanBoard — cards moved between stages, and stages put in order.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the board
 *     defaultValue?  { columns: { id, title, limit?, cards }[] }  (empty)
 *                      card     { id, title, description?, tags?, assignee?,
 *                               due? (YYYY-MM-DD), priority? }
 *     onChange?      every committed change, to persist
 *     rules?         { transitions? (column → columns it may send cards to),
 *                    enforceLimits? }
 *     readOnly?      look and filter; nothing moves or changes
 *     empty?         what a board with no columns says
 *
 * # Behaviour
 *
 * R1  Columns sit side by side and the board scrolls sideways inside
 *     itself, never the page; a long column scrolls on its own. Dragging
 *     near an edge scrolls.
 * R2  A card is dragged within its column or into another, landing above or
 *     below the card it is dropped on, or at the end of a column. A line
 *     shows where. A column is dragged by its header, landing left or right
 *     of another.
 * R3  A move is refused — the drop does nothing and the reason is
 *     announced — when the card's column may not send cards there
 *     (transitions), or the column is at its limit and limits are enforced.
 *     While a card is dragged, columns that would refuse it step back. Moving
 *     within a column is always allowed.
 * R4  A column shows its count, and its limit when it has one; over the
 *     limit it is marked (in words as well as colour).
 * R5  Every move has a keyboard way. A card is one tab stop: Enter opens it;
 *     Space picks it up, the arrows move it (up and down within the column;
 *     left and right to the nearest column that takes it), Space or Enter
 *     drops it as one change, Escape puts it back. Each step is announced
 *     as column and position. Column settings move a column left or right.
 * R6  A card opens in a dialog that edits title, description, column,
 *     priority, assignee, due date and tags in place; columns that would
 *     refuse it are disabled there, with the reason. A column's dialog edits
 *     its name and limit, and deletes it only once it is empty.
 * R7  Cards are added at the end of a column, one after another, with Enter.
 * R8  The filter matches title, description, tags and assignee, and shows
 *     how many of how many match. While it is on, cards do not move (a
 *     partial column has no honest place to drop into).
 * R9  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change; typing
 *     in one field is one step. onChange receives committed changes only:
 *     not a card mid keyboard move, not the starting value.
 * R10 A card's facts are one sentence for assistive technology ("High
 *     priority, assigned to Ada, overdue, was due 3 Oct"); an overdue date
 *     is marked in words as well as colour. Due dates are calendar days and
 *     never shift with time zone.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Moves, refusals, drop places, keyboard steps and the other changes are
 * the framework-free kernel in ../_kernel/board.ts (identical in both apps,
 * tested there), with history from ../_kernel/history.ts; this renderer only
 * draws the board and calls it. Dragging is Atlassian's pragmatic drag and
 * drop — plain DOM, so both apps use the same engine: each card and column
 * registers as a draggable and a drop target, with the closest edge
 * attached, and one monitor on the board draws the landing line and
 * commits the drop. Handlers read the board through a ref, so registrations
 * are not redone on every change. A keyboard move previews the board
 * without touching history, and keeps focus on the card as it changes
 * column (where it is a new element).
 */
export {};

components/workspaces/kanban-board/kanban-board.tsx

"use client";

import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
  extractClosestEdge,
  type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { Button } from "@/components/forms/button";
import { Input } from "@/components/forms/input";
import { Plus, Redo2, Search, Undo2 } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  addCard,
  addColumn,
  BOARD_REFUSALS,
  cardCount,
  checkMove,
  columnDropIndex,
  dropPlace,
  EMPTY_BOARD,
  locate,
  matches,
  moveCard,
  moveColumn,
  nextBoardId,
  removeCard,
  removeColumn,
  step,
  updateCard,
  updateColumn,
  type Board,
  type BoardRules,
  type Direction,
} from "../_kernel/board";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import { CardDialog, ColumnDialog } from "./board-dialogs";
import { BoardColumnView, type Indicator } from "./board-column";
import { EngineContext, isCard, isColumn, type Engine } from "./engine";
import styles from "./kanban-board.module.css";

export type KanbanBoardProps = {
  /** Names the board for assistive technology. */
  label: string;
  /** Where editing starts. The board keeps its own history from here. */
  defaultValue?: Board;
  /** Every committed change: a drop, an edit, a new card. Persist here. */
  onChange?: (board: Board) => void;
  rules?: BoardRules;
  /** Look and filter; nothing moves or changes. */
  readOnly?: boolean;
  /** Shown when the board has no columns. */
  empty?: ReactNode;
  className?: string;
};

const DIRECTIONS: Readonly<Record<string, Direction>> = {
  ArrowUp: "up",
  ArrowDown: "down",
  ArrowLeft: "left",
  ArrowRight: "right",
};

const isTyping = (target: EventTarget | null) =>
  target instanceof HTMLElement &&
  (target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));

/** Where a card is, as a person hears it. */
function whereIs(board: Board, cardId: string) {
  const found = locate(board, cardId);
  return found
    ? `${found.column.title}, position ${found.index + 1} of ${found.column.cards.length}`
    : "";
}

/** A board of stages: cards dragged between and within columns, columns
 *  dragged into order; see doc.ts. */
export function KanbanBoard({
  label,
  defaultValue = EMPTY_BOARD,
  onChange,
  rules = {},
  readOnly = false,
  empty = "No columns yet. Add one to start.",
  className,
}: KanbanBoardProps) {
  const id = useId();
  const scroller = useRef<HTMLDivElement>(null);
  const root = useRef<HTMLDivElement>(null);

  const [history, setHistory] = useState<History<Board>>(() =>
    start(defaultValue),
  );
  const change = useCallback(
    (next: Board | null, key: string | null = null) => {
      if (next) setHistory((current) => commit(current, next, key));
    },
    [],
  );
  // Tell the caller about committed changes, not the starting value.
  const reported = useRef(history.present);
  useEffect(() => {
    if (reported.current === history.present) return;
    reported.current = history.present;
    onChange?.(history.present);
  }, [history.present, onChange]);

  // A keyboard move shows the card travelling without committing; the
  // drop commits once, as one undo step.
  const [grab, setGrab] = useState<{ cardId: string; preview: Board } | null>(
    null,
  );
  const board = grab?.preview ?? history.present;

  const [query, setQuery] = useState("");
  const filtered = query.trim() !== "";
  const [dragging, setDragging] = useState<string | null>(null);
  const [indicator, setIndicator] = useState<Indicator>(null);
  const [editing, setEditing] = useState<{
    kind: "card" | "column";
    id: string;
  } | null>(null);
  const [message, setMessage] = useState("");

  // What drag handlers read when a drag happens; see engine.ts.
  const engine = useRef<Engine>({
    board,
    rules,
    movable: false,
    arrangeable: false,
  });
  useLayoutEffect(() => {
    engine.current = {
      board,
      rules,
      movable: !readOnly && !filtered && !grab,
      arrangeable: !readOnly && !grab,
    };
  });

  // One monitor for the whole board: it draws where a drop would land and
  // commits the drop.
  useEffect(() => {
    const element = scroller.current;
    if (readOnly || !element) return;
    const edgeOf = (data: Record<string | symbol, unknown>) =>
      extractClosestEdge(data) as Edge | null;

    return combine(
      monitorForElements({
        canMonitor: ({ source }) =>
          isCard(source.data) || isColumn(source.data),
        onDragStart: ({ source }) => {
          if (isCard(source.data)) setDragging(source.data.cardId as string);
        },
        // Every move, not only a change of target: the edge a card or
        // column would land on changes within one target.
        onDrag: ({ location, source }) => {
          const target = location.current.dropTargets[0];
          let next: Indicator = null;
          if (target && isColumn(source.data)) {
            const edge = edgeOf(target.data);
            if (
              isColumn(target.data) &&
              target.data.columnId !== source.data.columnId &&
              (edge === "left" || edge === "right")
            )
              next = {
                kind: "column",
                columnId: target.data.columnId as string,
                edge,
              };
          } else if (target && isCard(target.data)) {
            const edge = edgeOf(target.data);
            if (
              target.data.cardId !== source.data.cardId &&
              (edge === "top" || edge === "bottom")
            )
              next = {
                kind: "card",
                cardId: target.data.cardId as string,
                edge,
              };
          } else if (target && isColumn(target.data)) {
            next = { kind: "end", columnId: target.data.columnId as string };
          }
          setIndicator((current) =>
            JSON.stringify(current) === JSON.stringify(next) ? current : next,
          );
        },
        onDrop: ({ location, source }) => {
          setDragging(null);
          setIndicator(null);
          const target = location.current.dropTargets[0];
          if (!target) return;
          const current = engine.current.board;
          if (isColumn(source.data)) {
            if (!isColumn(target.data)) return;
            const columnId = source.data.columnId as string;
            const index = columnDropIndex(
              current,
              columnId,
              target.data.columnId as string,
              edgeOf(target.data) as "left" | "right" | null,
            );
            const next = moveColumn(current, columnId, index);
            if (next !== current) {
              change(next);
              const title = next.columns[index]?.title;
              setMessage(
                `Moved ${title} to position ${index + 1} of ${next.columns.length}.`,
              );
            }
            return;
          }
          const cardId = source.data.cardId as string;
          const place = isCard(target.data)
            ? dropPlace(
                current,
                cardId,
                target.data.columnId as string,
                target.data.index as number,
                edgeOf(target.data) as "top" | "bottom" | null,
              )
            : dropPlace(
                current,
                cardId,
                target.data.columnId as string,
                null,
                null,
              );
          const before = locate(current, cardId);
          if (
            before &&
            before.column.id === place.columnId &&
            before.index === place.index
          )
            return;
          const result = moveCard(current, cardId, place, engine.current.rules);
          if (result.refusal) {
            setMessage(BOARD_REFUSALS[result.refusal]);
            return;
          }
          change(result.board);
          setMessage(
            `Moved ${before?.card.title} to ${whereIs(result.board, cardId)}.`,
          );
        },
      }),
      autoScrollForElements({ element }),
    );
  }, [engine, readOnly, change]);

  // A card moved by keyboard may land in another column, where it is a new
  // element: keep focus on it.
  useEffect(() => {
    if (!grab) return;
    root.current
      ?.querySelector<HTMLElement>(
        `[data-card-id="${CSS.escape(grab.cardId)}"]`,
      )
      ?.focus();
  }, [grab]);

  function onCardKey(cardId: string, event: KeyboardEvent<HTMLDivElement>) {
    if (!grab) {
      if (event.key === "Enter") {
        event.preventDefault();
        setEditing({ kind: "card", id: cardId });
      } else if (event.key === " ") {
        event.preventDefault();
        if (filtered) {
          setMessage("Clear the filter to move cards.");
          return;
        }
        setGrab({ cardId, preview: history.present });
        setMessage(
          `Picked up ${locate(history.present, cardId)?.card.title}. ${whereIs(history.present, cardId)}. Use the arrow keys to move it, Space to drop it, Escape to cancel.`,
        );
      }
      return;
    }
    if (grab.cardId !== cardId) return;
    const direction = DIRECTIONS[event.key];
    if (direction) {
      event.preventDefault();
      const next = step(grab.preview, cardId, direction, rules);
      if (next.place) {
        const moved = moveCard(grab.preview, cardId, next.place, rules);
        if (moved.board) {
          setGrab({ cardId, preview: moved.board });
          setMessage(whereIs(moved.board, cardId));
        }
      } else {
        setMessage(
          next.refusal
            ? BOARD_REFUSALS[next.refusal]
            : `${whereIs(grab.preview, cardId)}. It cannot go further that way.`,
        );
      }
    } else if (event.key === " " || event.key === "Enter") {
      event.preventDefault();
      change(grab.preview === history.present ? null : grab.preview);
      setGrab(null);
      setMessage(
        `Dropped ${locate(grab.preview, cardId)?.card.title} in ${whereIs(grab.preview, cardId)}.`,
      );
    } else if (event.key === "Escape") {
      event.preventDefault();
      setGrab(null);
      setMessage(
        `Cancelled. ${locate(history.present, cardId)?.card.title} is back in ${whereIs(history.present, cardId)}.`,
      );
    }
  }

  function historyStep(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (readOnly || grab || editing || isTyping(event.target)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      historyStep(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      historyStep("redo");
    }
  }

  const present = history.present;
  const editingColumn =
    editing?.kind === "column"
      ? present.columns.find((column) => column.id === editing.id)
      : undefined;
  const total = cardCount(present);
  const shown = present.columns.reduce(
    (sum, column) =>
      sum + column.cards.filter((card) => matches(card, query)).length,
    0,
  );

  return (
    <EngineContext.Provider value={engine}>
      <div
        ref={root}
        className={cn(styles.board, className)}
        onKeyDown={onKeyDown}
        // A press anywhere else ends a keyboard move, as Escape does.
        onPointerDown={() => grab && setGrab(null)}
      >
        <div
          className={styles.toolbar}
          role="toolbar"
          aria-label={`${label} tools`}
        >
          <div className={styles.search}>
            <Search className={styles.searchIcon} aria-hidden="true" />
            <Input
              type="search"
              size="sm"
              aria-label="Filter cards"
              placeholder="Filter cards…"
              value={query}
              onChange={(event) => setQuery(event.target.value)}
            />
          </div>
          <span className={styles.summary} role="status">
            {filtered
              ? `${shown} of ${total} ${total === 1 ? "card" : "cards"}`
              : `${total} ${total === 1 ? "card" : "cards"}`}
          </span>
          {readOnly ? null : (
            <span className={styles.toolbarEnd}>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Undo"
                title="Undo (⌘Z)"
                disabled={!canUndo(history) || !!grab}
                onClick={() => historyStep("undo")}
              >
                <Undo2 aria-hidden="true" />
              </Button>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Redo"
                title="Redo (⇧⌘Z)"
                disabled={!canRedo(history) || !!grab}
                onClick={() => historyStep("redo")}
              >
                <Redo2 aria-hidden="true" />
              </Button>
              <Button
                size="sm"
                variant="quiet"
                onClick={() => {
                  const columnId = nextBoardId(present, "s");
                  change(
                    addColumn(present, {
                      id: columnId,
                      title: "New column",
                      cards: [],
                    }),
                  );
                  setEditing({ kind: "column", id: columnId });
                  setMessage("Added a column.");
                }}
              >
                <Plus aria-hidden="true" />
                Add column
              </Button>
            </span>
          )}
        </div>
        {filtered && !readOnly ? (
          <p className={styles.filterNote}>Clear the filter to move cards.</p>
        ) : null}

        <div
          ref={scroller}
          className={styles.scroller}
          role="region"
          aria-label={label}
          data-dragging={dragging !== null || undefined}
        >
          {board.columns.length ? (
            <div className={styles.columns}>
              {board.columns.map((column) => (
                <BoardColumnView
                  key={column.id}
                  column={column}
                  cards={
                    filtered
                      ? column.cards.filter((card) => matches(card, query))
                      : column.cards
                  }
                  readOnly={readOnly}
                  filtered={filtered}
                  refused={
                    dragging !== null &&
                    checkMove(board, dragging, column.id, rules) !== null
                  }
                  grabbedCard={grab?.cardId ?? null}
                  indicator={indicator}
                  hintId={`${id}-hint`}
                  onOpenCard={(cardId) =>
                    setEditing({ kind: "card", id: cardId })
                  }
                  onCardKey={onCardKey}
                  onAddCard={(title) => {
                    const cardId = nextBoardId(present, "c");
                    change(addCard(present, column.id, { id: cardId, title }));
                    setMessage(`Added ${title} to ${column.title}.`);
                  }}
                  onSettings={() =>
                    setEditing({ kind: "column", id: column.id })
                  }
                />
              ))}
            </div>
          ) : (
            <div className={styles.empty}>{empty}</div>
          )}
        </div>

        <p id={`${id}-hint`} className={styles.hidden}>
          Press Enter to open. Press Space to pick up, then use the arrow keys
          to move and Space to drop.
        </p>
        <p className={styles.hidden} role="status">
          {message}
        </p>

        {editing?.kind === "card" ? (
          <CardDialog
            key={editing.id}
            board={present}
            cardId={editing.id}
            rules={rules}
            onChange={(value, key) =>
              change(updateCard(present, editing.id, value), key)
            }
            onMove={(columnId) => {
              const target = present.columns.find(
                (column) => column.id === columnId,
              );
              const result = moveCard(
                present,
                editing.id,
                { columnId, index: target?.cards.length ?? 0 },
                rules,
              );
              if (result.refusal) setMessage(BOARD_REFUSALS[result.refusal]);
              else {
                change(result.board);
                setMessage(`Moved to ${whereIs(result.board, editing.id)}.`);
              }
            }}
            onDelete={() => {
              const title = locate(present, editing.id)?.card.title;
              change(removeCard(present, editing.id));
              setEditing(null);
              setMessage(`Deleted ${title}. Undo brings it back.`);
            }}
            onClose={() => setEditing(null)}
          />
        ) : null}
        {editingColumn ? (
          <ColumnDialog
            board={present}
            column={editingColumn}
            onChange={(value, key) =>
              change(updateColumn(present, editingColumn.id, value), key)
            }
            onMove={(direction) => {
              const index = present.columns.indexOf(editingColumn) + direction;
              change(moveColumn(present, editingColumn.id, index));
              setMessage(
                `Moved ${editingColumn.title} to position ${index + 1} of ${present.columns.length}.`,
              );
            }}
            onDelete={() => {
              change(removeColumn(present, editingColumn.id));
              setEditing(null);
              setMessage(`Deleted the ${editingColumn.title} column.`);
            }}
            onClose={() => setEditing(null)}
          />
        ) : null}
      </div>
    </EngineContext.Provider>
  );
}

components/workspaces/kanban-board/board-column.tsx

"use client";

import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

import { Button } from "@/components/forms/button";
import { Input } from "@/components/forms/input";
import { GripVertical, Plus, Settings } from "@/components/utility/icon";
import {
  checkMove,
  overLimit,
  type BoardCard,
  type BoardColumn,
} from "../_kernel/board";
import { BoardCardView } from "./board-card";
import { isCard, isColumn, useEngine } from "./engine";
import styles from "./kanban-board.module.css";

/** Where something dragged over the board would land, as drawn. */
export type Indicator =
  | { kind: "card"; cardId: string; edge: "top" | "bottom" }
  | { kind: "end"; columnId: string }
  | { kind: "column"; columnId: string; edge: "left" | "right" }
  | null;

export type BoardColumnViewProps = {
  column: BoardColumn;
  /** Cards shown: all of them, or those the filter matches. */
  cards: readonly BoardCard[];
  readOnly: boolean;
  filtered: boolean;
  /** A card is being dragged that this column would refuse. */
  refused: boolean;
  grabbedCard: string | null;
  indicator: Indicator;
  hintId: string;
  onOpenCard: (cardId: string) => void;
  onCardKey: (cardId: string, event: KeyboardEvent<HTMLDivElement>) => void;
  onAddCard: (title: string) => void;
  onSettings: () => void;
};

/** One stage: its header (the handle for dragging the column), its cards,
 *  and a way to add one. */
export function BoardColumnView({
  column,
  cards,
  readOnly,
  filtered,
  refused,
  grabbedCard,
  indicator,
  hintId,
  onOpenCard,
  onCardKey,
  onAddCard,
  onSettings,
}: BoardColumnViewProps) {
  const engine = useEngine();
  const id = useId();
  const ref = useRef<HTMLElement>(null);
  const head = useRef<HTMLDivElement>(null);
  const list = useRef<HTMLOListElement>(null);
  const [adding, setAdding] = useState(false);
  const [draft, setDraft] = useState("");

  useEffect(() => {
    const element = ref.current;
    if (!element || !head.current || !list.current || readOnly) return;
    return combine(
      draggable({
        element,
        dragHandle: head.current,
        canDrag: () => engine.current.arrangeable,
        getInitialData: () => ({ type: "column", columnId: column.id }),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) =>
          isColumn(source.data) ||
          (isCard(source.data) &&
            !checkMove(
              engine.current.board,
              source.data.cardId as string,
              column.id,
              engine.current.rules,
            )),
        getData: ({ input, source }) => {
          const data = { type: "column", columnId: column.id };
          return isColumn(source.data)
            ? attachClosestEdge(data, {
                input,
                element,
                allowedEdges: ["left", "right"],
              })
            : data;
        },
        getIsSticky: () => true,
      }),
      autoScrollForElements({ element: list.current }),
    );
  }, [engine, column.id, readOnly]);

  const over = overLimit(column);
  const count = filtered
    ? `${cards.length} of ${column.cards.length}`
    : String(column.cards.length);

  function add() {
    const title = draft.trim();
    if (!title) return;
    onAddCard(title);
    setDraft("");
  }

  return (
    <section
      ref={ref}
      className={styles.column}
      aria-labelledby={`${id}-title`}
      data-refused={refused || undefined}
      data-indicator={
        indicator?.kind === "column" && indicator.columnId === column.id
          ? indicator.edge
          : undefined
      }
    >
      <div
        ref={head}
        className={styles.columnHead}
        data-handle={!readOnly || undefined}
      >
        {readOnly ? null : (
          <GripVertical className={styles.grip} aria-hidden="true" />
        )}
        <h3 id={`${id}-title`} className={styles.columnTitle}>
          {column.title}
        </h3>
        <span
          className={styles.count}
          data-over={over || undefined}
          title={
            column.limit !== undefined
              ? `${column.cards.length} of a limit of ${column.limit}`
              : undefined
          }
        >
          {count}
          {column.limit !== undefined ? ` / ${column.limit}` : ""}
          <span className={styles.hidden}>
            {column.cards.length === 1 ? " card" : " cards"}
            {over ? ", over the limit" : ""}
          </span>
        </span>
        {readOnly ? null : (
          <Button
            size="icon"
            variant="quiet"
            className={styles.columnMenu}
            aria-label={`${column.title} settings`}
            onClick={onSettings}
          >
            <Settings aria-hidden="true" />
          </Button>
        )}
      </div>

      <ol ref={list} className={styles.cards}>
        {cards.map((card, index) => (
          <li key={card.id}>
            <BoardCardView
              card={card}
              columnId={column.id}
              index={index}
              readOnly={readOnly}
              grabbed={grabbedCard === card.id}
              indicator={
                indicator?.kind === "card" && indicator.cardId === card.id
                  ? indicator.edge
                  : null
              }
              hintId={hintId}
              onOpen={() => onOpenCard(card.id)}
              onKey={(event) => onCardKey(card.id, event)}
            />
          </li>
        ))}
        {indicator?.kind === "end" && indicator.columnId === column.id ? (
          <li className={styles.endLine} aria-hidden="true" />
        ) : null}
        {cards.length === 0 ? (
          <li className={styles.columnEmpty}>
            {filtered && column.cards.length ? "No matching cards" : "No cards"}
          </li>
        ) : null}
      </ol>

      {readOnly ? null : adding ? (
        <div className={styles.composer}>
          <Input
            size="sm"
            autoFocus
            aria-label={`New card in ${column.title}`}
            placeholder="What needs doing?"
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") {
                event.preventDefault();
                add();
              } else if (event.key === "Escape") {
                setAdding(false);
                setDraft("");
              }
            }}
          />
          <div className={styles.composerButtons}>
            <Button
              size="sm"
              variant="primary"
              disabled={!draft.trim()}
              onClick={add}
            >
              Add card
            </Button>
            <Button
              size="sm"
              variant="quiet"
              onClick={() => {
                setAdding(false);
                setDraft("");
              }}
            >
              Done
            </Button>
          </div>
        </div>
      ) : (
        <Button
          size="sm"
          variant="quiet"
          className={styles.addCard}
          onClick={() => setAdding(true)}
        >
          <Plus aria-hidden="true" />
          Add card
        </Button>
      )}
    </section>
  );
}

components/workspaces/kanban-board/board-card.tsx

"use client";

import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { CalendarDays } from "@/components/utility/icon";
import { checkMove, type BoardCard } from "../_kernel/board";
import { isCard, useEngine } from "./engine";
import {
  describeCard,
  formatDue,
  overdue,
  PRIORITY_LABEL,
  PRIORITY_TONE,
} from "./format";
import styles from "./kanban-board.module.css";

export type BoardCardViewProps = {
  card: BoardCard;
  columnId: string;
  index: number;
  readOnly: boolean;
  /** Picked up with the keyboard. */
  grabbed: boolean;
  /** Where a dragged card would land against this one. */
  indicator: "top" | "bottom" | null;
  /** The id of the hidden text telling how to open and move a card. */
  hintId: string;
  onOpen: () => void;
  onKey: (event: KeyboardEvent<HTMLDivElement>) => void;
};

/** One card: a drag source and a drop target (above or below it). */
export function BoardCardView({
  card,
  columnId,
  index,
  readOnly,
  grabbed,
  indicator,
  hintId,
  onOpen,
  onKey,
}: BoardCardViewProps) {
  const engine = useEngine();
  const ref = useRef<HTMLDivElement>(null);
  const id = useId();
  const [dragging, setDragging] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element || readOnly) return;
    return combine(
      draggable({
        element,
        canDrag: () => engine.current.movable,
        getInitialData: () => ({ type: "card", cardId: card.id }),
        onDragStart: () => setDragging(true),
        onDrop: () => setDragging(false),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) =>
          isCard(source.data) &&
          !checkMove(
            engine.current.board,
            source.data.cardId as string,
            columnId,
            engine.current.rules,
          ),
        getData: ({ input }) =>
          attachClosestEdge(
            { type: "card", cardId: card.id, columnId, index },
            { input, element, allowedEdges: ["top", "bottom"] },
          ),
        getIsSticky: () => true,
      }),
    );
  }, [engine, card.id, columnId, index, readOnly]);

  const due = card.due ? formatDue(card.due) : null;
  const late = overdue(card);
  const description = describeCard(card);

  return (
    <div
      ref={ref}
      className={styles.card}
      data-card-id={card.id}
      data-dragging={dragging || undefined}
      data-grabbed={grabbed || undefined}
      data-indicator={indicator ?? undefined}
      {...(readOnly
        ? {}
        : {
            role: "button",
            tabIndex: 0,
            // Named by its title alone; the facts and the keys describe it.
            "aria-labelledby": `${id}-title`,
            "aria-describedby": description ? `${id}-facts ${hintId}` : hintId,
            "aria-pressed": grabbed ? true : undefined,
            onClick: onOpen,
            onKeyDown: onKey,
          })}
    >
      <p id={`${id}-title`} className={styles.cardTitle}>
        {card.title}
      </p>
      {card.description ? (
        <p className={styles.cardText}>{card.description}</p>
      ) : null}
      {card.tags?.length ? (
        <ul className={styles.tags} aria-label="Tags">
          {card.tags.map((tag) => (
            <li key={tag} className={styles.tag}>
              {tag}
            </li>
          ))}
        </ul>
      ) : null}
      {card.priority || due || card.assignee ? (
        <div className={styles.cardFoot}>
          {card.priority ? (
            <Badge tone={PRIORITY_TONE[card.priority]} dot>
              {PRIORITY_LABEL[card.priority]}
            </Badge>
          ) : null}
          {due ? (
            <span className={styles.due} data-late={late || undefined}>
              <CalendarDays aria-hidden="true" />
              {due}
            </span>
          ) : null}
          {card.assignee ? (
            <Avatar
              name={card.assignee}
              size="sm"
              className={styles.assignee}
            />
          ) : null}
        </div>
      ) : null}
      {/* The facts as a sentence, for a reader that cannot see the badges. */}
      {description ? (
        <span id={`${id}-facts`} className={styles.hidden}>
          {description}
        </span>
      ) : null}
    </div>
  );
}

components/workspaces/kanban-board/board-dialogs.tsx

"use client";

import { useState } from "react";

import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input, Textarea } from "@/components/forms/input";
import { NumberInput } from "@/components/forms/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/forms/select";
import {
  Dialog,
  DialogContent,
  DialogFooter,
} from "@/components/overlays/dialog";
import { ArrowLeft, ArrowRight, Trash2 } from "@/components/utility/icon";
import {
  checkMove,
  locate,
  PRIORITIES,
  type Board,
  type BoardCard,
  type BoardColumn,
  type BoardRules,
} from "../_kernel/board";
import { parseTags, PRIORITY_LABEL } from "./format";
import styles from "./kanban-board.module.css";

const NO_PRIORITY = "none";

export type CardDialogProps = {
  board: Board;
  cardId: string;
  rules: BoardRules;
  /** A change to the card: `key` folds a burst of typing into one undo
   *  step. */
  onChange: (change: Partial<Omit<BoardCard, "id">>, key: string) => void;
  onMove: (columnId: string) => void;
  onDelete: () => void;
  onClose: () => void;
};

/** A card's details, edited in place: every change applies at once and
 *  undoes like any other. Also the pointer-free, drag-free way to move a
 *  card to another column. */
export function CardDialog({
  board,
  cardId,
  rules,
  onChange,
  onMove,
  onDelete,
  onClose,
}: CardDialogProps) {
  const found = locate(board, cardId);
  // Tags are typed as text; the draft keeps what is typed ("a, ") while
  // the card keeps the parsed list.
  const [tags, setTags] = useState(() => found?.card.tags?.join(", ") ?? "");
  if (!found) return null;
  const { card, column } = found;

  return (
    <Dialog open onOpenChange={(open) => (open ? null : onClose())}>
      <DialogContent title="Edit card">
        <div className={styles.dialogBody}>
          <Field label="Title" required>
            {(control) => (
              <Input
                {...control}
                value={card.title}
                onChange={(event) =>
                  onChange({ title: event.target.value }, `title:${card.id}`)
                }
              />
            )}
          </Field>
          <Field label="Description">
            {(control) => (
              <Textarea
                {...control}
                rows={3}
                value={card.description ?? ""}
                onChange={(event) =>
                  onChange(
                    { description: event.target.value || undefined },
                    `description:${card.id}`,
                  )
                }
              />
            )}
          </Field>
          <div className={styles.dialogRow}>
            <Field label="Column">
              {(control) => (
                <Select value={column.id} onValueChange={onMove}>
                  <SelectTrigger {...control} />
                  <SelectContent>
                    {board.columns.map((item) => {
                      const refusal = checkMove(board, card.id, item.id, rules);
                      return (
                        <SelectItem
                          key={item.id}
                          value={item.id}
                          disabled={refusal !== null}
                        >
                          {item.title}
                          {refusal === "limit"
                            ? " (full)"
                            : refusal === "transition"
                              ? " (not from here)"
                              : ""}
                        </SelectItem>
                      );
                    })}
                  </SelectContent>
                </Select>
              )}
            </Field>
            <Field label="Priority">
              {(control) => (
                <Select
                  value={card.priority ?? NO_PRIORITY}
                  onValueChange={(value) =>
                    onChange(
                      {
                        priority:
                          value === NO_PRIORITY
                            ? undefined
                            : (value as BoardCard["priority"]),
                      },
                      `priority:${card.id}`,
                    )
                  }
                >
                  <SelectTrigger {...control} />
                  <SelectContent>
                    <SelectItem value={NO_PRIORITY}>None</SelectItem>
                    {PRIORITIES.map((priority) => (
                      <SelectItem key={priority} value={priority}>
                        {PRIORITY_LABEL[priority]}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              )}
            </Field>
          </div>
          <div className={styles.dialogRow}>
            <Field label="Assignee">
              {(control) => (
                <Input
                  {...control}
                  value={card.assignee ?? ""}
                  placeholder="Nobody"
                  onChange={(event) =>
                    onChange(
                      { assignee: event.target.value || undefined },
                      `assignee:${card.id}`,
                    )
                  }
                />
              )}
            </Field>
            <Field label="Due">
              {(control) => (
                <Input
                  {...control}
                  type="date"
                  value={card.due ?? ""}
                  onChange={(event) =>
                    onChange(
                      { due: event.target.value || undefined },
                      `due:${card.id}`,
                    )
                  }
                />
              )}
            </Field>
          </div>
          <Field label="Tags" hint="Separate tags with commas.">
            {(control) => (
              <Input
                {...control}
                value={tags}
                onChange={(event) => {
                  setTags(event.target.value);
                  const list = parseTags(event.target.value);
                  onChange(
                    { tags: list.length ? list : undefined },
                    `tags:${card.id}`,
                  );
                }}
              />
            )}
          </Field>
        </div>
        <DialogFooter>
          <Button variant="danger" size="sm" onClick={onDelete}>
            <Trash2 aria-hidden="true" />
            Delete card
          </Button>
          <Button variant="primary" size="sm" onClick={onClose}>
            Done
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export type ColumnDialogProps = {
  board: Board;
  column: BoardColumn;
  onChange: (
    change: { title?: string; limit?: number | null },
    key: string,
  ) => void;
  onMove: (direction: -1 | 1) => void;
  onDelete: () => void;
  onClose: () => void;
};

/** A column's name, limit and place: the keyboard's way to rearrange
 *  columns, too. */
export function ColumnDialog({
  board,
  column,
  onChange,
  onMove,
  onDelete,
  onClose,
}: ColumnDialogProps) {
  const index = board.columns.findIndex((item) => item.id === column.id);
  return (
    <Dialog open onOpenChange={(open) => (open ? null : onClose())}>
      <DialogContent title="Column settings">
        <div className={styles.dialogBody}>
          <Field label="Name" required>
            {(control) => (
              <Input
                {...control}
                value={column.title}
                onChange={(event) =>
                  onChange({ title: event.target.value }, `column:${column.id}`)
                }
              />
            )}
          </Field>
          <Field
            label="Limit"
            hint="The most cards this stage should hold at once. Empty for no limit."
          >
            {(control) => (
              <NumberInput
                {...control}
                min={1}
                max={99}
                value={column.limit ?? null}
                onValueChange={(value) =>
                  onChange({ limit: value }, `limit:${column.id}`)
                }
              />
            )}
          </Field>
          <div
            className={styles.dialogButtons}
            role="group"
            aria-label="Position"
          >
            <Button
              size="sm"
              variant="secondary"
              disabled={index <= 0}
              onClick={() => onMove(-1)}
            >
              <ArrowLeft aria-hidden="true" />
              Move left
            </Button>
            <Button
              size="sm"
              variant="secondary"
              disabled={index >= board.columns.length - 1}
              onClick={() => onMove(1)}
            >
              Move right
              <ArrowRight aria-hidden="true" />
            </Button>
          </div>
          {column.cards.length ? (
            <p className={styles.dialogNote}>
              Move or delete its {column.cards.length}{" "}
              {column.cards.length === 1 ? "card" : "cards"} to delete this
              column.
            </p>
          ) : null}
        </div>
        <DialogFooter>
          <Button
            variant="danger"
            size="sm"
            disabled={column.cards.length > 0}
            onClick={onDelete}
          >
            <Trash2 aria-hidden="true" />
            Delete column
          </Button>
          <Button variant="primary" size="sm" onClick={onClose}>
            Done
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

components/workspaces/kanban-board/kanban-board.module.css

@layer composition {
  .board {
    display: grid;
    gap: var(--space-3);
    min-inline-size: 0;
  }

  /* ── Toolbar ── */
  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
  }
  .toolbar svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .search {
    position: relative;
    inline-size: min(100%, 260px);
  }
  .search input {
    padding-inline-start: 30px;
  }
  .searchIcon {
    position: absolute;
    top: 50%;
    left: 10px;
    color: var(--ink-3);
    transform: translateY(-50%);
    pointer-events: none;
  }
  .summary {
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .toolbarEnd {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    margin-inline-start: auto;
  }
  .filterNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }

  /* ── Columns ── */
  /* The board scrolls sideways inside itself; the page never does. */
  .scroller {
    min-inline-size: 0;
    padding-block-end: var(--space-3);
    overflow-x: auto;
  }
  .columns {
    display: flex;
    align-items: flex-start;
    gap: var(--space-4);
    inline-size: max-content;
  }
  .column {
    position: relative;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
    inline-size: 280px;
    max-block-size: 640px;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-sunk);
    transition: opacity var(--dur-1) var(--ease);
  }
  /* While a card is dragged, a column that would refuse it steps back. */
  .scroller[data-dragging] .column[data-refused] {
    opacity: 0.45;
  }
  /* A dragged column lands before or after this one. */
  .column[data-indicator]::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    inline-size: 3px;
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .column[data-indicator="left"]::after {
    left: calc(-1 * var(--space-4) / 2 - 2px);
  }
  .column[data-indicator="right"]::after {
    right: calc(-1 * var(--space-4) / 2 - 2px);
  }
  .columnHead {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: 44px;
    padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  }
  .columnHead[data-handle] {
    padding-inline-start: var(--space-2);
    cursor: grab;
  }
  .grip {
    flex: none;
    inline-size: 14px;
    block-size: 14px;
    color: var(--ink-4);
  }
  .columnTitle {
    overflow: hidden;
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .count {
    flex: none;
    padding: 1px var(--space-2);
    border-radius: var(--radius-pill);
    background: var(--surface-hover);
    color: var(--ink-3);
    font-family: var(--font-mono);
    font-size: var(--text-11);
  }
  .count[data-over] {
    background: var(--warn-tint);
    color: var(--warn);
  }
  .columnMenu {
    margin-inline-start: auto;
  }
  .columnMenu svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .cards {
    display: grid;
    align-content: start;
    gap: var(--space-2);
    min-block-size: 48px;
    margin: 0;
    padding: var(--space-1) var(--space-2) var(--space-2);
    overflow-y: auto;
    list-style: none;
  }
  .columnEmpty {
    display: grid;
    place-items: center;
    min-block-size: 64px;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-2);
    color: var(--ink-4);
    font-size: var(--text-12);
  }
  .endLine {
    block-size: 2px;
    margin-block: calc(-1 * var(--space-1));
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .addCard {
    justify-self: stretch;
    justify-content: flex-start;
    margin: 0 var(--space-2) var(--space-2);
    color: var(--ink-3);
  }
  .addCard svg {
    inline-size: 14px;
    block-size: 14px;
  }
  .composer {
    display: grid;
    gap: var(--space-2);
    padding: 0 var(--space-2) var(--space-2);
  }
  .composerButtons {
    display: flex;
    gap: var(--space-2);
  }

  /* ── Cards ── */
  .card {
    position: relative;
    display: grid;
    gap: var(--space-2);
    padding: var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
    color: var(--ink);
    box-shadow: 0 1px 2px var(--alpha-b-06);
    cursor: grab;
  }
  .card[role="button"]:hover {
    border-color: var(--line-strong);
  }
  .card:not([role="button"]) {
    cursor: default;
  }
  .card:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .card[data-dragging] {
    opacity: 0.4;
  }
  /* Picked up with the keyboard: lifted, and marked by more than colour. */
  .card[data-grabbed] {
    border-color: var(--accent);
    box-shadow:
      0 0 0 1px var(--accent),
      0 8px 20px var(--alpha-b-13);
    transform: rotate(-1deg);
  }
  /* A dragged card lands above or below this one. */
  .card[data-indicator]::after {
    content: "";
    position: absolute;
    right: 0;
    left: 0;
    block-size: 2px;
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .card[data-indicator="top"]::after {
    top: calc(-1 * var(--space-2) / 2 - 2px);
  }
  .card[data-indicator="bottom"]::after {
    bottom: calc(-1 * var(--space-2) / 2 - 2px);
  }
  .cardTitle {
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    line-height: var(--leading-snug);
  }
  .cardText {
    display: -webkit-box;
    overflow: hidden;
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
    line-height: var(--leading-snug);
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
  }
  .tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .tag {
    padding: 0 var(--space-2);
    border: 1px solid var(--line);
    border-radius: var(--radius-1);
    color: var(--ink-2);
    font-size: var(--text-11);
    line-height: 18px;
  }
  .cardFoot {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-block-size: 20px;
  }
  .due {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    color: var(--ink-3);
    font-size: var(--text-11);
  }
  .due svg {
    inline-size: 12px;
    block-size: 12px;
  }
  .due[data-late] {
    color: var(--crit);
  }
  .assignee {
    margin-inline-start: auto;
  }

  .empty {
    display: grid;
    place-items: center;
    min-block-size: 200px;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-3);
    color: var(--ink-3);
    font-size: var(--text-13);
  }

  /* ── Dialogs ── */
  .dialogBody {
    display: grid;
    gap: var(--space-5);
  }
  .dialogRow {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: var(--space-4);
  }
  .dialogButtons {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
  }
  .dialogButtons svg {
    inline-size: 14px;
    block-size: 14px;
  }
  .dialogNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }

  /* Read by assistive technology, not shown. */
  .hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

components/workspaces/_kernel/board.ts

/* A board: columns of cards, moved between stages. Plain data and pure
   functions, framework-free and identical in both apps; the board, its drag
   engine and its keyboard mode only render it and call these. Every change
   returns a new board, so history can keep the old one. */

export type Priority = "low" | "medium" | "high" | "urgent";

export type BoardCard = Readonly<{
  id: string;
  title: string;
  description?: string;
  tags?: readonly string[];
  /** A person's name. */
  assignee?: string;
  /** A calendar date, YYYY-MM-DD. */
  due?: string;
  priority?: Priority;
}>;

export type BoardColumn = Readonly<{
  id: string;
  title: string;
  /** How many cards the stage should hold at once (work in progress). */
  limit?: number;
  cards: readonly BoardCard[];
}>;

export type Board = Readonly<{ columns: readonly BoardColumn[] }>;

export type BoardRules = Readonly<{
  /** Where a card may go from each column. A column not listed may send
   *  cards anywhere; moving within a column is always allowed. */
  transitions?: Readonly<Record<string, readonly string[]>>;
  /** Refuse a move into a column at its limit. Off, the column only shows
   *  that it is over. */
  enforceLimits?: boolean;
}>;

/** Where a card sits: its column, and its place counted from the top. */
export type Place = Readonly<{ columnId: string; index: number }>;

export type BoardRefusal = "unknown" | "transition" | "limit";

export const BOARD_REFUSALS: Readonly<Record<BoardRefusal, string>> = {
  unknown: "That card or column does not exist.",
  transition: "Cards cannot move there from this column.",
  limit: "That column is at its limit.",
};

export const PRIORITIES: readonly Priority[] = [
  "low",
  "medium",
  "high",
  "urgent",
];

export const EMPTY_BOARD: Board = { columns: [] };

// ── Reading ──────────────────────────────────────────────────────────────

/** A card's column and place, or null. */
export function locate(board: Board, cardId: string) {
  for (const [columnIndex, column] of board.columns.entries()) {
    const index = column.cards.findIndex((card) => card.id === cardId);
    if (index >= 0)
      return { column, columnIndex, index, card: column.cards[index] };
  }
  return null;
}

export const cardCount = (board: Board) =>
  board.columns.reduce((sum, column) => sum + column.cards.length, 0);

export const overLimit = (column: BoardColumn) =>
  column.limit !== undefined && column.cards.length > column.limit;

/** Whether a card mentions `query` in its title, description, tags or
 *  assignee, ignoring case. An empty query matches everything. */
export function matches(card: BoardCard, query: string) {
  const q = query.trim().toLowerCase();
  if (!q) return true;
  return [card.title, card.description, card.assignee, ...(card.tags ?? [])]
    .filter(Boolean)
    .some((text) => text!.toLowerCase().includes(q));
}

// ── Moving cards ─────────────────────────────────────────────────────────

/** Why moving `cardId` into `columnId` would be refused, or null. */
export function checkMove(
  board: Board,
  cardId: string,
  columnId: string,
  rules: BoardRules = {},
): BoardRefusal | null {
  const from = locate(board, cardId);
  const to = board.columns.find((column) => column.id === columnId);
  if (!from || !to) return "unknown";
  if (from.column.id === columnId) return null;
  const allowed = rules.transitions?.[from.column.id];
  if (allowed && !allowed.includes(columnId)) return "transition";
  if (
    rules.enforceLimits &&
    to.limit !== undefined &&
    to.cards.length >= to.limit
  )
    return "limit";
  return null;
}

/** Moves a card to `to`, its index counted in the destination without the
 *  card (so it is the card's index afterwards); clamped to the column. */
export function moveCard(
  board: Board,
  cardId: string,
  to: Place,
  rules: BoardRules = {},
): { board: Board; refusal: null } | { board: null; refusal: BoardRefusal } {
  const refusal = checkMove(board, cardId, to.columnId, rules);
  if (refusal) return { board: null, refusal };
  const from = locate(board, cardId)!;
  const columns = board.columns.map((column) =>
    column.id === from.column.id
      ? { ...column, cards: column.cards.filter((card) => card.id !== cardId) }
      : column,
  );
  return {
    board: {
      columns: columns.map((column) => {
        if (column.id !== to.columnId) return column;
        const cards = [...column.cards];
        const index = Math.max(0, Math.min(to.index, cards.length));
        cards.splice(index, 0, from.card);
        return { ...column, cards };
      }),
    },
    refusal: null,
  };
}

/** Where a drop lands: over the card at `overIndex` (as drawn, dragged
 *  card included) on its top or bottom edge, or over the column itself
 *  (`overIndex` null), which means its end. */
export function dropPlace(
  board: Board,
  cardId: string,
  columnId: string,
  overIndex: number | null,
  edge: "top" | "bottom" | null,
): Place {
  const column = board.columns.find((item) => item.id === columnId);
  const from = locate(board, cardId);
  const same = from?.column.id === columnId;
  const length = (column?.cards.length ?? 0) - (same ? 1 : 0);
  if (overIndex === null) return { columnId, index: length };
  let index = edge === "bottom" ? overIndex + 1 : overIndex;
  // The card leaves its old place first, so everything after it shifts up.
  if (same && from && from.index < index) index -= 1;
  return { columnId, index: Math.max(0, Math.min(index, length)) };
}

export type Direction = "up" | "down" | "left" | "right";

/** The keyboard's next place for a card: up and down within its column;
 *  left and right to the nearest column in that direction that takes it,
 *  at the same height or the end. Null at an edge; a refusal when no
 *  column that way takes it. */
export function step(
  board: Board,
  cardId: string,
  direction: Direction,
  rules: BoardRules = {},
):
  | { place: Place; refusal: null }
  | { place: null; refusal: BoardRefusal | null } {
  const from = locate(board, cardId);
  if (!from) return { place: null, refusal: "unknown" };
  if (direction === "up" || direction === "down") {
    const index = from.index + (direction === "up" ? -1 : 1);
    if (index < 0 || index >= from.column.cards.length)
      return { place: null, refusal: null };
    return { place: { columnId: from.column.id, index }, refusal: null };
  }
  const delta = direction === "left" ? -1 : 1;
  let first: BoardRefusal | null = null;
  for (
    let c = from.columnIndex + delta;
    c >= 0 && c < board.columns.length;
    c += delta
  ) {
    const column = board.columns[c];
    const refusal = checkMove(board, cardId, column.id, rules);
    if (!refusal)
      return {
        place: {
          columnId: column.id,
          index: Math.min(from.index, column.cards.length),
        },
        refusal: null,
      };
    first ??= refusal;
  }
  return { place: null, refusal: first };
}

// ── Cards and columns ────────────────────────────────────────────────────

export function addCard(
  board: Board,
  columnId: string,
  card: BoardCard,
  at: "start" | "end" = "end",
): Board {
  return {
    columns: board.columns.map((column) =>
      column.id === columnId
        ? {
            ...column,
            cards:
              at === "start"
                ? [card, ...column.cards]
                : [...column.cards, card],
          }
        : column,
    ),
  };
}

export function updateCard(
  board: Board,
  cardId: string,
  change: Partial<Omit<BoardCard, "id">>,
): Board {
  return {
    columns: board.columns.map((column) =>
      column.cards.some((card) => card.id === cardId)
        ? {
            ...column,
            cards: column.cards.map((card) =>
              card.id === cardId ? { ...card, ...change } : card,
            ),
          }
        : column,
    ),
  };
}

export function removeCard(board: Board, cardId: string): Board {
  return {
    columns: board.columns.map((column) => ({
      ...column,
      cards: column.cards.filter((card) => card.id !== cardId),
    })),
  };
}

export function addColumn(board: Board, column: BoardColumn): Board {
  return { columns: [...board.columns, column] };
}

/** Renames a column or changes its limit; a null limit removes it. */
export function updateColumn(
  board: Board,
  columnId: string,
  change: Readonly<{ title?: string; limit?: number | null }>,
): Board {
  return {
    columns: board.columns.map((column) => {
      if (column.id !== columnId) return column;
      const next: { -readonly [K in keyof BoardColumn]: BoardColumn[K] } = {
        ...column,
      };
      if (change.title !== undefined) next.title = change.title;
      if (change.limit === null) delete next.limit;
      else if (change.limit !== undefined) next.limit = change.limit;
      return next;
    }),
  };
}

/** Removes an empty column. Null when it still has cards: they would be
 *  lost, so the caller moves them first. */
export function removeColumn(board: Board, columnId: string): Board | null {
  const column = board.columns.find((item) => item.id === columnId);
  if (!column || column.cards.length) return null;
  return { columns: board.columns.filter((item) => item.id !== columnId) };
}

/** Moves a column to `index`, counted without it. */
export function moveColumn(
  board: Board,
  columnId: string,
  index: number,
): Board {
  const from = board.columns.findIndex((column) => column.id === columnId);
  if (from < 0) return board;
  const columns = board.columns.filter((column) => column.id !== columnId);
  const to = Math.max(0, Math.min(index, columns.length));
  if (to === from) return board;
  columns.splice(to, 0, board.columns[from]);
  return { columns };
}

/** An id not yet used on the board: c1, c2… for cards, s1, s2… for
 *  columns (stages). */
export function nextBoardId(board: Board, prefix: string) {
  const ids = new Set([
    ...board.columns.map((column) => column.id),
    ...board.columns.flatMap((column) => column.cards.map((card) => card.id)),
  ]);
  let n = 1;
  while (ids.has(`${prefix}${n}`)) n += 1;
  return `${prefix}${n}`;
}

/** Where a dragged column lands, dropped on the left or right half of
 *  another; counted without the dragged column, as moveColumn takes it. */
export function columnDropIndex(
  board: Board,
  columnId: string,
  overColumnId: string,
  edge: "left" | "right" | null,
): number {
  const from = board.columns.findIndex((column) => column.id === columnId);
  const over = board.columns.findIndex((column) => column.id === overColumnId);
  if (from < 0 || over < 0) return Math.max(from, 0);
  let index = edge === "right" ? over + 1 : over;
  if (from < index) index -= 1;
  return index;
}

Board, empty

no columns yet

No columns yet. Add one to start.

Press Enter to open. Press Space to pick up, then use the arrow keys to move and Space to drop.

Sourcecomponents/workspaces/kanban-board/doc.ts · components/workspaces/kanban-board/kanban-board.tsx · components/workspaces/kanban-board/board-column.tsx · components/workspaces/kanban-board/board-card.tsx · components/workspaces/kanban-board/board-dialogs.tsx · components/workspaces/kanban-board/kanban-board.module.css · components/workspaces/_kernel/board.ts

components/workspaces/kanban-board/doc.ts

/**
 * KanbanBoard — cards moved between stages, and stages put in order.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the board
 *     defaultValue?  { columns: { id, title, limit?, cards }[] }  (empty)
 *                      card     { id, title, description?, tags?, assignee?,
 *                               due? (YYYY-MM-DD), priority? }
 *     onChange?      every committed change, to persist
 *     rules?         { transitions? (column → columns it may send cards to),
 *                    enforceLimits? }
 *     readOnly?      look and filter; nothing moves or changes
 *     empty?         what a board with no columns says
 *
 * # Behaviour
 *
 * R1  Columns sit side by side and the board scrolls sideways inside
 *     itself, never the page; a long column scrolls on its own. Dragging
 *     near an edge scrolls.
 * R2  A card is dragged within its column or into another, landing above or
 *     below the card it is dropped on, or at the end of a column. A line
 *     shows where. A column is dragged by its header, landing left or right
 *     of another.
 * R3  A move is refused — the drop does nothing and the reason is
 *     announced — when the card's column may not send cards there
 *     (transitions), or the column is at its limit and limits are enforced.
 *     While a card is dragged, columns that would refuse it step back. Moving
 *     within a column is always allowed.
 * R4  A column shows its count, and its limit when it has one; over the
 *     limit it is marked (in words as well as colour).
 * R5  Every move has a keyboard way. A card is one tab stop: Enter opens it;
 *     Space picks it up, the arrows move it (up and down within the column;
 *     left and right to the nearest column that takes it), Space or Enter
 *     drops it as one change, Escape puts it back. Each step is announced
 *     as column and position. Column settings move a column left or right.
 * R6  A card opens in a dialog that edits title, description, column,
 *     priority, assignee, due date and tags in place; columns that would
 *     refuse it are disabled there, with the reason. A column's dialog edits
 *     its name and limit, and deletes it only once it is empty.
 * R7  Cards are added at the end of a column, one after another, with Enter.
 * R8  The filter matches title, description, tags and assignee, and shows
 *     how many of how many match. While it is on, cards do not move (a
 *     partial column has no honest place to drop into).
 * R9  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change; typing
 *     in one field is one step. onChange receives committed changes only:
 *     not a card mid keyboard move, not the starting value.
 * R10 A card's facts are one sentence for assistive technology ("High
 *     priority, assigned to Ada, overdue, was due 3 Oct"); an overdue date
 *     is marked in words as well as colour. Due dates are calendar days and
 *     never shift with time zone.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * Moves, refusals, drop places, keyboard steps and the other changes are
 * the framework-free kernel in ../_kernel/board.ts (identical in both apps,
 * tested there), with history from ../_kernel/history.ts; this renderer only
 * draws the board and calls it. Dragging is Atlassian's pragmatic drag and
 * drop — plain DOM, so both apps use the same engine: each card and column
 * registers as a draggable and a drop target, with the closest edge
 * attached, and one monitor on the board draws the landing line and
 * commits the drop. Handlers read the board through a ref, so registrations
 * are not redone on every change. A keyboard move previews the board
 * without touching history, and keeps focus on the card as it changes
 * column (where it is a new element).
 */
export {};

components/workspaces/kanban-board/kanban-board.tsx

"use client";

import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import { monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import {
  extractClosestEdge,
  type Edge,
} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import {
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from "react";

import { Button } from "@/components/forms/button";
import { Input } from "@/components/forms/input";
import { Plus, Redo2, Search, Undo2 } from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  addCard,
  addColumn,
  BOARD_REFUSALS,
  cardCount,
  checkMove,
  columnDropIndex,
  dropPlace,
  EMPTY_BOARD,
  locate,
  matches,
  moveCard,
  moveColumn,
  nextBoardId,
  removeCard,
  removeColumn,
  step,
  updateCard,
  updateColumn,
  type Board,
  type BoardRules,
  type Direction,
} from "../_kernel/board";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import { CardDialog, ColumnDialog } from "./board-dialogs";
import { BoardColumnView, type Indicator } from "./board-column";
import { EngineContext, isCard, isColumn, type Engine } from "./engine";
import styles from "./kanban-board.module.css";

export type KanbanBoardProps = {
  /** Names the board for assistive technology. */
  label: string;
  /** Where editing starts. The board keeps its own history from here. */
  defaultValue?: Board;
  /** Every committed change: a drop, an edit, a new card. Persist here. */
  onChange?: (board: Board) => void;
  rules?: BoardRules;
  /** Look and filter; nothing moves or changes. */
  readOnly?: boolean;
  /** Shown when the board has no columns. */
  empty?: ReactNode;
  className?: string;
};

const DIRECTIONS: Readonly<Record<string, Direction>> = {
  ArrowUp: "up",
  ArrowDown: "down",
  ArrowLeft: "left",
  ArrowRight: "right",
};

const isTyping = (target: EventTarget | null) =>
  target instanceof HTMLElement &&
  (target.isContentEditable ||
    ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName));

/** Where a card is, as a person hears it. */
function whereIs(board: Board, cardId: string) {
  const found = locate(board, cardId);
  return found
    ? `${found.column.title}, position ${found.index + 1} of ${found.column.cards.length}`
    : "";
}

/** A board of stages: cards dragged between and within columns, columns
 *  dragged into order; see doc.ts. */
export function KanbanBoard({
  label,
  defaultValue = EMPTY_BOARD,
  onChange,
  rules = {},
  readOnly = false,
  empty = "No columns yet. Add one to start.",
  className,
}: KanbanBoardProps) {
  const id = useId();
  const scroller = useRef<HTMLDivElement>(null);
  const root = useRef<HTMLDivElement>(null);

  const [history, setHistory] = useState<History<Board>>(() =>
    start(defaultValue),
  );
  const change = useCallback(
    (next: Board | null, key: string | null = null) => {
      if (next) setHistory((current) => commit(current, next, key));
    },
    [],
  );
  // Tell the caller about committed changes, not the starting value.
  const reported = useRef(history.present);
  useEffect(() => {
    if (reported.current === history.present) return;
    reported.current = history.present;
    onChange?.(history.present);
  }, [history.present, onChange]);

  // A keyboard move shows the card travelling without committing; the
  // drop commits once, as one undo step.
  const [grab, setGrab] = useState<{ cardId: string; preview: Board } | null>(
    null,
  );
  const board = grab?.preview ?? history.present;

  const [query, setQuery] = useState("");
  const filtered = query.trim() !== "";
  const [dragging, setDragging] = useState<string | null>(null);
  const [indicator, setIndicator] = useState<Indicator>(null);
  const [editing, setEditing] = useState<{
    kind: "card" | "column";
    id: string;
  } | null>(null);
  const [message, setMessage] = useState("");

  // What drag handlers read when a drag happens; see engine.ts.
  const engine = useRef<Engine>({
    board,
    rules,
    movable: false,
    arrangeable: false,
  });
  useLayoutEffect(() => {
    engine.current = {
      board,
      rules,
      movable: !readOnly && !filtered && !grab,
      arrangeable: !readOnly && !grab,
    };
  });

  // One monitor for the whole board: it draws where a drop would land and
  // commits the drop.
  useEffect(() => {
    const element = scroller.current;
    if (readOnly || !element) return;
    const edgeOf = (data: Record<string | symbol, unknown>) =>
      extractClosestEdge(data) as Edge | null;

    return combine(
      monitorForElements({
        canMonitor: ({ source }) =>
          isCard(source.data) || isColumn(source.data),
        onDragStart: ({ source }) => {
          if (isCard(source.data)) setDragging(source.data.cardId as string);
        },
        // Every move, not only a change of target: the edge a card or
        // column would land on changes within one target.
        onDrag: ({ location, source }) => {
          const target = location.current.dropTargets[0];
          let next: Indicator = null;
          if (target && isColumn(source.data)) {
            const edge = edgeOf(target.data);
            if (
              isColumn(target.data) &&
              target.data.columnId !== source.data.columnId &&
              (edge === "left" || edge === "right")
            )
              next = {
                kind: "column",
                columnId: target.data.columnId as string,
                edge,
              };
          } else if (target && isCard(target.data)) {
            const edge = edgeOf(target.data);
            if (
              target.data.cardId !== source.data.cardId &&
              (edge === "top" || edge === "bottom")
            )
              next = {
                kind: "card",
                cardId: target.data.cardId as string,
                edge,
              };
          } else if (target && isColumn(target.data)) {
            next = { kind: "end", columnId: target.data.columnId as string };
          }
          setIndicator((current) =>
            JSON.stringify(current) === JSON.stringify(next) ? current : next,
          );
        },
        onDrop: ({ location, source }) => {
          setDragging(null);
          setIndicator(null);
          const target = location.current.dropTargets[0];
          if (!target) return;
          const current = engine.current.board;
          if (isColumn(source.data)) {
            if (!isColumn(target.data)) return;
            const columnId = source.data.columnId as string;
            const index = columnDropIndex(
              current,
              columnId,
              target.data.columnId as string,
              edgeOf(target.data) as "left" | "right" | null,
            );
            const next = moveColumn(current, columnId, index);
            if (next !== current) {
              change(next);
              const title = next.columns[index]?.title;
              setMessage(
                `Moved ${title} to position ${index + 1} of ${next.columns.length}.`,
              );
            }
            return;
          }
          const cardId = source.data.cardId as string;
          const place = isCard(target.data)
            ? dropPlace(
                current,
                cardId,
                target.data.columnId as string,
                target.data.index as number,
                edgeOf(target.data) as "top" | "bottom" | null,
              )
            : dropPlace(
                current,
                cardId,
                target.data.columnId as string,
                null,
                null,
              );
          const before = locate(current, cardId);
          if (
            before &&
            before.column.id === place.columnId &&
            before.index === place.index
          )
            return;
          const result = moveCard(current, cardId, place, engine.current.rules);
          if (result.refusal) {
            setMessage(BOARD_REFUSALS[result.refusal]);
            return;
          }
          change(result.board);
          setMessage(
            `Moved ${before?.card.title} to ${whereIs(result.board, cardId)}.`,
          );
        },
      }),
      autoScrollForElements({ element }),
    );
  }, [engine, readOnly, change]);

  // A card moved by keyboard may land in another column, where it is a new
  // element: keep focus on it.
  useEffect(() => {
    if (!grab) return;
    root.current
      ?.querySelector<HTMLElement>(
        `[data-card-id="${CSS.escape(grab.cardId)}"]`,
      )
      ?.focus();
  }, [grab]);

  function onCardKey(cardId: string, event: KeyboardEvent<HTMLDivElement>) {
    if (!grab) {
      if (event.key === "Enter") {
        event.preventDefault();
        setEditing({ kind: "card", id: cardId });
      } else if (event.key === " ") {
        event.preventDefault();
        if (filtered) {
          setMessage("Clear the filter to move cards.");
          return;
        }
        setGrab({ cardId, preview: history.present });
        setMessage(
          `Picked up ${locate(history.present, cardId)?.card.title}. ${whereIs(history.present, cardId)}. Use the arrow keys to move it, Space to drop it, Escape to cancel.`,
        );
      }
      return;
    }
    if (grab.cardId !== cardId) return;
    const direction = DIRECTIONS[event.key];
    if (direction) {
      event.preventDefault();
      const next = step(grab.preview, cardId, direction, rules);
      if (next.place) {
        const moved = moveCard(grab.preview, cardId, next.place, rules);
        if (moved.board) {
          setGrab({ cardId, preview: moved.board });
          setMessage(whereIs(moved.board, cardId));
        }
      } else {
        setMessage(
          next.refusal
            ? BOARD_REFUSALS[next.refusal]
            : `${whereIs(grab.preview, cardId)}. It cannot go further that way.`,
        );
      }
    } else if (event.key === " " || event.key === "Enter") {
      event.preventDefault();
      change(grab.preview === history.present ? null : grab.preview);
      setGrab(null);
      setMessage(
        `Dropped ${locate(grab.preview, cardId)?.card.title} in ${whereIs(grab.preview, cardId)}.`,
      );
    } else if (event.key === "Escape") {
      event.preventDefault();
      setGrab(null);
      setMessage(
        `Cancelled. ${locate(history.present, cardId)?.card.title} is back in ${whereIs(history.present, cardId)}.`,
      );
    }
  }

  function historyStep(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (readOnly || grab || editing || isTyping(event.target)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      historyStep(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      historyStep("redo");
    }
  }

  const present = history.present;
  const editingColumn =
    editing?.kind === "column"
      ? present.columns.find((column) => column.id === editing.id)
      : undefined;
  const total = cardCount(present);
  const shown = present.columns.reduce(
    (sum, column) =>
      sum + column.cards.filter((card) => matches(card, query)).length,
    0,
  );

  return (
    <EngineContext.Provider value={engine}>
      <div
        ref={root}
        className={cn(styles.board, className)}
        onKeyDown={onKeyDown}
        // A press anywhere else ends a keyboard move, as Escape does.
        onPointerDown={() => grab && setGrab(null)}
      >
        <div
          className={styles.toolbar}
          role="toolbar"
          aria-label={`${label} tools`}
        >
          <div className={styles.search}>
            <Search className={styles.searchIcon} aria-hidden="true" />
            <Input
              type="search"
              size="sm"
              aria-label="Filter cards"
              placeholder="Filter cards…"
              value={query}
              onChange={(event) => setQuery(event.target.value)}
            />
          </div>
          <span className={styles.summary} role="status">
            {filtered
              ? `${shown} of ${total} ${total === 1 ? "card" : "cards"}`
              : `${total} ${total === 1 ? "card" : "cards"}`}
          </span>
          {readOnly ? null : (
            <span className={styles.toolbarEnd}>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Undo"
                title="Undo (⌘Z)"
                disabled={!canUndo(history) || !!grab}
                onClick={() => historyStep("undo")}
              >
                <Undo2 aria-hidden="true" />
              </Button>
              <Button
                size="icon"
                variant="quiet"
                aria-label="Redo"
                title="Redo (⇧⌘Z)"
                disabled={!canRedo(history) || !!grab}
                onClick={() => historyStep("redo")}
              >
                <Redo2 aria-hidden="true" />
              </Button>
              <Button
                size="sm"
                variant="quiet"
                onClick={() => {
                  const columnId = nextBoardId(present, "s");
                  change(
                    addColumn(present, {
                      id: columnId,
                      title: "New column",
                      cards: [],
                    }),
                  );
                  setEditing({ kind: "column", id: columnId });
                  setMessage("Added a column.");
                }}
              >
                <Plus aria-hidden="true" />
                Add column
              </Button>
            </span>
          )}
        </div>
        {filtered && !readOnly ? (
          <p className={styles.filterNote}>Clear the filter to move cards.</p>
        ) : null}

        <div
          ref={scroller}
          className={styles.scroller}
          role="region"
          aria-label={label}
          data-dragging={dragging !== null || undefined}
        >
          {board.columns.length ? (
            <div className={styles.columns}>
              {board.columns.map((column) => (
                <BoardColumnView
                  key={column.id}
                  column={column}
                  cards={
                    filtered
                      ? column.cards.filter((card) => matches(card, query))
                      : column.cards
                  }
                  readOnly={readOnly}
                  filtered={filtered}
                  refused={
                    dragging !== null &&
                    checkMove(board, dragging, column.id, rules) !== null
                  }
                  grabbedCard={grab?.cardId ?? null}
                  indicator={indicator}
                  hintId={`${id}-hint`}
                  onOpenCard={(cardId) =>
                    setEditing({ kind: "card", id: cardId })
                  }
                  onCardKey={onCardKey}
                  onAddCard={(title) => {
                    const cardId = nextBoardId(present, "c");
                    change(addCard(present, column.id, { id: cardId, title }));
                    setMessage(`Added ${title} to ${column.title}.`);
                  }}
                  onSettings={() =>
                    setEditing({ kind: "column", id: column.id })
                  }
                />
              ))}
            </div>
          ) : (
            <div className={styles.empty}>{empty}</div>
          )}
        </div>

        <p id={`${id}-hint`} className={styles.hidden}>
          Press Enter to open. Press Space to pick up, then use the arrow keys
          to move and Space to drop.
        </p>
        <p className={styles.hidden} role="status">
          {message}
        </p>

        {editing?.kind === "card" ? (
          <CardDialog
            key={editing.id}
            board={present}
            cardId={editing.id}
            rules={rules}
            onChange={(value, key) =>
              change(updateCard(present, editing.id, value), key)
            }
            onMove={(columnId) => {
              const target = present.columns.find(
                (column) => column.id === columnId,
              );
              const result = moveCard(
                present,
                editing.id,
                { columnId, index: target?.cards.length ?? 0 },
                rules,
              );
              if (result.refusal) setMessage(BOARD_REFUSALS[result.refusal]);
              else {
                change(result.board);
                setMessage(`Moved to ${whereIs(result.board, editing.id)}.`);
              }
            }}
            onDelete={() => {
              const title = locate(present, editing.id)?.card.title;
              change(removeCard(present, editing.id));
              setEditing(null);
              setMessage(`Deleted ${title}. Undo brings it back.`);
            }}
            onClose={() => setEditing(null)}
          />
        ) : null}
        {editingColumn ? (
          <ColumnDialog
            board={present}
            column={editingColumn}
            onChange={(value, key) =>
              change(updateColumn(present, editingColumn.id, value), key)
            }
            onMove={(direction) => {
              const index = present.columns.indexOf(editingColumn) + direction;
              change(moveColumn(present, editingColumn.id, index));
              setMessage(
                `Moved ${editingColumn.title} to position ${index + 1} of ${present.columns.length}.`,
              );
            }}
            onDelete={() => {
              change(removeColumn(present, editingColumn.id));
              setEditing(null);
              setMessage(`Deleted the ${editingColumn.title} column.`);
            }}
            onClose={() => setEditing(null)}
          />
        ) : null}
      </div>
    </EngineContext.Provider>
  );
}

components/workspaces/kanban-board/board-column.tsx

"use client";

import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element";
import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

import { Button } from "@/components/forms/button";
import { Input } from "@/components/forms/input";
import { GripVertical, Plus, Settings } from "@/components/utility/icon";
import {
  checkMove,
  overLimit,
  type BoardCard,
  type BoardColumn,
} from "../_kernel/board";
import { BoardCardView } from "./board-card";
import { isCard, isColumn, useEngine } from "./engine";
import styles from "./kanban-board.module.css";

/** Where something dragged over the board would land, as drawn. */
export type Indicator =
  | { kind: "card"; cardId: string; edge: "top" | "bottom" }
  | { kind: "end"; columnId: string }
  | { kind: "column"; columnId: string; edge: "left" | "right" }
  | null;

export type BoardColumnViewProps = {
  column: BoardColumn;
  /** Cards shown: all of them, or those the filter matches. */
  cards: readonly BoardCard[];
  readOnly: boolean;
  filtered: boolean;
  /** A card is being dragged that this column would refuse. */
  refused: boolean;
  grabbedCard: string | null;
  indicator: Indicator;
  hintId: string;
  onOpenCard: (cardId: string) => void;
  onCardKey: (cardId: string, event: KeyboardEvent<HTMLDivElement>) => void;
  onAddCard: (title: string) => void;
  onSettings: () => void;
};

/** One stage: its header (the handle for dragging the column), its cards,
 *  and a way to add one. */
export function BoardColumnView({
  column,
  cards,
  readOnly,
  filtered,
  refused,
  grabbedCard,
  indicator,
  hintId,
  onOpenCard,
  onCardKey,
  onAddCard,
  onSettings,
}: BoardColumnViewProps) {
  const engine = useEngine();
  const id = useId();
  const ref = useRef<HTMLElement>(null);
  const head = useRef<HTMLDivElement>(null);
  const list = useRef<HTMLOListElement>(null);
  const [adding, setAdding] = useState(false);
  const [draft, setDraft] = useState("");

  useEffect(() => {
    const element = ref.current;
    if (!element || !head.current || !list.current || readOnly) return;
    return combine(
      draggable({
        element,
        dragHandle: head.current,
        canDrag: () => engine.current.arrangeable,
        getInitialData: () => ({ type: "column", columnId: column.id }),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) =>
          isColumn(source.data) ||
          (isCard(source.data) &&
            !checkMove(
              engine.current.board,
              source.data.cardId as string,
              column.id,
              engine.current.rules,
            )),
        getData: ({ input, source }) => {
          const data = { type: "column", columnId: column.id };
          return isColumn(source.data)
            ? attachClosestEdge(data, {
                input,
                element,
                allowedEdges: ["left", "right"],
              })
            : data;
        },
        getIsSticky: () => true,
      }),
      autoScrollForElements({ element: list.current }),
    );
  }, [engine, column.id, readOnly]);

  const over = overLimit(column);
  const count = filtered
    ? `${cards.length} of ${column.cards.length}`
    : String(column.cards.length);

  function add() {
    const title = draft.trim();
    if (!title) return;
    onAddCard(title);
    setDraft("");
  }

  return (
    <section
      ref={ref}
      className={styles.column}
      aria-labelledby={`${id}-title`}
      data-refused={refused || undefined}
      data-indicator={
        indicator?.kind === "column" && indicator.columnId === column.id
          ? indicator.edge
          : undefined
      }
    >
      <div
        ref={head}
        className={styles.columnHead}
        data-handle={!readOnly || undefined}
      >
        {readOnly ? null : (
          <GripVertical className={styles.grip} aria-hidden="true" />
        )}
        <h3 id={`${id}-title`} className={styles.columnTitle}>
          {column.title}
        </h3>
        <span
          className={styles.count}
          data-over={over || undefined}
          title={
            column.limit !== undefined
              ? `${column.cards.length} of a limit of ${column.limit}`
              : undefined
          }
        >
          {count}
          {column.limit !== undefined ? ` / ${column.limit}` : ""}
          <span className={styles.hidden}>
            {column.cards.length === 1 ? " card" : " cards"}
            {over ? ", over the limit" : ""}
          </span>
        </span>
        {readOnly ? null : (
          <Button
            size="icon"
            variant="quiet"
            className={styles.columnMenu}
            aria-label={`${column.title} settings`}
            onClick={onSettings}
          >
            <Settings aria-hidden="true" />
          </Button>
        )}
      </div>

      <ol ref={list} className={styles.cards}>
        {cards.map((card, index) => (
          <li key={card.id}>
            <BoardCardView
              card={card}
              columnId={column.id}
              index={index}
              readOnly={readOnly}
              grabbed={grabbedCard === card.id}
              indicator={
                indicator?.kind === "card" && indicator.cardId === card.id
                  ? indicator.edge
                  : null
              }
              hintId={hintId}
              onOpen={() => onOpenCard(card.id)}
              onKey={(event) => onCardKey(card.id, event)}
            />
          </li>
        ))}
        {indicator?.kind === "end" && indicator.columnId === column.id ? (
          <li className={styles.endLine} aria-hidden="true" />
        ) : null}
        {cards.length === 0 ? (
          <li className={styles.columnEmpty}>
            {filtered && column.cards.length ? "No matching cards" : "No cards"}
          </li>
        ) : null}
      </ol>

      {readOnly ? null : adding ? (
        <div className={styles.composer}>
          <Input
            size="sm"
            autoFocus
            aria-label={`New card in ${column.title}`}
            placeholder="What needs doing?"
            value={draft}
            onChange={(event) => setDraft(event.target.value)}
            onKeyDown={(event) => {
              if (event.key === "Enter") {
                event.preventDefault();
                add();
              } else if (event.key === "Escape") {
                setAdding(false);
                setDraft("");
              }
            }}
          />
          <div className={styles.composerButtons}>
            <Button
              size="sm"
              variant="primary"
              disabled={!draft.trim()}
              onClick={add}
            >
              Add card
            </Button>
            <Button
              size="sm"
              variant="quiet"
              onClick={() => {
                setAdding(false);
                setDraft("");
              }}
            >
              Done
            </Button>
          </div>
        </div>
      ) : (
        <Button
          size="sm"
          variant="quiet"
          className={styles.addCard}
          onClick={() => setAdding(true)}
        >
          <Plus aria-hidden="true" />
          Add card
        </Button>
      )}
    </section>
  );
}

components/workspaces/kanban-board/board-card.tsx

"use client";

import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine";
import {
  draggable,
  dropTargetForElements,
} from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";

import { Avatar } from "@/components/display/avatar";
import { Badge } from "@/components/display/badge";
import { CalendarDays } from "@/components/utility/icon";
import { checkMove, type BoardCard } from "../_kernel/board";
import { isCard, useEngine } from "./engine";
import {
  describeCard,
  formatDue,
  overdue,
  PRIORITY_LABEL,
  PRIORITY_TONE,
} from "./format";
import styles from "./kanban-board.module.css";

export type BoardCardViewProps = {
  card: BoardCard;
  columnId: string;
  index: number;
  readOnly: boolean;
  /** Picked up with the keyboard. */
  grabbed: boolean;
  /** Where a dragged card would land against this one. */
  indicator: "top" | "bottom" | null;
  /** The id of the hidden text telling how to open and move a card. */
  hintId: string;
  onOpen: () => void;
  onKey: (event: KeyboardEvent<HTMLDivElement>) => void;
};

/** One card: a drag source and a drop target (above or below it). */
export function BoardCardView({
  card,
  columnId,
  index,
  readOnly,
  grabbed,
  indicator,
  hintId,
  onOpen,
  onKey,
}: BoardCardViewProps) {
  const engine = useEngine();
  const ref = useRef<HTMLDivElement>(null);
  const id = useId();
  const [dragging, setDragging] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element || readOnly) return;
    return combine(
      draggable({
        element,
        canDrag: () => engine.current.movable,
        getInitialData: () => ({ type: "card", cardId: card.id }),
        onDragStart: () => setDragging(true),
        onDrop: () => setDragging(false),
      }),
      dropTargetForElements({
        element,
        canDrop: ({ source }) =>
          isCard(source.data) &&
          !checkMove(
            engine.current.board,
            source.data.cardId as string,
            columnId,
            engine.current.rules,
          ),
        getData: ({ input }) =>
          attachClosestEdge(
            { type: "card", cardId: card.id, columnId, index },
            { input, element, allowedEdges: ["top", "bottom"] },
          ),
        getIsSticky: () => true,
      }),
    );
  }, [engine, card.id, columnId, index, readOnly]);

  const due = card.due ? formatDue(card.due) : null;
  const late = overdue(card);
  const description = describeCard(card);

  return (
    <div
      ref={ref}
      className={styles.card}
      data-card-id={card.id}
      data-dragging={dragging || undefined}
      data-grabbed={grabbed || undefined}
      data-indicator={indicator ?? undefined}
      {...(readOnly
        ? {}
        : {
            role: "button",
            tabIndex: 0,
            // Named by its title alone; the facts and the keys describe it.
            "aria-labelledby": `${id}-title`,
            "aria-describedby": description ? `${id}-facts ${hintId}` : hintId,
            "aria-pressed": grabbed ? true : undefined,
            onClick: onOpen,
            onKeyDown: onKey,
          })}
    >
      <p id={`${id}-title`} className={styles.cardTitle}>
        {card.title}
      </p>
      {card.description ? (
        <p className={styles.cardText}>{card.description}</p>
      ) : null}
      {card.tags?.length ? (
        <ul className={styles.tags} aria-label="Tags">
          {card.tags.map((tag) => (
            <li key={tag} className={styles.tag}>
              {tag}
            </li>
          ))}
        </ul>
      ) : null}
      {card.priority || due || card.assignee ? (
        <div className={styles.cardFoot}>
          {card.priority ? (
            <Badge tone={PRIORITY_TONE[card.priority]} dot>
              {PRIORITY_LABEL[card.priority]}
            </Badge>
          ) : null}
          {due ? (
            <span className={styles.due} data-late={late || undefined}>
              <CalendarDays aria-hidden="true" />
              {due}
            </span>
          ) : null}
          {card.assignee ? (
            <Avatar
              name={card.assignee}
              size="sm"
              className={styles.assignee}
            />
          ) : null}
        </div>
      ) : null}
      {/* The facts as a sentence, for a reader that cannot see the badges. */}
      {description ? (
        <span id={`${id}-facts`} className={styles.hidden}>
          {description}
        </span>
      ) : null}
    </div>
  );
}

components/workspaces/kanban-board/board-dialogs.tsx

"use client";

import { useState } from "react";

import { Button } from "@/components/forms/button";
import { Field } from "@/components/forms/field";
import { Input, Textarea } from "@/components/forms/input";
import { NumberInput } from "@/components/forms/number-input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
} from "@/components/forms/select";
import {
  Dialog,
  DialogContent,
  DialogFooter,
} from "@/components/overlays/dialog";
import { ArrowLeft, ArrowRight, Trash2 } from "@/components/utility/icon";
import {
  checkMove,
  locate,
  PRIORITIES,
  type Board,
  type BoardCard,
  type BoardColumn,
  type BoardRules,
} from "../_kernel/board";
import { parseTags, PRIORITY_LABEL } from "./format";
import styles from "./kanban-board.module.css";

const NO_PRIORITY = "none";

export type CardDialogProps = {
  board: Board;
  cardId: string;
  rules: BoardRules;
  /** A change to the card: `key` folds a burst of typing into one undo
   *  step. */
  onChange: (change: Partial<Omit<BoardCard, "id">>, key: string) => void;
  onMove: (columnId: string) => void;
  onDelete: () => void;
  onClose: () => void;
};

/** A card's details, edited in place: every change applies at once and
 *  undoes like any other. Also the pointer-free, drag-free way to move a
 *  card to another column. */
export function CardDialog({
  board,
  cardId,
  rules,
  onChange,
  onMove,
  onDelete,
  onClose,
}: CardDialogProps) {
  const found = locate(board, cardId);
  // Tags are typed as text; the draft keeps what is typed ("a, ") while
  // the card keeps the parsed list.
  const [tags, setTags] = useState(() => found?.card.tags?.join(", ") ?? "");
  if (!found) return null;
  const { card, column } = found;

  return (
    <Dialog open onOpenChange={(open) => (open ? null : onClose())}>
      <DialogContent title="Edit card">
        <div className={styles.dialogBody}>
          <Field label="Title" required>
            {(control) => (
              <Input
                {...control}
                value={card.title}
                onChange={(event) =>
                  onChange({ title: event.target.value }, `title:${card.id}`)
                }
              />
            )}
          </Field>
          <Field label="Description">
            {(control) => (
              <Textarea
                {...control}
                rows={3}
                value={card.description ?? ""}
                onChange={(event) =>
                  onChange(
                    { description: event.target.value || undefined },
                    `description:${card.id}`,
                  )
                }
              />
            )}
          </Field>
          <div className={styles.dialogRow}>
            <Field label="Column">
              {(control) => (
                <Select value={column.id} onValueChange={onMove}>
                  <SelectTrigger {...control} />
                  <SelectContent>
                    {board.columns.map((item) => {
                      const refusal = checkMove(board, card.id, item.id, rules);
                      return (
                        <SelectItem
                          key={item.id}
                          value={item.id}
                          disabled={refusal !== null}
                        >
                          {item.title}
                          {refusal === "limit"
                            ? " (full)"
                            : refusal === "transition"
                              ? " (not from here)"
                              : ""}
                        </SelectItem>
                      );
                    })}
                  </SelectContent>
                </Select>
              )}
            </Field>
            <Field label="Priority">
              {(control) => (
                <Select
                  value={card.priority ?? NO_PRIORITY}
                  onValueChange={(value) =>
                    onChange(
                      {
                        priority:
                          value === NO_PRIORITY
                            ? undefined
                            : (value as BoardCard["priority"]),
                      },
                      `priority:${card.id}`,
                    )
                  }
                >
                  <SelectTrigger {...control} />
                  <SelectContent>
                    <SelectItem value={NO_PRIORITY}>None</SelectItem>
                    {PRIORITIES.map((priority) => (
                      <SelectItem key={priority} value={priority}>
                        {PRIORITY_LABEL[priority]}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
              )}
            </Field>
          </div>
          <div className={styles.dialogRow}>
            <Field label="Assignee">
              {(control) => (
                <Input
                  {...control}
                  value={card.assignee ?? ""}
                  placeholder="Nobody"
                  onChange={(event) =>
                    onChange(
                      { assignee: event.target.value || undefined },
                      `assignee:${card.id}`,
                    )
                  }
                />
              )}
            </Field>
            <Field label="Due">
              {(control) => (
                <Input
                  {...control}
                  type="date"
                  value={card.due ?? ""}
                  onChange={(event) =>
                    onChange(
                      { due: event.target.value || undefined },
                      `due:${card.id}`,
                    )
                  }
                />
              )}
            </Field>
          </div>
          <Field label="Tags" hint="Separate tags with commas.">
            {(control) => (
              <Input
                {...control}
                value={tags}
                onChange={(event) => {
                  setTags(event.target.value);
                  const list = parseTags(event.target.value);
                  onChange(
                    { tags: list.length ? list : undefined },
                    `tags:${card.id}`,
                  );
                }}
              />
            )}
          </Field>
        </div>
        <DialogFooter>
          <Button variant="danger" size="sm" onClick={onDelete}>
            <Trash2 aria-hidden="true" />
            Delete card
          </Button>
          <Button variant="primary" size="sm" onClick={onClose}>
            Done
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

export type ColumnDialogProps = {
  board: Board;
  column: BoardColumn;
  onChange: (
    change: { title?: string; limit?: number | null },
    key: string,
  ) => void;
  onMove: (direction: -1 | 1) => void;
  onDelete: () => void;
  onClose: () => void;
};

/** A column's name, limit and place: the keyboard's way to rearrange
 *  columns, too. */
export function ColumnDialog({
  board,
  column,
  onChange,
  onMove,
  onDelete,
  onClose,
}: ColumnDialogProps) {
  const index = board.columns.findIndex((item) => item.id === column.id);
  return (
    <Dialog open onOpenChange={(open) => (open ? null : onClose())}>
      <DialogContent title="Column settings">
        <div className={styles.dialogBody}>
          <Field label="Name" required>
            {(control) => (
              <Input
                {...control}
                value={column.title}
                onChange={(event) =>
                  onChange({ title: event.target.value }, `column:${column.id}`)
                }
              />
            )}
          </Field>
          <Field
            label="Limit"
            hint="The most cards this stage should hold at once. Empty for no limit."
          >
            {(control) => (
              <NumberInput
                {...control}
                min={1}
                max={99}
                value={column.limit ?? null}
                onValueChange={(value) =>
                  onChange({ limit: value }, `limit:${column.id}`)
                }
              />
            )}
          </Field>
          <div
            className={styles.dialogButtons}
            role="group"
            aria-label="Position"
          >
            <Button
              size="sm"
              variant="secondary"
              disabled={index <= 0}
              onClick={() => onMove(-1)}
            >
              <ArrowLeft aria-hidden="true" />
              Move left
            </Button>
            <Button
              size="sm"
              variant="secondary"
              disabled={index >= board.columns.length - 1}
              onClick={() => onMove(1)}
            >
              Move right
              <ArrowRight aria-hidden="true" />
            </Button>
          </div>
          {column.cards.length ? (
            <p className={styles.dialogNote}>
              Move or delete its {column.cards.length}{" "}
              {column.cards.length === 1 ? "card" : "cards"} to delete this
              column.
            </p>
          ) : null}
        </div>
        <DialogFooter>
          <Button
            variant="danger"
            size="sm"
            disabled={column.cards.length > 0}
            onClick={onDelete}
          >
            <Trash2 aria-hidden="true" />
            Delete column
          </Button>
          <Button variant="primary" size="sm" onClick={onClose}>
            Done
          </Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
  );
}

components/workspaces/kanban-board/kanban-board.module.css

@layer composition {
  .board {
    display: grid;
    gap: var(--space-3);
    min-inline-size: 0;
  }

  /* ── Toolbar ── */
  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
  }
  .toolbar svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .search {
    position: relative;
    inline-size: min(100%, 260px);
  }
  .search input {
    padding-inline-start: 30px;
  }
  .searchIcon {
    position: absolute;
    top: 50%;
    left: 10px;
    color: var(--ink-3);
    transform: translateY(-50%);
    pointer-events: none;
  }
  .summary {
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .toolbarEnd {
    display: flex;
    align-items: center;
    gap: var(--space-1);
    margin-inline-start: auto;
  }
  .filterNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }

  /* ── Columns ── */
  /* The board scrolls sideways inside itself; the page never does. */
  .scroller {
    min-inline-size: 0;
    padding-block-end: var(--space-3);
    overflow-x: auto;
  }
  .columns {
    display: flex;
    align-items: flex-start;
    gap: var(--space-4);
    inline-size: max-content;
  }
  .column {
    position: relative;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr) auto;
    inline-size: 280px;
    max-block-size: 640px;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-sunk);
    transition: opacity var(--dur-1) var(--ease);
  }
  /* While a card is dragged, a column that would refuse it steps back. */
  .scroller[data-dragging] .column[data-refused] {
    opacity: 0.45;
  }
  /* A dragged column lands before or after this one. */
  .column[data-indicator]::after {
    content: "";
    position: absolute;
    top: 0;
    bottom: 0;
    inline-size: 3px;
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .column[data-indicator="left"]::after {
    left: calc(-1 * var(--space-4) / 2 - 2px);
  }
  .column[data-indicator="right"]::after {
    right: calc(-1 * var(--space-4) / 2 - 2px);
  }
  .columnHead {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: 44px;
    padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  }
  .columnHead[data-handle] {
    padding-inline-start: var(--space-2);
    cursor: grab;
  }
  .grip {
    flex: none;
    inline-size: 14px;
    block-size: 14px;
    color: var(--ink-4);
  }
  .columnTitle {
    overflow: hidden;
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .count {
    flex: none;
    padding: 1px var(--space-2);
    border-radius: var(--radius-pill);
    background: var(--surface-hover);
    color: var(--ink-3);
    font-family: var(--font-mono);
    font-size: var(--text-11);
  }
  .count[data-over] {
    background: var(--warn-tint);
    color: var(--warn);
  }
  .columnMenu {
    margin-inline-start: auto;
  }
  .columnMenu svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .cards {
    display: grid;
    align-content: start;
    gap: var(--space-2);
    min-block-size: 48px;
    margin: 0;
    padding: var(--space-1) var(--space-2) var(--space-2);
    overflow-y: auto;
    list-style: none;
  }
  .columnEmpty {
    display: grid;
    place-items: center;
    min-block-size: 64px;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-2);
    color: var(--ink-4);
    font-size: var(--text-12);
  }
  .endLine {
    block-size: 2px;
    margin-block: calc(-1 * var(--space-1));
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .addCard {
    justify-self: stretch;
    justify-content: flex-start;
    margin: 0 var(--space-2) var(--space-2);
    color: var(--ink-3);
  }
  .addCard svg {
    inline-size: 14px;
    block-size: 14px;
  }
  .composer {
    display: grid;
    gap: var(--space-2);
    padding: 0 var(--space-2) var(--space-2);
  }
  .composerButtons {
    display: flex;
    gap: var(--space-2);
  }

  /* ── Cards ── */
  .card {
    position: relative;
    display: grid;
    gap: var(--space-2);
    padding: var(--space-3);
    border: 1px solid var(--line);
    border-radius: var(--radius-2);
    background: var(--surface-panel);
    color: var(--ink);
    box-shadow: 0 1px 2px var(--alpha-b-06);
    cursor: grab;
  }
  .card[role="button"]:hover {
    border-color: var(--line-strong);
  }
  .card:not([role="button"]) {
    cursor: default;
  }
  .card:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
  }
  .card[data-dragging] {
    opacity: 0.4;
  }
  /* Picked up with the keyboard: lifted, and marked by more than colour. */
  .card[data-grabbed] {
    border-color: var(--accent);
    box-shadow:
      0 0 0 1px var(--accent),
      0 8px 20px var(--alpha-b-13);
    transform: rotate(-1deg);
  }
  /* A dragged card lands above or below this one. */
  .card[data-indicator]::after {
    content: "";
    position: absolute;
    right: 0;
    left: 0;
    block-size: 2px;
    border-radius: var(--radius-pill);
    background: var(--accent);
  }
  .card[data-indicator="top"]::after {
    top: calc(-1 * var(--space-2) / 2 - 2px);
  }
  .card[data-indicator="bottom"]::after {
    bottom: calc(-1 * var(--space-2) / 2 - 2px);
  }
  .cardTitle {
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    line-height: var(--leading-snug);
  }
  .cardText {
    display: -webkit-box;
    overflow: hidden;
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
    line-height: var(--leading-snug);
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
  }
  .tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-1);
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .tag {
    padding: 0 var(--space-2);
    border: 1px solid var(--line);
    border-radius: var(--radius-1);
    color: var(--ink-2);
    font-size: var(--text-11);
    line-height: 18px;
  }
  .cardFoot {
    display: flex;
    align-items: center;
    gap: var(--space-3);
    min-block-size: 20px;
  }
  .due {
    display: inline-flex;
    align-items: center;
    gap: var(--space-1);
    color: var(--ink-3);
    font-size: var(--text-11);
  }
  .due svg {
    inline-size: 12px;
    block-size: 12px;
  }
  .due[data-late] {
    color: var(--crit);
  }
  .assignee {
    margin-inline-start: auto;
  }

  .empty {
    display: grid;
    place-items: center;
    min-block-size: 200px;
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-3);
    color: var(--ink-3);
    font-size: var(--text-13);
  }

  /* ── Dialogs ── */
  .dialogBody {
    display: grid;
    gap: var(--space-5);
  }
  .dialogRow {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: var(--space-4);
  }
  .dialogButtons {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
  }
  .dialogButtons svg {
    inline-size: 14px;
    block-size: 14px;
  }
  .dialogNote {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }

  /* Read by assistive technology, not shown. */
  .hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

components/workspaces/_kernel/board.ts

/* A board: columns of cards, moved between stages. Plain data and pure
   functions, framework-free and identical in both apps; the board, its drag
   engine and its keyboard mode only render it and call these. Every change
   returns a new board, so history can keep the old one. */

export type Priority = "low" | "medium" | "high" | "urgent";

export type BoardCard = Readonly<{
  id: string;
  title: string;
  description?: string;
  tags?: readonly string[];
  /** A person's name. */
  assignee?: string;
  /** A calendar date, YYYY-MM-DD. */
  due?: string;
  priority?: Priority;
}>;

export type BoardColumn = Readonly<{
  id: string;
  title: string;
  /** How many cards the stage should hold at once (work in progress). */
  limit?: number;
  cards: readonly BoardCard[];
}>;

export type Board = Readonly<{ columns: readonly BoardColumn[] }>;

export type BoardRules = Readonly<{
  /** Where a card may go from each column. A column not listed may send
   *  cards anywhere; moving within a column is always allowed. */
  transitions?: Readonly<Record<string, readonly string[]>>;
  /** Refuse a move into a column at its limit. Off, the column only shows
   *  that it is over. */
  enforceLimits?: boolean;
}>;

/** Where a card sits: its column, and its place counted from the top. */
export type Place = Readonly<{ columnId: string; index: number }>;

export type BoardRefusal = "unknown" | "transition" | "limit";

export const BOARD_REFUSALS: Readonly<Record<BoardRefusal, string>> = {
  unknown: "That card or column does not exist.",
  transition: "Cards cannot move there from this column.",
  limit: "That column is at its limit.",
};

export const PRIORITIES: readonly Priority[] = [
  "low",
  "medium",
  "high",
  "urgent",
];

export const EMPTY_BOARD: Board = { columns: [] };

// ── Reading ──────────────────────────────────────────────────────────────

/** A card's column and place, or null. */
export function locate(board: Board, cardId: string) {
  for (const [columnIndex, column] of board.columns.entries()) {
    const index = column.cards.findIndex((card) => card.id === cardId);
    if (index >= 0)
      return { column, columnIndex, index, card: column.cards[index] };
  }
  return null;
}

export const cardCount = (board: Board) =>
  board.columns.reduce((sum, column) => sum + column.cards.length, 0);

export const overLimit = (column: BoardColumn) =>
  column.limit !== undefined && column.cards.length > column.limit;

/** Whether a card mentions `query` in its title, description, tags or
 *  assignee, ignoring case. An empty query matches everything. */
export function matches(card: BoardCard, query: string) {
  const q = query.trim().toLowerCase();
  if (!q) return true;
  return [card.title, card.description, card.assignee, ...(card.tags ?? [])]
    .filter(Boolean)
    .some((text) => text!.toLowerCase().includes(q));
}

// ── Moving cards ─────────────────────────────────────────────────────────

/** Why moving `cardId` into `columnId` would be refused, or null. */
export function checkMove(
  board: Board,
  cardId: string,
  columnId: string,
  rules: BoardRules = {},
): BoardRefusal | null {
  const from = locate(board, cardId);
  const to = board.columns.find((column) => column.id === columnId);
  if (!from || !to) return "unknown";
  if (from.column.id === columnId) return null;
  const allowed = rules.transitions?.[from.column.id];
  if (allowed && !allowed.includes(columnId)) return "transition";
  if (
    rules.enforceLimits &&
    to.limit !== undefined &&
    to.cards.length >= to.limit
  )
    return "limit";
  return null;
}

/** Moves a card to `to`, its index counted in the destination without the
 *  card (so it is the card's index afterwards); clamped to the column. */
export function moveCard(
  board: Board,
  cardId: string,
  to: Place,
  rules: BoardRules = {},
): { board: Board; refusal: null } | { board: null; refusal: BoardRefusal } {
  const refusal = checkMove(board, cardId, to.columnId, rules);
  if (refusal) return { board: null, refusal };
  const from = locate(board, cardId)!;
  const columns = board.columns.map((column) =>
    column.id === from.column.id
      ? { ...column, cards: column.cards.filter((card) => card.id !== cardId) }
      : column,
  );
  return {
    board: {
      columns: columns.map((column) => {
        if (column.id !== to.columnId) return column;
        const cards = [...column.cards];
        const index = Math.max(0, Math.min(to.index, cards.length));
        cards.splice(index, 0, from.card);
        return { ...column, cards };
      }),
    },
    refusal: null,
  };
}

/** Where a drop lands: over the card at `overIndex` (as drawn, dragged
 *  card included) on its top or bottom edge, or over the column itself
 *  (`overIndex` null), which means its end. */
export function dropPlace(
  board: Board,
  cardId: string,
  columnId: string,
  overIndex: number | null,
  edge: "top" | "bottom" | null,
): Place {
  const column = board.columns.find((item) => item.id === columnId);
  const from = locate(board, cardId);
  const same = from?.column.id === columnId;
  const length = (column?.cards.length ?? 0) - (same ? 1 : 0);
  if (overIndex === null) return { columnId, index: length };
  let index = edge === "bottom" ? overIndex + 1 : overIndex;
  // The card leaves its old place first, so everything after it shifts up.
  if (same && from && from.index < index) index -= 1;
  return { columnId, index: Math.max(0, Math.min(index, length)) };
}

export type Direction = "up" | "down" | "left" | "right";

/** The keyboard's next place for a card: up and down within its column;
 *  left and right to the nearest column in that direction that takes it,
 *  at the same height or the end. Null at an edge; a refusal when no
 *  column that way takes it. */
export function step(
  board: Board,
  cardId: string,
  direction: Direction,
  rules: BoardRules = {},
):
  | { place: Place; refusal: null }
  | { place: null; refusal: BoardRefusal | null } {
  const from = locate(board, cardId);
  if (!from) return { place: null, refusal: "unknown" };
  if (direction === "up" || direction === "down") {
    const index = from.index + (direction === "up" ? -1 : 1);
    if (index < 0 || index >= from.column.cards.length)
      return { place: null, refusal: null };
    return { place: { columnId: from.column.id, index }, refusal: null };
  }
  const delta = direction === "left" ? -1 : 1;
  let first: BoardRefusal | null = null;
  for (
    let c = from.columnIndex + delta;
    c >= 0 && c < board.columns.length;
    c += delta
  ) {
    const column = board.columns[c];
    const refusal = checkMove(board, cardId, column.id, rules);
    if (!refusal)
      return {
        place: {
          columnId: column.id,
          index: Math.min(from.index, column.cards.length),
        },
        refusal: null,
      };
    first ??= refusal;
  }
  return { place: null, refusal: first };
}

// ── Cards and columns ────────────────────────────────────────────────────

export function addCard(
  board: Board,
  columnId: string,
  card: BoardCard,
  at: "start" | "end" = "end",
): Board {
  return {
    columns: board.columns.map((column) =>
      column.id === columnId
        ? {
            ...column,
            cards:
              at === "start"
                ? [card, ...column.cards]
                : [...column.cards, card],
          }
        : column,
    ),
  };
}

export function updateCard(
  board: Board,
  cardId: string,
  change: Partial<Omit<BoardCard, "id">>,
): Board {
  return {
    columns: board.columns.map((column) =>
      column.cards.some((card) => card.id === cardId)
        ? {
            ...column,
            cards: column.cards.map((card) =>
              card.id === cardId ? { ...card, ...change } : card,
            ),
          }
        : column,
    ),
  };
}

export function removeCard(board: Board, cardId: string): Board {
  return {
    columns: board.columns.map((column) => ({
      ...column,
      cards: column.cards.filter((card) => card.id !== cardId),
    })),
  };
}

export function addColumn(board: Board, column: BoardColumn): Board {
  return { columns: [...board.columns, column] };
}

/** Renames a column or changes its limit; a null limit removes it. */
export function updateColumn(
  board: Board,
  columnId: string,
  change: Readonly<{ title?: string; limit?: number | null }>,
): Board {
  return {
    columns: board.columns.map((column) => {
      if (column.id !== columnId) return column;
      const next: { -readonly [K in keyof BoardColumn]: BoardColumn[K] } = {
        ...column,
      };
      if (change.title !== undefined) next.title = change.title;
      if (change.limit === null) delete next.limit;
      else if (change.limit !== undefined) next.limit = change.limit;
      return next;
    }),
  };
}

/** Removes an empty column. Null when it still has cards: they would be
 *  lost, so the caller moves them first. */
export function removeColumn(board: Board, columnId: string): Board | null {
  const column = board.columns.find((item) => item.id === columnId);
  if (!column || column.cards.length) return null;
  return { columns: board.columns.filter((item) => item.id !== columnId) };
}

/** Moves a column to `index`, counted without it. */
export function moveColumn(
  board: Board,
  columnId: string,
  index: number,
): Board {
  const from = board.columns.findIndex((column) => column.id === columnId);
  if (from < 0) return board;
  const columns = board.columns.filter((column) => column.id !== columnId);
  const to = Math.max(0, Math.min(index, columns.length));
  if (to === from) return board;
  columns.splice(to, 0, board.columns[from]);
  return { columns };
}

/** An id not yet used on the board: c1, c2… for cards, s1, s2… for
 *  columns (stages). */
export function nextBoardId(board: Board, prefix: string) {
  const ids = new Set([
    ...board.columns.map((column) => column.id),
    ...board.columns.flatMap((column) => column.cards.map((card) => card.id)),
  ]);
  let n = 1;
  while (ids.has(`${prefix}${n}`)) n += 1;
  return `${prefix}${n}`;
}

/** Where a dragged column lands, dropped on the left or right half of
 *  another; counted without the dragged column, as moveColumn takes it. */
export function columnDropIndex(
  board: Board,
  columnId: string,
  overColumnId: string,
  edge: "left" | "right" | null,
): number {
  const from = board.columns.findIndex((column) => column.id === columnId);
  const over = board.columns.findIndex((column) => column.id === overColumnId);
  if (from < 0 || over < 0) return Math.max(from, 0);
  let index = edge === "right" ? over + 1 : over;
  if (from < index) index -= 1;
  return index;
}

DashboardGrid

customize · drag · resize · add and remove

Revenue

MRR$48.2k+6.2% this month

Active workspaces

This week1,284+38 since Monday

Error rate

Last 24 hours2.3%Down from 4.2%

Active workspaces by month

  • This year
  • Last year
Use the left and right arrow keys to read each position.
View data for Active workspaces by month
Active workspaces by month
LabelThis yearLast year
Oct1,180840
Nov1,242876
Dec1,204910
Jan1,310902
Feb1,398954
Mar1,4671,003
Apr1,5201,041
May1,6041,066
Jun1,6881,102
Jul1,7311,150
Aug1,8401,162
Sep1,9621,175

Workspaces by plan

  • Free1,840 · 63.9%
  • Starter612 · 21.2%
  • Team388 · 13.5%
  • Enterprise41 · 1.4%

Arrow keys move the widget one cell; Shift and the arrow keys resize it.

Customize, then Done. Viewing is just the widgets; Customize shows the cells, a handle and a corner on each widget, and Add widget, Tidy, undo and redo. Plan limits and Recent activity start in Add widget.

Nothing ever overlaps. A widget moved or grown onto others pushes them down; gaps stay where they are left until Tidy closes them.

Every gesture has a key. Tab to a widget's handle: the arrows move it a cell (up or down into a neighbour swaps them), Shift and the arrows resize it, each step announced. Narrow screens stack the widgets in reading order and keep the saved arrangement.

Sourcecomponents/workspaces/dashboard-grid/doc.ts · components/workspaces/dashboard-grid/dashboard-grid.tsx · components/workspaces/dashboard-grid/dashboard-grid.module.css · components/workspaces/_kernel/grid.ts

components/workspaces/dashboard-grid/doc.ts

/**
 * DashboardGrid — widgets on a grid that a person arranges for themselves.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the dashboard
 *     widgets        REQUIRED   { id, title, description?, size? {w,h},
 *                               limits? {minW,minH,maxW,maxH}, content }[]
 *                               — everything it can show
 *     defaultValue?  { id, x, y, w, h }[] in cells: what it shows, where
 *     onChange?      every committed change, to persist
 *     readOnly?      show only; no Customize
 *     columns?       12        rowHeight?  72 (px)
 *     stackBelow?    640 (px)  empty?      what an empty dashboard says
 *
 * # Behaviour
 *
 * R1  Viewing shows widgets where the layout puts them. Customize turns on
 *     arranging: the cells show, each widget gets a handle, a remove button
 *     and a resize corner, and the toolbar gets Add widget, Tidy, undo, redo
 *     and Done.
 * R2  A widget is moved by dragging its header and resized by dragging its
 *     corner. It follows the pointer exactly, clamped — a move stays inside
 *     the columns and below the top, a resize within the widget's limits
 *     and the right edge — while a placeholder shows the cells it will land
 *     in (the nearest, at the half-cell) and the others make room. On
 *     release it glides into those cells; Escape glides it back.
 * R2a Every change of place — a drag's neighbours, a drop, a keyboard step,
 *     Tidy, an added widget — glides rather than jumps, and a glide
 *     interrupted turns smoothly toward its new end. With reduced motion,
 *     changes are immediate.
 * R3  Placement is free: gaps stay where a person leaves them. A widget
 *     moved or grown onto others pushes them straight down, never over or
 *     aside, so widgets never overlap. Sizes stay within the widget's
 *     limits and the columns.
 * R4  Every gesture has a keyboard way. A widget's handle is one tab stop:
 *     the arrows move it a cell (up or down into a neighbour swaps them),
 *     Shift and the arrows resize it; each step is announced as column, row
 *     and size, and focus stays on the handle as widgets reorder.
 * R5  Add widget lists the widgets not shown, with what each shows, and
 *     places the chosen one in the first free place that fits it. Removing
 *     a widget returns it to that list.
 * R6  Tidy closes vertical gaps: each widget rises as far as it can; none
 *     changes column. It is disabled when there is nothing to close.
 * R7  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change; a run
 *     of arrow presses on one widget is one step. onChange receives
 *     committed changes only: not a drag in progress, not the start.
 * R8  Widgets render in reading order (top to bottom, then left to right),
 *     so the tab order and a screen reader follow what is seen.
 * R9  Below `stackBelow` the widgets stack in reading order at their own
 *     heights and cannot be arranged (they can still be added and removed);
 *     the saved layout is untouched.
 * R10 A saved layout naming a widget that no longer exists drops it.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The layout maths — fitting, pushing down, nudging and swapping, turning
 * pointer travel into cells, first fit, compaction, reading order — is the
 * framework-free kernel in ../_kernel/grid.ts (identical in both apps,
 * tested there), with history from ../_kernel/history.ts. No drag engine:
 * widgets are placed absolutely from the layout with calc() over the
 * column pitch, so a change of place is a CSS transition (interruptible by
 * nature). A gesture is pointer capture on the header or corner; the
 * kernel's drift() turns pointer travel into the clamped floating rectangle
 * and the cells it lands on, applied to the layout as it was when the
 * gesture began. During a drag the document order is held still, since
 * moving the dragged element would release its pointer capture. Both apps therefore behave
 * identically. The same component, with a different kernel rule, differs
 * from flover's (which refuses occupied destinations): refusing makes a
 * full dashboard impossible to rearrange.
 */
export {};

components/workspaces/dashboard-grid/dashboard-grid.tsx

"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

import { Button } from "@/components/forms/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/overlays/dropdown-menu";
import {
  GripVertical,
  LayoutGrid,
  Plus,
  Redo2,
  Undo2,
  WandSparkles,
  X,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  add,
  compact,
  DEFAULT_COLUMNS,
  describePlace,
  drift,
  gesture,
  nudge,
  readingOrder,
  remove,
  rows,
  type Layout,
  type Nudge,
  type Pitch,
  type Rect,
  type SizeLimits,
} from "../_kernel/grid";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import styles from "./dashboard-grid.module.css";

export type DashboardWidget = {
  id: string;
  title: string;
  /** What it shows, for the Add widget menu. */
  description?: string;
  /** Its size when added, in cells. */
  size?: Readonly<{ w: number; h: number }>;
  limits?: SizeLimits;
  content: ReactNode;
};

export type DashboardGridProps = {
  /** Names the dashboard for assistive technology. */
  label: string;
  /** Every widget the dashboard can show; the layout says which it does. */
  widgets: readonly DashboardWidget[];
  /** Where arranging starts. The grid keeps its own history from here. */
  defaultValue?: Layout;
  /** Every committed change: a drop, a resize, an added or removed
   *  widget. Persist here. */
  onChange?: (layout: Layout) => void;
  /** Show only; no Customize. */
  readOnly?: boolean;
  columns?: number;
  /** One row's height, in pixels. */
  rowHeight?: number;
  /** Below this width, in pixels, widgets stack in reading order and
   *  cannot be arranged. */
  stackBelow?: number;
  /** Shown when no widget is placed. */
  empty?: ReactNode;
  className?: string;
};

const NUDGES: Readonly<Record<string, Nudge>> = {
  ArrowLeft: "left",
  ArrowRight: "right",
  ArrowUp: "up",
  ArrowDown: "down",
};
const DEFAULT_SIZE = { w: 4, h: 3 };

type Gesture = {
  id: string;
  mode: "move" | "resize";
  pointer: number;
  from: { x: number; y: number };
  pitch: Pitch;
  /** Where the widget is drawn: following the pointer, clamped. */
  rect: Rect;
  baseline: Layout;
  /** Where it would land, and where the others make room. */
  preview: Layout;
};

/** Widgets on a grid that a person arranges: moved by their header,
 *  resized from their corner, added and removed; see doc.ts. */
export function DashboardGrid({
  label,
  widgets,
  defaultValue = [],
  onChange,
  readOnly = false,
  columns = DEFAULT_COLUMNS,
  rowHeight = 72,
  stackBelow = 640,
  empty = "No widgets yet. Customize the dashboard to add one.",
  className,
}: DashboardGridProps) {
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const grid = useRef<HTMLDivElement>(null);
  const byId = new Map(widgets.map((widget) => [widget.id, widget]));

  const [history, setHistory] = useState<History<Layout>>(() =>
    // A saved layout may name widgets this build no longer has.
    start(defaultValue.filter((item) => byId.has(item.id))),
  );
  const change = (next: Layout, key: string | null = null) =>
    setHistory((current) => commit(current, next, key));
  const reported = useRef(history.present);
  useEffect(() => {
    if (reported.current === history.present) return;
    reported.current = history.present;
    onChange?.(history.present);
  }, [history.present, onChange]);

  const [editing, setEditing] = useState(false);
  const [drag, setDrag] = useState<Gesture | null>(null);
  const [message, setMessage] = useState("");
  const [width, setWidth] = useState<number | null>(null);
  // The handle a keyboard move came from: widgets render in reading order,
  // so a move can reorder them, and focus must follow.
  const refocus = useRef<string | null>(null);

  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    const widgetId = refocus.current;
    if (!widgetId) return;
    refocus.current = null;
    root.current
      ?.querySelector<HTMLElement>(`[data-handle="${CSS.escape(widgetId)}"]`)
      ?.focus();
  });

  // A drag keeps focus where it was (often nowhere), so Escape is heard
  // on the window while one is in progress.
  const dragging = drag !== null;
  useEffect(() => {
    if (!dragging) return;
    const cancel = (event: globalThis.KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setDrag(null);
      setMessage("Cancelled.");
    };
    window.addEventListener("keydown", cancel);
    return () => window.removeEventListener("keydown", cancel);
  }, [dragging]);

  const stacked = width !== null && width < stackBelow;
  const arranging = editing && !readOnly && !stacked;
  const layout = drag?.preview ?? history.present;
  const placed = new Set(layout.map((item) => item.id));
  const available = widgets.filter((widget) => !placed.has(widget.id));
  const titleOf = (widgetId: string) => byId.get(widgetId)?.title ?? widgetId;

  function announce(next: Layout, widgetId: string, verb: string) {
    const item = next.find((entry) => entry.id === widgetId);
    if (item)
      setMessage(`${verb} ${titleOf(widgetId)}: ${describePlace(item)}.`);
  }

  // ── Pointer: move by the header, resize by the corner ──
  function begin(
    event: PointerEvent<HTMLElement>,
    widgetId: string,
    mode: Gesture["mode"],
  ) {
    if (!arranging || event.button !== 0 || !grid.current) return;
    // Buttons in the header keep their own clicks.
    if (
      mode === "move" &&
      (event.target as HTMLElement).closest("button:not([data-handle])")
    )
      return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    const item = history.present.find((entry) => entry.id === widgetId);
    if (!item) return;
    const gap = parseFloat(getComputedStyle(grid.current).rowGap) || 0;
    const pitch = {
      x: (grid.current.clientWidth + gap) / columns,
      y: rowHeight + gap,
      gap,
    };
    setDrag({
      id: widgetId,
      mode,
      pointer: event.pointerId,
      from: { x: event.clientX, y: event.clientY },
      pitch,
      rect: drift(item, mode, 0, 0, pitch, columns).rect,
      baseline: history.present,
      preview: history.present,
    });
  }
  function move(event: PointerEvent<HTMLElement>) {
    if (!drag || event.pointerId !== drag.pointer) return;
    const item = drag.baseline.find((entry) => entry.id === drag.id);
    if (!item) return;
    const limits = byId.get(drag.id)?.limits;
    const { rect, cells } = drift(
      item,
      drag.mode,
      event.clientX - drag.from.x,
      event.clientY - drag.from.y,
      drag.pitch,
      columns,
      limits,
    );
    const preview = gesture(
      drag.baseline,
      drag.id,
      drag.mode,
      cells,
      columns,
      limits,
    );
    setDrag({ ...drag, rect, preview });
  }
  function finish(event: PointerEvent<HTMLElement>) {
    if (!drag || event.pointerId !== drag.pointer) return;
    setDrag(null);
    if (drag.preview !== drag.baseline) {
      change(drag.preview);
      announce(
        drag.preview,
        drag.id,
        drag.mode === "move" ? "Moved" : "Resized",
      );
    }
  }

  // ── Keyboard: arrows move, Shift+arrows resize ──
  function onHandleKey(
    event: KeyboardEvent<HTMLButtonElement>,
    widgetId: string,
  ) {
    const direction = NUDGES[event.key];
    if (!direction) return;
    event.preventDefault();
    const resize = event.shiftKey;
    const current = history.present;
    const next = nudge(
      current,
      widgetId,
      direction,
      resize,
      columns,
      byId.get(widgetId)?.limits,
    );
    if (next === current) {
      setMessage(
        `${titleOf(widgetId)} cannot ${resize ? "change size" : "move"} that way.`,
      );
      return;
    }
    change(next, `${resize ? "size" : "move"}:${widgetId}`);
    announce(next, widgetId, resize ? "Resized" : "Moved");
    refocus.current = widgetId;
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (!arranging) return;
    const target = event.target as HTMLElement;
    if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      step(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      step("redo");
    }
  }

  function step(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  // Reading order, but held still during a drag: moving the dragged
  // element in the document would drop its pointer capture.
  const ordered = drag
    ? readingOrder(drag.baseline).map(
        (item) => layout.find((entry) => entry.id === item.id) ?? item,
      )
    : readingOrder(layout);
  const gridStyle = {
    "--columns": columns,
    "--row-h": `${rowHeight}px`,
    // A spare row while arranging, to drop into below the rest.
    "--rows": Math.max(
      rows(layout) + (arranging ? 1 : 0),
      drag ? Math.ceil((drag.rect.top + drag.rect.height) / drag.pitch.y) : 0,
      1,
    ),
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={cn(styles.dashboard, className)}
      data-editing={arranging || undefined}
      onKeyDown={onKeyDown}
    >
      {readOnly ? null : (
        <div
          className={styles.toolbar}
          role="toolbar"
          aria-label={`${label} tools`}
        >
          {editing ? (
            <>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    size="sm"
                    variant="secondary"
                    disabled={available.length === 0}
                  >
                    <Plus aria-hidden="true" />
                    Add widget
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="start" className={styles.menu}>
                  <DropdownMenuLabel>Widgets</DropdownMenuLabel>
                  {available.map((widget) => (
                    <DropdownMenuItem
                      key={widget.id}
                      className={styles.menuItem}
                      onSelect={() => {
                        const next = add(
                          history.present,
                          widget.id,
                          widget.size ?? DEFAULT_SIZE,
                          columns,
                        );
                        change(next);
                        announce(next, widget.id, "Added");
                      }}
                    >
                      <span className={styles.menuTitle}>{widget.title}</span>
                      {widget.description ? (
                        <span className={styles.menuNote}>
                          {widget.description}
                        </span>
                      ) : null}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
              {stacked ? null : (
                <>
                  <Button
                    size="sm"
                    variant="quiet"
                    disabled={compact(history.present) === history.present}
                    onClick={() => {
                      change(compact(history.present));
                      setMessage("Closed the gaps.");
                    }}
                  >
                    <WandSparkles aria-hidden="true" />
                    Tidy
                  </Button>
                  <Button
                    size="icon"
                    variant="quiet"
                    aria-label="Undo"
                    title="Undo (⌘Z)"
                    disabled={!canUndo(history)}
                    onClick={() => step("undo")}
                  >
                    <Undo2 aria-hidden="true" />
                  </Button>
                  <Button
                    size="icon"
                    variant="quiet"
                    aria-label="Redo"
                    title="Redo (⇧⌘Z)"
                    disabled={!canRedo(history)}
                    onClick={() => step("redo")}
                  >
                    <Redo2 aria-hidden="true" />
                  </Button>
                </>
              )}
              <Button
                size="sm"
                variant="primary"
                className={styles.toolbarEnd}
                onClick={() => {
                  setEditing(false);
                  setMessage("Done customizing.");
                }}
              >
                Done
              </Button>
            </>
          ) : (
            <Button
              size="sm"
              variant="secondary"
              className={styles.toolbarEnd}
              onClick={() => {
                setEditing(true);
                setMessage(
                  stacked
                    ? "Customizing. Widgets can be added and removed here; widen the window to arrange them."
                    : "Customizing. Drag a widget by its header or its corner, or use its handle: arrow keys move it, Shift and the arrow keys resize it.",
                );
              }}
            >
              <LayoutGrid aria-hidden="true" />
              Customize
            </Button>
          )}
        </div>
      )}
      {editing && stacked ? (
        <p className={styles.note}>
          Widgets stack on narrow screens. Widen the window to move and resize
          them.
        </p>
      ) : null}

      <div
        ref={grid}
        role="region"
        aria-label={label}
        className={stacked ? styles.stack : styles.grid}
        style={gridStyle}
      >
        {ordered.length === 0 ? (
          <div className={styles.empty}>{empty}</div>
        ) : null}
        {drag
          ? (() => {
              const landing = layout.find((item) => item.id === drag.id);
              return landing ? (
                <div
                  className={styles.placeholder}
                  aria-hidden="true"
                  style={
                    {
                      "--x": landing.x,
                      "--y": landing.y,
                      "--w": landing.w,
                      "--h": landing.h,
                    } as CSSProperties
                  }
                />
              ) : null;
            })()
          : null}
        {ordered.map((item) => {
          const widget = byId.get(item.id);
          if (!widget) return null;
          const active = drag?.id === item.id;
          return (
            <section
              key={item.id}
              aria-labelledby={`${id}-${item.id}`}
              className={styles.widget}
              data-active={active || undefined}
              style={
                active
                  ? // Following the pointer; the placeholder shows where
                    // it lands, and it glides there on release.
                    {
                      left: drag.rect.left,
                      top: drag.rect.top,
                      width: drag.rect.width,
                      height: drag.rect.height,
                    }
                  : ({
                      "--x": item.x,
                      "--y": item.y,
                      "--w": item.w,
                      "--h": item.h,
                    } as CSSProperties)
              }
            >
              <header
                className={styles.head}
                onPointerDown={(event) => begin(event, item.id, "move")}
                onPointerMove={move}
                onPointerUp={finish}
                onPointerCancel={() => setDrag(null)}
              >
                {arranging ? (
                  <button
                    type="button"
                    className={styles.handle}
                    data-handle={item.id}
                    aria-label={`Arrange ${widget.title}`}
                    aria-describedby={`${id}-hint`}
                    onKeyDown={(event) => onHandleKey(event, item.id)}
                  >
                    <GripVertical aria-hidden="true" />
                  </button>
                ) : null}
                <h3 id={`${id}-${item.id}`} className={styles.title}>
                  {widget.title}
                </h3>
                {editing ? (
                  <Button
                    size="icon"
                    variant="quiet"
                    className={styles.remove}
                    aria-label={`Remove ${widget.title}`}
                    onClick={() => {
                      change(remove(history.present, item.id));
                      setMessage(
                        `Removed ${widget.title}. Add widget brings it back.`,
                      );
                    }}
                  >
                    <X aria-hidden="true" />
                  </Button>
                ) : null}
              </header>
              <div className={styles.body}>{widget.content}</div>
              {arranging ? (
                <span
                  className={styles.resize}
                  aria-hidden="true"
                  onPointerDown={(event) => begin(event, item.id, "resize")}
                  onPointerMove={move}
                  onPointerUp={finish}
                  onPointerCancel={() => setDrag(null)}
                />
              ) : null}
            </section>
          );
        })}
      </div>

      <p id={`${id}-hint`} className={styles.hidden}>
        Arrow keys move the widget one cell; Shift and the arrow keys resize it.
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

components/workspaces/dashboard-grid/dashboard-grid.module.css

@layer composition {
  .dashboard {
    display: grid;
    gap: var(--space-4);
    min-inline-size: 0;
  }

  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
  }
  .toolbar svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .toolbarEnd {
    margin-inline-start: auto;
  }
  .note {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .menu {
    max-inline-size: 300px;
  }
  .menuItem {
    display: grid;
    gap: 1px;
    block-size: auto;
    padding-block: var(--space-2);
  }
  .menuTitle {
    font-weight: var(--weight-medium);
  }
  .menuNote {
    color: var(--ink-3);
    font-size: var(--text-12);
    white-space: normal;
  }

  /* ── The grid ── */
  /* Widgets are placed absolutely from the layout, so a change of place is
     a transition: widgets glide out of the way and into their cells, and a
     glide interrupted mid-way turns smoothly toward its new end. */
  .grid {
    --gap: var(--space-4);
    position: relative;
    gap: var(--gap);
    block-size: calc(var(--rows) * (var(--row-h) + var(--gap)) - var(--gap));
    min-block-size: calc(var(--row-h) * 2);
  }
  .grid > .widget,
  .placeholder {
    position: absolute;
    left: calc(var(--x) * (100% + var(--gap)) / var(--columns));
    top: calc(var(--y) * (var(--row-h) + var(--gap)));
    width: calc(var(--w) * (100% + var(--gap)) / var(--columns) - var(--gap));
    height: calc(var(--h) * (var(--row-h) + var(--gap)) - var(--gap));
    transition:
      left var(--dur-2) var(--ease),
      top var(--dur-2) var(--ease),
      width var(--dur-2) var(--ease),
      height var(--dur-2) var(--ease);
  }
  /* The widget under the pointer follows it exactly. */
  .grid > .widget[data-active] {
    transition: none;
  }
  /* Where the dragged widget will land. */
  .placeholder {
    border: 2px dashed var(--accent);
    border-radius: var(--radius-3);
    background: var(--accent-tint);
  }
  @media (prefers-reduced-motion: reduce) {
    .grid > .widget,
    .placeholder {
      transition: none;
    }
  }
  /* Arranging shows the cells, so a drop has something to snap to. */
  .dashboard[data-editing] .grid {
    border-radius: var(--radius-3);
    background-image: linear-gradient(
      to right,
      var(--line) 1px,
      transparent 1px
    );
    background-size: calc((100% + var(--space-4)) / var(--columns)) 100%;
    background-position: calc(-1 * var(--space-4) / 2) 0;
    outline: 1px dashed var(--line-strong);
    outline-offset: var(--space-2);
  }

  /* Narrow: one column, reading order, each widget as tall as it was. */
  .stack {
    display: grid;
    gap: var(--space-4);
  }
  .stack > .widget {
    block-size: calc(var(--h) * var(--row-h) + (var(--h) - 1) * var(--space-4));
    min-block-size: calc(var(--row-h) * 2);
  }

  .empty {
    display: grid;
    block-size: 100%;
    place-items: center;
    padding: var(--space-6);
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-3);
    color: var(--ink-3);
    font-size: var(--text-13);
    text-align: center;
  }

  /* ── A widget ── */
  .widget {
    position: relative;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    min-inline-size: 0;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-panel);
    overflow: hidden;
  }
  .widget[data-active] {
    z-index: var(--z-raised);
    border-color: var(--accent);
    box-shadow:
      0 0 0 1px var(--accent),
      0 10px 24px var(--alpha-b-13);
  }
  .head {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: 40px;
    padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  }
  .dashboard[data-editing] .head {
    padding-inline-start: var(--space-2);
    cursor: grab;
    touch-action: none;
    user-select: none;
  }
  .widget[data-active] .head {
    cursor: grabbing;
  }
  .handle {
    display: grid;
    place-items: center;
    inline-size: 24px;
    block-size: 24px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-1);
    background: none;
    color: var(--ink-3);
    cursor: inherit;
  }
  .handle:hover {
    background: var(--surface-hover);
    color: var(--ink);
  }
  .handle:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
  }
  .handle svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .title {
    overflow: hidden;
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .remove {
    margin-inline-start: auto;
    cursor: pointer;
  }
  .remove svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .body {
    min-block-size: 0;
    padding: 0 var(--space-4) var(--space-4);
    overflow: auto;
  }
  /* The corner that resizes: a pointer target, drawn as two short lines. */
  .resize {
    position: absolute;
    right: 0;
    bottom: 0;
    inline-size: 20px;
    block-size: 20px;
    cursor: nwse-resize;
    touch-action: none;
  }
  .resize::after {
    content: "";
    position: absolute;
    right: 5px;
    bottom: 5px;
    inline-size: 8px;
    block-size: 8px;
    border-right: 2px solid var(--ink-4);
    border-bottom: 2px solid var(--ink-4);
    border-bottom-right-radius: 2px;
  }
  .resize:hover::after,
  .widget[data-active] .resize::after {
    border-color: var(--accent);
  }

  .hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

components/workspaces/_kernel/grid.ts

/* A dashboard layout: rectangles on a grid of columns and rows. Plain data
   and pure functions, framework-free and identical in both apps; the grid
   renderer only draws it, turns pointer travel into cells, and calls these.
   Every change returns a new layout, so history can keep the old one.

   Placement is free — gaps stay where a person leaves them — and a widget
   moved or grown onto others pushes them down, never over or aside. Tidy
   (compact) closes the gaps on request. */

export type GridItem = Readonly<{
  id: string;
  x: number;
  y: number;
  w: number;
  h: number;
}>;

export type Layout = readonly GridItem[];

/** How small or large one widget may be, in cells. */
export type SizeLimits = Readonly<{
  minW?: number;
  minH?: number;
  maxW?: number;
  maxH?: number;
}>;

export const DEFAULT_COLUMNS = 12;

export const overlaps = (a: GridItem, b: GridItem) =>
  a.id !== b.id &&
  a.x < b.x + b.w &&
  a.x + a.w > b.x &&
  a.y < b.y + b.h &&
  a.y + a.h > b.y;

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

/** An item made to fit: whole cells, within its limits, inside the
 *  columns, never above the first row. */
export function fit(
  item: GridItem,
  columns: number,
  limits: SizeLimits = {},
): GridItem {
  const w = clamp(
    Math.round(item.w),
    Math.max(1, limits.minW ?? 1),
    Math.min(columns, limits.maxW ?? columns),
  );
  const h = clamp(
    Math.round(item.h),
    Math.max(1, limits.minH ?? 1),
    limits.maxH ?? Number.MAX_SAFE_INTEGER,
  );
  return {
    id: item.id,
    w,
    h,
    x: clamp(Math.round(item.x), 0, columns - w),
    y: Math.max(0, Math.round(item.y)),
  };
}

/** Puts `item` where it says and pushes whatever it lands on straight down,
 *  and whatever those land on, until nothing overlaps. */
export function place(layout: Layout, item: GridItem): Layout {
  const items = new Map<string, GridItem>(
    layout.map((entry) => [entry.id, entry.id === item.id ? item : entry]),
  );
  if (!items.has(item.id)) items.set(item.id, item);
  const push = (mover: GridItem) => {
    const hit = [...items.values()]
      .filter((other) => overlaps(mover, other))
      .sort((a, b) => a.y - b.y || a.x - b.x);
    for (const other of hit) {
      const current = items.get(other.id)!;
      // An earlier push may already have moved it clear.
      if (!overlaps(mover, current)) continue;
      const moved = { ...current, y: mover.y + mover.h };
      items.set(moved.id, moved);
      push(moved);
    }
  };
  push(item);
  // Keep the caller's order, so ids render in a stable order.
  const order = layout.some((entry) => entry.id === item.id)
    ? layout.map((entry) => entry.id)
    : [...layout.map((entry) => entry.id), item.id];
  return order.map((id) => items.get(id)!);
}

const same = (a: Layout, b: Layout) =>
  a.length === b.length &&
  a.every(
    (item, index) =>
      item.id === b[index].id &&
      item.x === b[index].x &&
      item.y === b[index].y &&
      item.w === b[index].w &&
      item.h === b[index].h,
  );

/** Moves or resizes one item to `to` (fitted), pushing others down. The
 *  same layout back when nothing changed. */
export function change(
  layout: Layout,
  id: string,
  to: Partial<Omit<GridItem, "id">>,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const current = layout.find((item) => item.id === id);
  if (!current) return layout;
  const next = place(layout, fit({ ...current, ...to }, columns, limits));
  return same(next, layout) ? layout : next;
}

export type Nudge = "left" | "right" | "up" | "down";

/** The keyboard's move: one cell in a direction, or with `resize`, one cell
 *  wider, narrower, taller or shorter. */
export function nudge(
  layout: Layout,
  id: string,
  direction: Nudge,
  resize = false,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const item = layout.find((entry) => entry.id === id);
  if (!item) return layout;
  const d = direction === "left" || direction === "up" ? -1 : 1;
  const horizontal = direction === "left" || direction === "right";
  const to = resize
    ? horizontal
      ? { w: item.w + d }
      : { h: item.h + d }
    : horizontal
      ? { x: item.x + d }
      : { y: item.y + d };
  // Up into another widget: jump above it rather than push it down (which
  // would leave this one where it was, relative to it).
  if (!resize && direction === "up") {
    const above = layout
      .filter(
        (other) =>
          other.id !== id &&
          other.x < item.x + item.w &&
          other.x + other.w > item.x &&
          other.y + other.h === item.y,
      )
      .sort((a, b) => a.y - b.y)[0];
    if (above) return change(layout, id, { y: above.y }, columns, limits);
  }
  // Down onto another widget: the mirror, that one jumps above this one.
  if (!resize && direction === "down") {
    const below = layout
      .filter(
        (other) =>
          other.id !== id &&
          other.x < item.x + item.w &&
          other.x + other.w > item.x &&
          other.y === item.y + item.h,
      )
      .sort((a, b) => a.x - b.x)[0];
    if (below) return change(layout, below.id, { y: item.y }, columns);
  }
  return change(layout, id, to, columns, limits);
}

/** Where a pointer gesture puts an item: its starting rectangle plus the
 *  cells travelled, moved (`move`) or sized from its far corner
 *  (`resize`), applied to the layout as it was when the gesture began. */
export function gesture(
  baseline: Layout,
  id: string,
  mode: "move" | "resize",
  cells: Readonly<{ dx: number; dy: number }>,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const start = baseline.find((item) => item.id === id);
  if (!start) return baseline;
  return change(
    baseline,
    id,
    mode === "move"
      ? { x: start.x + cells.dx, y: start.y + cells.dy }
      : { w: start.w + cells.dx, h: start.h + cells.dy },
    columns,
    limits,
  );
}

/** Pointer travel in pixels as whole cells. `pitch` is a cell plus its
 *  gap; half a cell is enough to snap to the next. */
export const toCells = (
  dx: number,
  dy: number,
  pitch: Readonly<{ x: number; y: number }>,
) => ({
  dx: pitch.x > 0 ? Math.round(dx / pitch.x) : 0,
  dy: pitch.y > 0 ? Math.round(dy / pitch.y) : 0,
});

/** The first place, reading top to bottom and left to right, where a
 *  `w` × `h` item fits without overlapping anything. */
export function firstFit(
  layout: Layout,
  w: number,
  h: number,
  columns = DEFAULT_COLUMNS,
): Readonly<{ x: number; y: number }> {
  const width = Math.min(w, columns);
  const bottom = layout.reduce(
    (max, item) => Math.max(max, item.y + item.h),
    0,
  );
  for (let y = 0; y <= bottom; y += 1)
    for (let x = 0; x + width <= columns; x += 1) {
      const probe = { id: "\u0000", x, y, w: width, h };
      if (!layout.some((item) => overlaps(probe, item))) return { x, y };
    }
  return { x: 0, y: bottom };
}

export function add(
  layout: Layout,
  id: string,
  size: Readonly<{ w: number; h: number }>,
  columns = DEFAULT_COLUMNS,
): Layout {
  if (layout.some((item) => item.id === id)) return layout;
  const at = firstFit(layout, size.w, size.h, columns);
  return [...layout, { id, ...at, w: Math.min(size.w, columns), h: size.h }];
}

export const remove = (layout: Layout, id: string): Layout =>
  layout.filter((item) => item.id !== id);

/** Closes vertical gaps: each item, top to bottom, rises until it meets
 *  another or the first row. Columns never change. */
export function compact(layout: Layout): Layout {
  const settled: GridItem[] = [];
  const byId = new Map<string, GridItem>();
  for (const item of readingOrder(layout)) {
    let next = { ...item, y: 0 };
    while (settled.some((other) => overlaps(next, other)))
      next = { ...next, y: next.y + 1 };
    // Never lower than it was.
    if (next.y > item.y) next = item;
    settled.push(next);
    byId.set(next.id, next);
  }
  const result = layout.map((item) => byId.get(item.id)!);
  return same(result, layout) ? layout : result;
}

/** Top to bottom, then left to right: the order a narrow screen stacks
 *  widgets in, and the order they are read in. */
export const readingOrder = (layout: Layout): GridItem[] =>
  [...layout].sort((a, b) => a.y - b.y || a.x - b.x);

/** Rows the layout spans. */
export const rows = (layout: Layout) =>
  layout.reduce((max, item) => Math.max(max, item.y + item.h), 0);

/** Where an item is, as a person hears it. */
export const describePlace = (item: GridItem) =>
  `column ${item.x + 1}, row ${item.y + 1}, ${item.w} wide and ${item.h} tall`;

// ── Pixels, for a gesture that follows the pointer ───────────────────────

/** A rectangle in pixels, from the grid's top left corner. */
export type Rect = Readonly<{
  left: number;
  top: number;
  width: number;
  height: number;
}>;

/** One column and one row, each with the gap after it; and the gap. */
export type Pitch = Readonly<{ x: number; y: number; gap: number }>;

/** Where an item's cells are, in pixels. */
export const cellRect = (item: GridItem, pitch: Pitch): Rect => ({
  left: item.x * pitch.x,
  top: item.y * pitch.y,
  width: item.w * pitch.x - pitch.gap,
  height: item.h * pitch.y - pitch.gap,
});

/** A pointer gesture as it is drawn and as it lands. The widget follows
 *  the pointer freely (`rect`), clamped: a move stays inside the columns
 *  and below the top; a resize stays within the widget's limits and the
 *  right edge. `cells` is how far that is in whole cells, which is where it
 *  lands (see gesture). */
export function drift(
  item: GridItem,
  mode: "move" | "resize",
  dx: number,
  dy: number,
  pitch: Pitch,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): { rect: Rect; cells: { dx: number; dy: number } } {
  const start = cellRect(item, pitch);
  const inner = columns * pitch.x - pitch.gap;
  if (mode === "move") {
    const left = clamp(start.left + dx, 0, inner - start.width);
    const top = Math.max(0, start.top + dy);
    return {
      rect: { ...start, left, top },
      cells: toCells(left - start.left, top - start.top, pitch),
    };
  }
  const span = (cells: number, step: number) => cells * step - pitch.gap;
  const minWidth = span(Math.max(1, limits.minW ?? 1), pitch.x);
  const maxWidth = Math.min(
    span(Math.min(columns, limits.maxW ?? columns), pitch.x),
    inner - start.left,
  );
  const minHeight = span(Math.max(1, limits.minH ?? 1), pitch.y);
  const maxHeight =
    limits.maxH === undefined
      ? Number.POSITIVE_INFINITY
      : span(limits.maxH, pitch.y);
  const width = clamp(start.width + dx, minWidth, Math.max(minWidth, maxWidth));
  const height = clamp(start.height + dy, minHeight, maxHeight);
  return {
    rect: { ...start, width, height },
    cells: toCells(width - start.width, height - start.height, pitch),
  };
}

Revenue

MRR$48.2k+6.2% this month

Active workspaces

This week1,284+38 since Monday

Error rate

Last 24 hours2.3%Down from 4.2%

Arrow keys move the widget one cell; Shift and the arrow keys resize it.

Sourcecomponents/workspaces/dashboard-grid/doc.ts · components/workspaces/dashboard-grid/dashboard-grid.tsx · components/workspaces/dashboard-grid/dashboard-grid.module.css · components/workspaces/_kernel/grid.ts

components/workspaces/dashboard-grid/doc.ts

/**
 * DashboardGrid — widgets on a grid that a person arranges for themselves.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the dashboard
 *     widgets        REQUIRED   { id, title, description?, size? {w,h},
 *                               limits? {minW,minH,maxW,maxH}, content }[]
 *                               — everything it can show
 *     defaultValue?  { id, x, y, w, h }[] in cells: what it shows, where
 *     onChange?      every committed change, to persist
 *     readOnly?      show only; no Customize
 *     columns?       12        rowHeight?  72 (px)
 *     stackBelow?    640 (px)  empty?      what an empty dashboard says
 *
 * # Behaviour
 *
 * R1  Viewing shows widgets where the layout puts them. Customize turns on
 *     arranging: the cells show, each widget gets a handle, a remove button
 *     and a resize corner, and the toolbar gets Add widget, Tidy, undo, redo
 *     and Done.
 * R2  A widget is moved by dragging its header and resized by dragging its
 *     corner. It follows the pointer exactly, clamped — a move stays inside
 *     the columns and below the top, a resize within the widget's limits
 *     and the right edge — while a placeholder shows the cells it will land
 *     in (the nearest, at the half-cell) and the others make room. On
 *     release it glides into those cells; Escape glides it back.
 * R2a Every change of place — a drag's neighbours, a drop, a keyboard step,
 *     Tidy, an added widget — glides rather than jumps, and a glide
 *     interrupted turns smoothly toward its new end. With reduced motion,
 *     changes are immediate.
 * R3  Placement is free: gaps stay where a person leaves them. A widget
 *     moved or grown onto others pushes them straight down, never over or
 *     aside, so widgets never overlap. Sizes stay within the widget's
 *     limits and the columns.
 * R4  Every gesture has a keyboard way. A widget's handle is one tab stop:
 *     the arrows move it a cell (up or down into a neighbour swaps them),
 *     Shift and the arrows resize it; each step is announced as column, row
 *     and size, and focus stays on the handle as widgets reorder.
 * R5  Add widget lists the widgets not shown, with what each shows, and
 *     places the chosen one in the first free place that fits it. Removing
 *     a widget returns it to that list.
 * R6  Tidy closes vertical gaps: each widget rises as far as it can; none
 *     changes column. It is disabled when there is nothing to close.
 * R7  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change; a run
 *     of arrow presses on one widget is one step. onChange receives
 *     committed changes only: not a drag in progress, not the start.
 * R8  Widgets render in reading order (top to bottom, then left to right),
 *     so the tab order and a screen reader follow what is seen.
 * R9  Below `stackBelow` the widgets stack in reading order at their own
 *     heights and cannot be arranged (they can still be added and removed);
 *     the saved layout is untouched.
 * R10 A saved layout naming a widget that no longer exists drops it.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The layout maths — fitting, pushing down, nudging and swapping, turning
 * pointer travel into cells, first fit, compaction, reading order — is the
 * framework-free kernel in ../_kernel/grid.ts (identical in both apps,
 * tested there), with history from ../_kernel/history.ts. No drag engine:
 * widgets are placed absolutely from the layout with calc() over the
 * column pitch, so a change of place is a CSS transition (interruptible by
 * nature). A gesture is pointer capture on the header or corner; the
 * kernel's drift() turns pointer travel into the clamped floating rectangle
 * and the cells it lands on, applied to the layout as it was when the
 * gesture began. During a drag the document order is held still, since
 * moving the dragged element would release its pointer capture. Both apps therefore behave
 * identically. The same component, with a different kernel rule, differs
 * from flover's (which refuses occupied destinations): refusing makes a
 * full dashboard impossible to rearrange.
 */
export {};

components/workspaces/dashboard-grid/dashboard-grid.tsx

"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

import { Button } from "@/components/forms/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/overlays/dropdown-menu";
import {
  GripVertical,
  LayoutGrid,
  Plus,
  Redo2,
  Undo2,
  WandSparkles,
  X,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  add,
  compact,
  DEFAULT_COLUMNS,
  describePlace,
  drift,
  gesture,
  nudge,
  readingOrder,
  remove,
  rows,
  type Layout,
  type Nudge,
  type Pitch,
  type Rect,
  type SizeLimits,
} from "../_kernel/grid";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import styles from "./dashboard-grid.module.css";

export type DashboardWidget = {
  id: string;
  title: string;
  /** What it shows, for the Add widget menu. */
  description?: string;
  /** Its size when added, in cells. */
  size?: Readonly<{ w: number; h: number }>;
  limits?: SizeLimits;
  content: ReactNode;
};

export type DashboardGridProps = {
  /** Names the dashboard for assistive technology. */
  label: string;
  /** Every widget the dashboard can show; the layout says which it does. */
  widgets: readonly DashboardWidget[];
  /** Where arranging starts. The grid keeps its own history from here. */
  defaultValue?: Layout;
  /** Every committed change: a drop, a resize, an added or removed
   *  widget. Persist here. */
  onChange?: (layout: Layout) => void;
  /** Show only; no Customize. */
  readOnly?: boolean;
  columns?: number;
  /** One row's height, in pixels. */
  rowHeight?: number;
  /** Below this width, in pixels, widgets stack in reading order and
   *  cannot be arranged. */
  stackBelow?: number;
  /** Shown when no widget is placed. */
  empty?: ReactNode;
  className?: string;
};

const NUDGES: Readonly<Record<string, Nudge>> = {
  ArrowLeft: "left",
  ArrowRight: "right",
  ArrowUp: "up",
  ArrowDown: "down",
};
const DEFAULT_SIZE = { w: 4, h: 3 };

type Gesture = {
  id: string;
  mode: "move" | "resize";
  pointer: number;
  from: { x: number; y: number };
  pitch: Pitch;
  /** Where the widget is drawn: following the pointer, clamped. */
  rect: Rect;
  baseline: Layout;
  /** Where it would land, and where the others make room. */
  preview: Layout;
};

/** Widgets on a grid that a person arranges: moved by their header,
 *  resized from their corner, added and removed; see doc.ts. */
export function DashboardGrid({
  label,
  widgets,
  defaultValue = [],
  onChange,
  readOnly = false,
  columns = DEFAULT_COLUMNS,
  rowHeight = 72,
  stackBelow = 640,
  empty = "No widgets yet. Customize the dashboard to add one.",
  className,
}: DashboardGridProps) {
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const grid = useRef<HTMLDivElement>(null);
  const byId = new Map(widgets.map((widget) => [widget.id, widget]));

  const [history, setHistory] = useState<History<Layout>>(() =>
    // A saved layout may name widgets this build no longer has.
    start(defaultValue.filter((item) => byId.has(item.id))),
  );
  const change = (next: Layout, key: string | null = null) =>
    setHistory((current) => commit(current, next, key));
  const reported = useRef(history.present);
  useEffect(() => {
    if (reported.current === history.present) return;
    reported.current = history.present;
    onChange?.(history.present);
  }, [history.present, onChange]);

  const [editing, setEditing] = useState(false);
  const [drag, setDrag] = useState<Gesture | null>(null);
  const [message, setMessage] = useState("");
  const [width, setWidth] = useState<number | null>(null);
  // The handle a keyboard move came from: widgets render in reading order,
  // so a move can reorder them, and focus must follow.
  const refocus = useRef<string | null>(null);

  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    const widgetId = refocus.current;
    if (!widgetId) return;
    refocus.current = null;
    root.current
      ?.querySelector<HTMLElement>(`[data-handle="${CSS.escape(widgetId)}"]`)
      ?.focus();
  });

  // A drag keeps focus where it was (often nowhere), so Escape is heard
  // on the window while one is in progress.
  const dragging = drag !== null;
  useEffect(() => {
    if (!dragging) return;
    const cancel = (event: globalThis.KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setDrag(null);
      setMessage("Cancelled.");
    };
    window.addEventListener("keydown", cancel);
    return () => window.removeEventListener("keydown", cancel);
  }, [dragging]);

  const stacked = width !== null && width < stackBelow;
  const arranging = editing && !readOnly && !stacked;
  const layout = drag?.preview ?? history.present;
  const placed = new Set(layout.map((item) => item.id));
  const available = widgets.filter((widget) => !placed.has(widget.id));
  const titleOf = (widgetId: string) => byId.get(widgetId)?.title ?? widgetId;

  function announce(next: Layout, widgetId: string, verb: string) {
    const item = next.find((entry) => entry.id === widgetId);
    if (item)
      setMessage(`${verb} ${titleOf(widgetId)}: ${describePlace(item)}.`);
  }

  // ── Pointer: move by the header, resize by the corner ──
  function begin(
    event: PointerEvent<HTMLElement>,
    widgetId: string,
    mode: Gesture["mode"],
  ) {
    if (!arranging || event.button !== 0 || !grid.current) return;
    // Buttons in the header keep their own clicks.
    if (
      mode === "move" &&
      (event.target as HTMLElement).closest("button:not([data-handle])")
    )
      return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    const item = history.present.find((entry) => entry.id === widgetId);
    if (!item) return;
    const gap = parseFloat(getComputedStyle(grid.current).rowGap) || 0;
    const pitch = {
      x: (grid.current.clientWidth + gap) / columns,
      y: rowHeight + gap,
      gap,
    };
    setDrag({
      id: widgetId,
      mode,
      pointer: event.pointerId,
      from: { x: event.clientX, y: event.clientY },
      pitch,
      rect: drift(item, mode, 0, 0, pitch, columns).rect,
      baseline: history.present,
      preview: history.present,
    });
  }
  function move(event: PointerEvent<HTMLElement>) {
    if (!drag || event.pointerId !== drag.pointer) return;
    const item = drag.baseline.find((entry) => entry.id === drag.id);
    if (!item) return;
    const limits = byId.get(drag.id)?.limits;
    const { rect, cells } = drift(
      item,
      drag.mode,
      event.clientX - drag.from.x,
      event.clientY - drag.from.y,
      drag.pitch,
      columns,
      limits,
    );
    const preview = gesture(
      drag.baseline,
      drag.id,
      drag.mode,
      cells,
      columns,
      limits,
    );
    setDrag({ ...drag, rect, preview });
  }
  function finish(event: PointerEvent<HTMLElement>) {
    if (!drag || event.pointerId !== drag.pointer) return;
    setDrag(null);
    if (drag.preview !== drag.baseline) {
      change(drag.preview);
      announce(
        drag.preview,
        drag.id,
        drag.mode === "move" ? "Moved" : "Resized",
      );
    }
  }

  // ── Keyboard: arrows move, Shift+arrows resize ──
  function onHandleKey(
    event: KeyboardEvent<HTMLButtonElement>,
    widgetId: string,
  ) {
    const direction = NUDGES[event.key];
    if (!direction) return;
    event.preventDefault();
    const resize = event.shiftKey;
    const current = history.present;
    const next = nudge(
      current,
      widgetId,
      direction,
      resize,
      columns,
      byId.get(widgetId)?.limits,
    );
    if (next === current) {
      setMessage(
        `${titleOf(widgetId)} cannot ${resize ? "change size" : "move"} that way.`,
      );
      return;
    }
    change(next, `${resize ? "size" : "move"}:${widgetId}`);
    announce(next, widgetId, resize ? "Resized" : "Moved");
    refocus.current = widgetId;
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (!arranging) return;
    const target = event.target as HTMLElement;
    if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      step(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      step("redo");
    }
  }

  function step(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  // Reading order, but held still during a drag: moving the dragged
  // element in the document would drop its pointer capture.
  const ordered = drag
    ? readingOrder(drag.baseline).map(
        (item) => layout.find((entry) => entry.id === item.id) ?? item,
      )
    : readingOrder(layout);
  const gridStyle = {
    "--columns": columns,
    "--row-h": `${rowHeight}px`,
    // A spare row while arranging, to drop into below the rest.
    "--rows": Math.max(
      rows(layout) + (arranging ? 1 : 0),
      drag ? Math.ceil((drag.rect.top + drag.rect.height) / drag.pitch.y) : 0,
      1,
    ),
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={cn(styles.dashboard, className)}
      data-editing={arranging || undefined}
      onKeyDown={onKeyDown}
    >
      {readOnly ? null : (
        <div
          className={styles.toolbar}
          role="toolbar"
          aria-label={`${label} tools`}
        >
          {editing ? (
            <>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    size="sm"
                    variant="secondary"
                    disabled={available.length === 0}
                  >
                    <Plus aria-hidden="true" />
                    Add widget
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="start" className={styles.menu}>
                  <DropdownMenuLabel>Widgets</DropdownMenuLabel>
                  {available.map((widget) => (
                    <DropdownMenuItem
                      key={widget.id}
                      className={styles.menuItem}
                      onSelect={() => {
                        const next = add(
                          history.present,
                          widget.id,
                          widget.size ?? DEFAULT_SIZE,
                          columns,
                        );
                        change(next);
                        announce(next, widget.id, "Added");
                      }}
                    >
                      <span className={styles.menuTitle}>{widget.title}</span>
                      {widget.description ? (
                        <span className={styles.menuNote}>
                          {widget.description}
                        </span>
                      ) : null}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
              {stacked ? null : (
                <>
                  <Button
                    size="sm"
                    variant="quiet"
                    disabled={compact(history.present) === history.present}
                    onClick={() => {
                      change(compact(history.present));
                      setMessage("Closed the gaps.");
                    }}
                  >
                    <WandSparkles aria-hidden="true" />
                    Tidy
                  </Button>
                  <Button
                    size="icon"
                    variant="quiet"
                    aria-label="Undo"
                    title="Undo (⌘Z)"
                    disabled={!canUndo(history)}
                    onClick={() => step("undo")}
                  >
                    <Undo2 aria-hidden="true" />
                  </Button>
                  <Button
                    size="icon"
                    variant="quiet"
                    aria-label="Redo"
                    title="Redo (⇧⌘Z)"
                    disabled={!canRedo(history)}
                    onClick={() => step("redo")}
                  >
                    <Redo2 aria-hidden="true" />
                  </Button>
                </>
              )}
              <Button
                size="sm"
                variant="primary"
                className={styles.toolbarEnd}
                onClick={() => {
                  setEditing(false);
                  setMessage("Done customizing.");
                }}
              >
                Done
              </Button>
            </>
          ) : (
            <Button
              size="sm"
              variant="secondary"
              className={styles.toolbarEnd}
              onClick={() => {
                setEditing(true);
                setMessage(
                  stacked
                    ? "Customizing. Widgets can be added and removed here; widen the window to arrange them."
                    : "Customizing. Drag a widget by its header or its corner, or use its handle: arrow keys move it, Shift and the arrow keys resize it.",
                );
              }}
            >
              <LayoutGrid aria-hidden="true" />
              Customize
            </Button>
          )}
        </div>
      )}
      {editing && stacked ? (
        <p className={styles.note}>
          Widgets stack on narrow screens. Widen the window to move and resize
          them.
        </p>
      ) : null}

      <div
        ref={grid}
        role="region"
        aria-label={label}
        className={stacked ? styles.stack : styles.grid}
        style={gridStyle}
      >
        {ordered.length === 0 ? (
          <div className={styles.empty}>{empty}</div>
        ) : null}
        {drag
          ? (() => {
              const landing = layout.find((item) => item.id === drag.id);
              return landing ? (
                <div
                  className={styles.placeholder}
                  aria-hidden="true"
                  style={
                    {
                      "--x": landing.x,
                      "--y": landing.y,
                      "--w": landing.w,
                      "--h": landing.h,
                    } as CSSProperties
                  }
                />
              ) : null;
            })()
          : null}
        {ordered.map((item) => {
          const widget = byId.get(item.id);
          if (!widget) return null;
          const active = drag?.id === item.id;
          return (
            <section
              key={item.id}
              aria-labelledby={`${id}-${item.id}`}
              className={styles.widget}
              data-active={active || undefined}
              style={
                active
                  ? // Following the pointer; the placeholder shows where
                    // it lands, and it glides there on release.
                    {
                      left: drag.rect.left,
                      top: drag.rect.top,
                      width: drag.rect.width,
                      height: drag.rect.height,
                    }
                  : ({
                      "--x": item.x,
                      "--y": item.y,
                      "--w": item.w,
                      "--h": item.h,
                    } as CSSProperties)
              }
            >
              <header
                className={styles.head}
                onPointerDown={(event) => begin(event, item.id, "move")}
                onPointerMove={move}
                onPointerUp={finish}
                onPointerCancel={() => setDrag(null)}
              >
                {arranging ? (
                  <button
                    type="button"
                    className={styles.handle}
                    data-handle={item.id}
                    aria-label={`Arrange ${widget.title}`}
                    aria-describedby={`${id}-hint`}
                    onKeyDown={(event) => onHandleKey(event, item.id)}
                  >
                    <GripVertical aria-hidden="true" />
                  </button>
                ) : null}
                <h3 id={`${id}-${item.id}`} className={styles.title}>
                  {widget.title}
                </h3>
                {editing ? (
                  <Button
                    size="icon"
                    variant="quiet"
                    className={styles.remove}
                    aria-label={`Remove ${widget.title}`}
                    onClick={() => {
                      change(remove(history.present, item.id));
                      setMessage(
                        `Removed ${widget.title}. Add widget brings it back.`,
                      );
                    }}
                  >
                    <X aria-hidden="true" />
                  </Button>
                ) : null}
              </header>
              <div className={styles.body}>{widget.content}</div>
              {arranging ? (
                <span
                  className={styles.resize}
                  aria-hidden="true"
                  onPointerDown={(event) => begin(event, item.id, "resize")}
                  onPointerMove={move}
                  onPointerUp={finish}
                  onPointerCancel={() => setDrag(null)}
                />
              ) : null}
            </section>
          );
        })}
      </div>

      <p id={`${id}-hint`} className={styles.hidden}>
        Arrow keys move the widget one cell; Shift and the arrow keys resize it.
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

components/workspaces/dashboard-grid/dashboard-grid.module.css

@layer composition {
  .dashboard {
    display: grid;
    gap: var(--space-4);
    min-inline-size: 0;
  }

  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
  }
  .toolbar svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .toolbarEnd {
    margin-inline-start: auto;
  }
  .note {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .menu {
    max-inline-size: 300px;
  }
  .menuItem {
    display: grid;
    gap: 1px;
    block-size: auto;
    padding-block: var(--space-2);
  }
  .menuTitle {
    font-weight: var(--weight-medium);
  }
  .menuNote {
    color: var(--ink-3);
    font-size: var(--text-12);
    white-space: normal;
  }

  /* ── The grid ── */
  /* Widgets are placed absolutely from the layout, so a change of place is
     a transition: widgets glide out of the way and into their cells, and a
     glide interrupted mid-way turns smoothly toward its new end. */
  .grid {
    --gap: var(--space-4);
    position: relative;
    gap: var(--gap);
    block-size: calc(var(--rows) * (var(--row-h) + var(--gap)) - var(--gap));
    min-block-size: calc(var(--row-h) * 2);
  }
  .grid > .widget,
  .placeholder {
    position: absolute;
    left: calc(var(--x) * (100% + var(--gap)) / var(--columns));
    top: calc(var(--y) * (var(--row-h) + var(--gap)));
    width: calc(var(--w) * (100% + var(--gap)) / var(--columns) - var(--gap));
    height: calc(var(--h) * (var(--row-h) + var(--gap)) - var(--gap));
    transition:
      left var(--dur-2) var(--ease),
      top var(--dur-2) var(--ease),
      width var(--dur-2) var(--ease),
      height var(--dur-2) var(--ease);
  }
  /* The widget under the pointer follows it exactly. */
  .grid > .widget[data-active] {
    transition: none;
  }
  /* Where the dragged widget will land. */
  .placeholder {
    border: 2px dashed var(--accent);
    border-radius: var(--radius-3);
    background: var(--accent-tint);
  }
  @media (prefers-reduced-motion: reduce) {
    .grid > .widget,
    .placeholder {
      transition: none;
    }
  }
  /* Arranging shows the cells, so a drop has something to snap to. */
  .dashboard[data-editing] .grid {
    border-radius: var(--radius-3);
    background-image: linear-gradient(
      to right,
      var(--line) 1px,
      transparent 1px
    );
    background-size: calc((100% + var(--space-4)) / var(--columns)) 100%;
    background-position: calc(-1 * var(--space-4) / 2) 0;
    outline: 1px dashed var(--line-strong);
    outline-offset: var(--space-2);
  }

  /* Narrow: one column, reading order, each widget as tall as it was. */
  .stack {
    display: grid;
    gap: var(--space-4);
  }
  .stack > .widget {
    block-size: calc(var(--h) * var(--row-h) + (var(--h) - 1) * var(--space-4));
    min-block-size: calc(var(--row-h) * 2);
  }

  .empty {
    display: grid;
    block-size: 100%;
    place-items: center;
    padding: var(--space-6);
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-3);
    color: var(--ink-3);
    font-size: var(--text-13);
    text-align: center;
  }

  /* ── A widget ── */
  .widget {
    position: relative;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    min-inline-size: 0;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-panel);
    overflow: hidden;
  }
  .widget[data-active] {
    z-index: var(--z-raised);
    border-color: var(--accent);
    box-shadow:
      0 0 0 1px var(--accent),
      0 10px 24px var(--alpha-b-13);
  }
  .head {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: 40px;
    padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  }
  .dashboard[data-editing] .head {
    padding-inline-start: var(--space-2);
    cursor: grab;
    touch-action: none;
    user-select: none;
  }
  .widget[data-active] .head {
    cursor: grabbing;
  }
  .handle {
    display: grid;
    place-items: center;
    inline-size: 24px;
    block-size: 24px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-1);
    background: none;
    color: var(--ink-3);
    cursor: inherit;
  }
  .handle:hover {
    background: var(--surface-hover);
    color: var(--ink);
  }
  .handle:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
  }
  .handle svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .title {
    overflow: hidden;
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .remove {
    margin-inline-start: auto;
    cursor: pointer;
  }
  .remove svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .body {
    min-block-size: 0;
    padding: 0 var(--space-4) var(--space-4);
    overflow: auto;
  }
  /* The corner that resizes: a pointer target, drawn as two short lines. */
  .resize {
    position: absolute;
    right: 0;
    bottom: 0;
    inline-size: 20px;
    block-size: 20px;
    cursor: nwse-resize;
    touch-action: none;
  }
  .resize::after {
    content: "";
    position: absolute;
    right: 5px;
    bottom: 5px;
    inline-size: 8px;
    block-size: 8px;
    border-right: 2px solid var(--ink-4);
    border-bottom: 2px solid var(--ink-4);
    border-bottom-right-radius: 2px;
  }
  .resize:hover::after,
  .widget[data-active] .resize::after {
    border-color: var(--accent);
  }

  .hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

components/workspaces/_kernel/grid.ts

/* A dashboard layout: rectangles on a grid of columns and rows. Plain data
   and pure functions, framework-free and identical in both apps; the grid
   renderer only draws it, turns pointer travel into cells, and calls these.
   Every change returns a new layout, so history can keep the old one.

   Placement is free — gaps stay where a person leaves them — and a widget
   moved or grown onto others pushes them down, never over or aside. Tidy
   (compact) closes the gaps on request. */

export type GridItem = Readonly<{
  id: string;
  x: number;
  y: number;
  w: number;
  h: number;
}>;

export type Layout = readonly GridItem[];

/** How small or large one widget may be, in cells. */
export type SizeLimits = Readonly<{
  minW?: number;
  minH?: number;
  maxW?: number;
  maxH?: number;
}>;

export const DEFAULT_COLUMNS = 12;

export const overlaps = (a: GridItem, b: GridItem) =>
  a.id !== b.id &&
  a.x < b.x + b.w &&
  a.x + a.w > b.x &&
  a.y < b.y + b.h &&
  a.y + a.h > b.y;

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

/** An item made to fit: whole cells, within its limits, inside the
 *  columns, never above the first row. */
export function fit(
  item: GridItem,
  columns: number,
  limits: SizeLimits = {},
): GridItem {
  const w = clamp(
    Math.round(item.w),
    Math.max(1, limits.minW ?? 1),
    Math.min(columns, limits.maxW ?? columns),
  );
  const h = clamp(
    Math.round(item.h),
    Math.max(1, limits.minH ?? 1),
    limits.maxH ?? Number.MAX_SAFE_INTEGER,
  );
  return {
    id: item.id,
    w,
    h,
    x: clamp(Math.round(item.x), 0, columns - w),
    y: Math.max(0, Math.round(item.y)),
  };
}

/** Puts `item` where it says and pushes whatever it lands on straight down,
 *  and whatever those land on, until nothing overlaps. */
export function place(layout: Layout, item: GridItem): Layout {
  const items = new Map<string, GridItem>(
    layout.map((entry) => [entry.id, entry.id === item.id ? item : entry]),
  );
  if (!items.has(item.id)) items.set(item.id, item);
  const push = (mover: GridItem) => {
    const hit = [...items.values()]
      .filter((other) => overlaps(mover, other))
      .sort((a, b) => a.y - b.y || a.x - b.x);
    for (const other of hit) {
      const current = items.get(other.id)!;
      // An earlier push may already have moved it clear.
      if (!overlaps(mover, current)) continue;
      const moved = { ...current, y: mover.y + mover.h };
      items.set(moved.id, moved);
      push(moved);
    }
  };
  push(item);
  // Keep the caller's order, so ids render in a stable order.
  const order = layout.some((entry) => entry.id === item.id)
    ? layout.map((entry) => entry.id)
    : [...layout.map((entry) => entry.id), item.id];
  return order.map((id) => items.get(id)!);
}

const same = (a: Layout, b: Layout) =>
  a.length === b.length &&
  a.every(
    (item, index) =>
      item.id === b[index].id &&
      item.x === b[index].x &&
      item.y === b[index].y &&
      item.w === b[index].w &&
      item.h === b[index].h,
  );

/** Moves or resizes one item to `to` (fitted), pushing others down. The
 *  same layout back when nothing changed. */
export function change(
  layout: Layout,
  id: string,
  to: Partial<Omit<GridItem, "id">>,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const current = layout.find((item) => item.id === id);
  if (!current) return layout;
  const next = place(layout, fit({ ...current, ...to }, columns, limits));
  return same(next, layout) ? layout : next;
}

export type Nudge = "left" | "right" | "up" | "down";

/** The keyboard's move: one cell in a direction, or with `resize`, one cell
 *  wider, narrower, taller or shorter. */
export function nudge(
  layout: Layout,
  id: string,
  direction: Nudge,
  resize = false,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const item = layout.find((entry) => entry.id === id);
  if (!item) return layout;
  const d = direction === "left" || direction === "up" ? -1 : 1;
  const horizontal = direction === "left" || direction === "right";
  const to = resize
    ? horizontal
      ? { w: item.w + d }
      : { h: item.h + d }
    : horizontal
      ? { x: item.x + d }
      : { y: item.y + d };
  // Up into another widget: jump above it rather than push it down (which
  // would leave this one where it was, relative to it).
  if (!resize && direction === "up") {
    const above = layout
      .filter(
        (other) =>
          other.id !== id &&
          other.x < item.x + item.w &&
          other.x + other.w > item.x &&
          other.y + other.h === item.y,
      )
      .sort((a, b) => a.y - b.y)[0];
    if (above) return change(layout, id, { y: above.y }, columns, limits);
  }
  // Down onto another widget: the mirror, that one jumps above this one.
  if (!resize && direction === "down") {
    const below = layout
      .filter(
        (other) =>
          other.id !== id &&
          other.x < item.x + item.w &&
          other.x + other.w > item.x &&
          other.y === item.y + item.h,
      )
      .sort((a, b) => a.x - b.x)[0];
    if (below) return change(layout, below.id, { y: item.y }, columns);
  }
  return change(layout, id, to, columns, limits);
}

/** Where a pointer gesture puts an item: its starting rectangle plus the
 *  cells travelled, moved (`move`) or sized from its far corner
 *  (`resize`), applied to the layout as it was when the gesture began. */
export function gesture(
  baseline: Layout,
  id: string,
  mode: "move" | "resize",
  cells: Readonly<{ dx: number; dy: number }>,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const start = baseline.find((item) => item.id === id);
  if (!start) return baseline;
  return change(
    baseline,
    id,
    mode === "move"
      ? { x: start.x + cells.dx, y: start.y + cells.dy }
      : { w: start.w + cells.dx, h: start.h + cells.dy },
    columns,
    limits,
  );
}

/** Pointer travel in pixels as whole cells. `pitch` is a cell plus its
 *  gap; half a cell is enough to snap to the next. */
export const toCells = (
  dx: number,
  dy: number,
  pitch: Readonly<{ x: number; y: number }>,
) => ({
  dx: pitch.x > 0 ? Math.round(dx / pitch.x) : 0,
  dy: pitch.y > 0 ? Math.round(dy / pitch.y) : 0,
});

/** The first place, reading top to bottom and left to right, where a
 *  `w` × `h` item fits without overlapping anything. */
export function firstFit(
  layout: Layout,
  w: number,
  h: number,
  columns = DEFAULT_COLUMNS,
): Readonly<{ x: number; y: number }> {
  const width = Math.min(w, columns);
  const bottom = layout.reduce(
    (max, item) => Math.max(max, item.y + item.h),
    0,
  );
  for (let y = 0; y <= bottom; y += 1)
    for (let x = 0; x + width <= columns; x += 1) {
      const probe = { id: "\u0000", x, y, w: width, h };
      if (!layout.some((item) => overlaps(probe, item))) return { x, y };
    }
  return { x: 0, y: bottom };
}

export function add(
  layout: Layout,
  id: string,
  size: Readonly<{ w: number; h: number }>,
  columns = DEFAULT_COLUMNS,
): Layout {
  if (layout.some((item) => item.id === id)) return layout;
  const at = firstFit(layout, size.w, size.h, columns);
  return [...layout, { id, ...at, w: Math.min(size.w, columns), h: size.h }];
}

export const remove = (layout: Layout, id: string): Layout =>
  layout.filter((item) => item.id !== id);

/** Closes vertical gaps: each item, top to bottom, rises until it meets
 *  another or the first row. Columns never change. */
export function compact(layout: Layout): Layout {
  const settled: GridItem[] = [];
  const byId = new Map<string, GridItem>();
  for (const item of readingOrder(layout)) {
    let next = { ...item, y: 0 };
    while (settled.some((other) => overlaps(next, other)))
      next = { ...next, y: next.y + 1 };
    // Never lower than it was.
    if (next.y > item.y) next = item;
    settled.push(next);
    byId.set(next.id, next);
  }
  const result = layout.map((item) => byId.get(item.id)!);
  return same(result, layout) ? layout : result;
}

/** Top to bottom, then left to right: the order a narrow screen stacks
 *  widgets in, and the order they are read in. */
export const readingOrder = (layout: Layout): GridItem[] =>
  [...layout].sort((a, b) => a.y - b.y || a.x - b.x);

/** Rows the layout spans. */
export const rows = (layout: Layout) =>
  layout.reduce((max, item) => Math.max(max, item.y + item.h), 0);

/** Where an item is, as a person hears it. */
export const describePlace = (item: GridItem) =>
  `column ${item.x + 1}, row ${item.y + 1}, ${item.w} wide and ${item.h} tall`;

// ── Pixels, for a gesture that follows the pointer ───────────────────────

/** A rectangle in pixels, from the grid's top left corner. */
export type Rect = Readonly<{
  left: number;
  top: number;
  width: number;
  height: number;
}>;

/** One column and one row, each with the gap after it; and the gap. */
export type Pitch = Readonly<{ x: number; y: number; gap: number }>;

/** Where an item's cells are, in pixels. */
export const cellRect = (item: GridItem, pitch: Pitch): Rect => ({
  left: item.x * pitch.x,
  top: item.y * pitch.y,
  width: item.w * pitch.x - pitch.gap,
  height: item.h * pitch.y - pitch.gap,
});

/** A pointer gesture as it is drawn and as it lands. The widget follows
 *  the pointer freely (`rect`), clamped: a move stays inside the columns
 *  and below the top; a resize stays within the widget's limits and the
 *  right edge. `cells` is how far that is in whole cells, which is where it
 *  lands (see gesture). */
export function drift(
  item: GridItem,
  mode: "move" | "resize",
  dx: number,
  dy: number,
  pitch: Pitch,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): { rect: Rect; cells: { dx: number; dy: number } } {
  const start = cellRect(item, pitch);
  const inner = columns * pitch.x - pitch.gap;
  if (mode === "move") {
    const left = clamp(start.left + dx, 0, inner - start.width);
    const top = Math.max(0, start.top + dy);
    return {
      rect: { ...start, left, top },
      cells: toCells(left - start.left, top - start.top, pitch),
    };
  }
  const span = (cells: number, step: number) => cells * step - pitch.gap;
  const minWidth = span(Math.max(1, limits.minW ?? 1), pitch.x);
  const maxWidth = Math.min(
    span(Math.min(columns, limits.maxW ?? columns), pitch.x),
    inner - start.left,
  );
  const minHeight = span(Math.max(1, limits.minH ?? 1), pitch.y);
  const maxHeight =
    limits.maxH === undefined
      ? Number.POSITIVE_INFINITY
      : span(limits.maxH, pitch.y);
  const width = clamp(start.width + dx, minWidth, Math.max(minWidth, maxWidth));
  const height = clamp(start.height + dy, minHeight, maxHeight);
  return {
    rect: { ...start, width, height },
    cells: toCells(width - start.width, height - start.height, pitch),
  };
}

Dashboard, empty

nothing placed yet

No widgets yet. Customize the dashboard to add one.

Arrow keys move the widget one cell; Shift and the arrow keys resize it.

Sourcecomponents/workspaces/dashboard-grid/doc.ts · components/workspaces/dashboard-grid/dashboard-grid.tsx · components/workspaces/dashboard-grid/dashboard-grid.module.css · components/workspaces/_kernel/grid.ts

components/workspaces/dashboard-grid/doc.ts

/**
 * DashboardGrid — widgets on a grid that a person arranges for themselves.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § CONTRACT — the oracle. Names no library, contains no code.              │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * # Shape
 *
 *     label          REQUIRED   names the dashboard
 *     widgets        REQUIRED   { id, title, description?, size? {w,h},
 *                               limits? {minW,minH,maxW,maxH}, content }[]
 *                               — everything it can show
 *     defaultValue?  { id, x, y, w, h }[] in cells: what it shows, where
 *     onChange?      every committed change, to persist
 *     readOnly?      show only; no Customize
 *     columns?       12        rowHeight?  72 (px)
 *     stackBelow?    640 (px)  empty?      what an empty dashboard says
 *
 * # Behaviour
 *
 * R1  Viewing shows widgets where the layout puts them. Customize turns on
 *     arranging: the cells show, each widget gets a handle, a remove button
 *     and a resize corner, and the toolbar gets Add widget, Tidy, undo, redo
 *     and Done.
 * R2  A widget is moved by dragging its header and resized by dragging its
 *     corner. It follows the pointer exactly, clamped — a move stays inside
 *     the columns and below the top, a resize within the widget's limits
 *     and the right edge — while a placeholder shows the cells it will land
 *     in (the nearest, at the half-cell) and the others make room. On
 *     release it glides into those cells; Escape glides it back.
 * R2a Every change of place — a drag's neighbours, a drop, a keyboard step,
 *     Tidy, an added widget — glides rather than jumps, and a glide
 *     interrupted turns smoothly toward its new end. With reduced motion,
 *     changes are immediate.
 * R3  Placement is free: gaps stay where a person leaves them. A widget
 *     moved or grown onto others pushes them straight down, never over or
 *     aside, so widgets never overlap. Sizes stay within the widget's
 *     limits and the columns.
 * R4  Every gesture has a keyboard way. A widget's handle is one tab stop:
 *     the arrows move it a cell (up or down into a neighbour swaps them),
 *     Shift and the arrows resize it; each step is announced as column, row
 *     and size, and focus stays on the handle as widgets reorder.
 * R5  Add widget lists the widgets not shown, with what each shows, and
 *     places the chosen one in the first free place that fits it. Removing
 *     a widget returns it to that list.
 * R6  Tidy closes vertical gaps: each widget rises as far as it can; none
 *     changes column. It is disabled when there is nothing to close.
 * R7  Undo (⌘Z / Ctrl+Z) and redo (⇧⌘Z, Ctrl+Y) cover every change; a run
 *     of arrow presses on one widget is one step. onChange receives
 *     committed changes only: not a drag in progress, not the start.
 * R8  Widgets render in reading order (top to bottom, then left to right),
 *     so the tab order and a screen reader follow what is seen.
 * R9  Below `stackBelow` the widgets stack in reading order at their own
 *     heights and cannot be arranged (they can still be added and removed);
 *     the saved layout is untouched.
 * R10 A saved layout naming a widget that no longer exists drops it.
 *
 * ┌───────────────────────────────────────────────────────────────────────────┐
 * │ § MECHANICS — how this implementation meets the contract.                 │
 * └───────────────────────────────────────────────────────────────────────────┘
 *
 * The layout maths — fitting, pushing down, nudging and swapping, turning
 * pointer travel into cells, first fit, compaction, reading order — is the
 * framework-free kernel in ../_kernel/grid.ts (identical in both apps,
 * tested there), with history from ../_kernel/history.ts. No drag engine:
 * widgets are placed absolutely from the layout with calc() over the
 * column pitch, so a change of place is a CSS transition (interruptible by
 * nature). A gesture is pointer capture on the header or corner; the
 * kernel's drift() turns pointer travel into the clamped floating rectangle
 * and the cells it lands on, applied to the layout as it was when the
 * gesture began. During a drag the document order is held still, since
 * moving the dragged element would release its pointer capture. Both apps therefore behave
 * identically. The same component, with a different kernel rule, differs
 * from flover's (which refuses occupied destinations): refusing makes a
 * full dashboard impossible to rearrange.
 */
export {};

components/workspaces/dashboard-grid/dashboard-grid.tsx

"use client";

import {
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type PointerEvent,
  type ReactNode,
} from "react";

import { Button } from "@/components/forms/button";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/overlays/dropdown-menu";
import {
  GripVertical,
  LayoutGrid,
  Plus,
  Redo2,
  Undo2,
  WandSparkles,
  X,
} from "@/components/utility/icon";
import { cn } from "@/lib/utils/cn";
import {
  add,
  compact,
  DEFAULT_COLUMNS,
  describePlace,
  drift,
  gesture,
  nudge,
  readingOrder,
  remove,
  rows,
  type Layout,
  type Nudge,
  type Pitch,
  type Rect,
  type SizeLimits,
} from "../_kernel/grid";
import {
  canRedo,
  canUndo,
  commit,
  redo,
  start,
  undo,
  type History,
} from "../_kernel/history";
import styles from "./dashboard-grid.module.css";

export type DashboardWidget = {
  id: string;
  title: string;
  /** What it shows, for the Add widget menu. */
  description?: string;
  /** Its size when added, in cells. */
  size?: Readonly<{ w: number; h: number }>;
  limits?: SizeLimits;
  content: ReactNode;
};

export type DashboardGridProps = {
  /** Names the dashboard for assistive technology. */
  label: string;
  /** Every widget the dashboard can show; the layout says which it does. */
  widgets: readonly DashboardWidget[];
  /** Where arranging starts. The grid keeps its own history from here. */
  defaultValue?: Layout;
  /** Every committed change: a drop, a resize, an added or removed
   *  widget. Persist here. */
  onChange?: (layout: Layout) => void;
  /** Show only; no Customize. */
  readOnly?: boolean;
  columns?: number;
  /** One row's height, in pixels. */
  rowHeight?: number;
  /** Below this width, in pixels, widgets stack in reading order and
   *  cannot be arranged. */
  stackBelow?: number;
  /** Shown when no widget is placed. */
  empty?: ReactNode;
  className?: string;
};

const NUDGES: Readonly<Record<string, Nudge>> = {
  ArrowLeft: "left",
  ArrowRight: "right",
  ArrowUp: "up",
  ArrowDown: "down",
};
const DEFAULT_SIZE = { w: 4, h: 3 };

type Gesture = {
  id: string;
  mode: "move" | "resize";
  pointer: number;
  from: { x: number; y: number };
  pitch: Pitch;
  /** Where the widget is drawn: following the pointer, clamped. */
  rect: Rect;
  baseline: Layout;
  /** Where it would land, and where the others make room. */
  preview: Layout;
};

/** Widgets on a grid that a person arranges: moved by their header,
 *  resized from their corner, added and removed; see doc.ts. */
export function DashboardGrid({
  label,
  widgets,
  defaultValue = [],
  onChange,
  readOnly = false,
  columns = DEFAULT_COLUMNS,
  rowHeight = 72,
  stackBelow = 640,
  empty = "No widgets yet. Customize the dashboard to add one.",
  className,
}: DashboardGridProps) {
  const id = useId();
  const root = useRef<HTMLDivElement>(null);
  const grid = useRef<HTMLDivElement>(null);
  const byId = new Map(widgets.map((widget) => [widget.id, widget]));

  const [history, setHistory] = useState<History<Layout>>(() =>
    // A saved layout may name widgets this build no longer has.
    start(defaultValue.filter((item) => byId.has(item.id))),
  );
  const change = (next: Layout, key: string | null = null) =>
    setHistory((current) => commit(current, next, key));
  const reported = useRef(history.present);
  useEffect(() => {
    if (reported.current === history.present) return;
    reported.current = history.present;
    onChange?.(history.present);
  }, [history.present, onChange]);

  const [editing, setEditing] = useState(false);
  const [drag, setDrag] = useState<Gesture | null>(null);
  const [message, setMessage] = useState("");
  const [width, setWidth] = useState<number | null>(null);
  // The handle a keyboard move came from: widgets render in reading order,
  // so a move can reorder them, and focus must follow.
  const refocus = useRef<string | null>(null);

  useEffect(() => {
    const element = root.current;
    if (!element) return;
    const observer = new ResizeObserver(([entry]) =>
      setWidth(entry.contentRect.width),
    );
    observer.observe(element);
    return () => observer.disconnect();
  }, []);
  useEffect(() => {
    const widgetId = refocus.current;
    if (!widgetId) return;
    refocus.current = null;
    root.current
      ?.querySelector<HTMLElement>(`[data-handle="${CSS.escape(widgetId)}"]`)
      ?.focus();
  });

  // A drag keeps focus where it was (often nowhere), so Escape is heard
  // on the window while one is in progress.
  const dragging = drag !== null;
  useEffect(() => {
    if (!dragging) return;
    const cancel = (event: globalThis.KeyboardEvent) => {
      if (event.key !== "Escape") return;
      setDrag(null);
      setMessage("Cancelled.");
    };
    window.addEventListener("keydown", cancel);
    return () => window.removeEventListener("keydown", cancel);
  }, [dragging]);

  const stacked = width !== null && width < stackBelow;
  const arranging = editing && !readOnly && !stacked;
  const layout = drag?.preview ?? history.present;
  const placed = new Set(layout.map((item) => item.id));
  const available = widgets.filter((widget) => !placed.has(widget.id));
  const titleOf = (widgetId: string) => byId.get(widgetId)?.title ?? widgetId;

  function announce(next: Layout, widgetId: string, verb: string) {
    const item = next.find((entry) => entry.id === widgetId);
    if (item)
      setMessage(`${verb} ${titleOf(widgetId)}: ${describePlace(item)}.`);
  }

  // ── Pointer: move by the header, resize by the corner ──
  function begin(
    event: PointerEvent<HTMLElement>,
    widgetId: string,
    mode: Gesture["mode"],
  ) {
    if (!arranging || event.button !== 0 || !grid.current) return;
    // Buttons in the header keep their own clicks.
    if (
      mode === "move" &&
      (event.target as HTMLElement).closest("button:not([data-handle])")
    )
      return;
    event.preventDefault();
    event.currentTarget.setPointerCapture(event.pointerId);
    const item = history.present.find((entry) => entry.id === widgetId);
    if (!item) return;
    const gap = parseFloat(getComputedStyle(grid.current).rowGap) || 0;
    const pitch = {
      x: (grid.current.clientWidth + gap) / columns,
      y: rowHeight + gap,
      gap,
    };
    setDrag({
      id: widgetId,
      mode,
      pointer: event.pointerId,
      from: { x: event.clientX, y: event.clientY },
      pitch,
      rect: drift(item, mode, 0, 0, pitch, columns).rect,
      baseline: history.present,
      preview: history.present,
    });
  }
  function move(event: PointerEvent<HTMLElement>) {
    if (!drag || event.pointerId !== drag.pointer) return;
    const item = drag.baseline.find((entry) => entry.id === drag.id);
    if (!item) return;
    const limits = byId.get(drag.id)?.limits;
    const { rect, cells } = drift(
      item,
      drag.mode,
      event.clientX - drag.from.x,
      event.clientY - drag.from.y,
      drag.pitch,
      columns,
      limits,
    );
    const preview = gesture(
      drag.baseline,
      drag.id,
      drag.mode,
      cells,
      columns,
      limits,
    );
    setDrag({ ...drag, rect, preview });
  }
  function finish(event: PointerEvent<HTMLElement>) {
    if (!drag || event.pointerId !== drag.pointer) return;
    setDrag(null);
    if (drag.preview !== drag.baseline) {
      change(drag.preview);
      announce(
        drag.preview,
        drag.id,
        drag.mode === "move" ? "Moved" : "Resized",
      );
    }
  }

  // ── Keyboard: arrows move, Shift+arrows resize ──
  function onHandleKey(
    event: KeyboardEvent<HTMLButtonElement>,
    widgetId: string,
  ) {
    const direction = NUDGES[event.key];
    if (!direction) return;
    event.preventDefault();
    const resize = event.shiftKey;
    const current = history.present;
    const next = nudge(
      current,
      widgetId,
      direction,
      resize,
      columns,
      byId.get(widgetId)?.limits,
    );
    if (next === current) {
      setMessage(
        `${titleOf(widgetId)} cannot ${resize ? "change size" : "move"} that way.`,
      );
      return;
    }
    change(next, `${resize ? "size" : "move"}:${widgetId}`);
    announce(next, widgetId, resize ? "Resized" : "Moved");
    refocus.current = widgetId;
  }

  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
    if (!arranging) return;
    const target = event.target as HTMLElement;
    if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
    const mod = event.metaKey || event.ctrlKey;
    if (mod && event.key.toLowerCase() === "z") {
      event.preventDefault();
      step(event.shiftKey ? "redo" : "undo");
    } else if (mod && event.key.toLowerCase() === "y") {
      event.preventDefault();
      step("redo");
    }
  }

  function step(to: "undo" | "redo") {
    setHistory((current) => (to === "undo" ? undo(current) : redo(current)));
    setMessage(to === "undo" ? "Undone." : "Redone.");
  }

  // Reading order, but held still during a drag: moving the dragged
  // element in the document would drop its pointer capture.
  const ordered = drag
    ? readingOrder(drag.baseline).map(
        (item) => layout.find((entry) => entry.id === item.id) ?? item,
      )
    : readingOrder(layout);
  const gridStyle = {
    "--columns": columns,
    "--row-h": `${rowHeight}px`,
    // A spare row while arranging, to drop into below the rest.
    "--rows": Math.max(
      rows(layout) + (arranging ? 1 : 0),
      drag ? Math.ceil((drag.rect.top + drag.rect.height) / drag.pitch.y) : 0,
      1,
    ),
  } as CSSProperties;

  return (
    <div
      ref={root}
      className={cn(styles.dashboard, className)}
      data-editing={arranging || undefined}
      onKeyDown={onKeyDown}
    >
      {readOnly ? null : (
        <div
          className={styles.toolbar}
          role="toolbar"
          aria-label={`${label} tools`}
        >
          {editing ? (
            <>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button
                    size="sm"
                    variant="secondary"
                    disabled={available.length === 0}
                  >
                    <Plus aria-hidden="true" />
                    Add widget
                  </Button>
                </DropdownMenuTrigger>
                <DropdownMenuContent align="start" className={styles.menu}>
                  <DropdownMenuLabel>Widgets</DropdownMenuLabel>
                  {available.map((widget) => (
                    <DropdownMenuItem
                      key={widget.id}
                      className={styles.menuItem}
                      onSelect={() => {
                        const next = add(
                          history.present,
                          widget.id,
                          widget.size ?? DEFAULT_SIZE,
                          columns,
                        );
                        change(next);
                        announce(next, widget.id, "Added");
                      }}
                    >
                      <span className={styles.menuTitle}>{widget.title}</span>
                      {widget.description ? (
                        <span className={styles.menuNote}>
                          {widget.description}
                        </span>
                      ) : null}
                    </DropdownMenuItem>
                  ))}
                </DropdownMenuContent>
              </DropdownMenu>
              {stacked ? null : (
                <>
                  <Button
                    size="sm"
                    variant="quiet"
                    disabled={compact(history.present) === history.present}
                    onClick={() => {
                      change(compact(history.present));
                      setMessage("Closed the gaps.");
                    }}
                  >
                    <WandSparkles aria-hidden="true" />
                    Tidy
                  </Button>
                  <Button
                    size="icon"
                    variant="quiet"
                    aria-label="Undo"
                    title="Undo (⌘Z)"
                    disabled={!canUndo(history)}
                    onClick={() => step("undo")}
                  >
                    <Undo2 aria-hidden="true" />
                  </Button>
                  <Button
                    size="icon"
                    variant="quiet"
                    aria-label="Redo"
                    title="Redo (⇧⌘Z)"
                    disabled={!canRedo(history)}
                    onClick={() => step("redo")}
                  >
                    <Redo2 aria-hidden="true" />
                  </Button>
                </>
              )}
              <Button
                size="sm"
                variant="primary"
                className={styles.toolbarEnd}
                onClick={() => {
                  setEditing(false);
                  setMessage("Done customizing.");
                }}
              >
                Done
              </Button>
            </>
          ) : (
            <Button
              size="sm"
              variant="secondary"
              className={styles.toolbarEnd}
              onClick={() => {
                setEditing(true);
                setMessage(
                  stacked
                    ? "Customizing. Widgets can be added and removed here; widen the window to arrange them."
                    : "Customizing. Drag a widget by its header or its corner, or use its handle: arrow keys move it, Shift and the arrow keys resize it.",
                );
              }}
            >
              <LayoutGrid aria-hidden="true" />
              Customize
            </Button>
          )}
        </div>
      )}
      {editing && stacked ? (
        <p className={styles.note}>
          Widgets stack on narrow screens. Widen the window to move and resize
          them.
        </p>
      ) : null}

      <div
        ref={grid}
        role="region"
        aria-label={label}
        className={stacked ? styles.stack : styles.grid}
        style={gridStyle}
      >
        {ordered.length === 0 ? (
          <div className={styles.empty}>{empty}</div>
        ) : null}
        {drag
          ? (() => {
              const landing = layout.find((item) => item.id === drag.id);
              return landing ? (
                <div
                  className={styles.placeholder}
                  aria-hidden="true"
                  style={
                    {
                      "--x": landing.x,
                      "--y": landing.y,
                      "--w": landing.w,
                      "--h": landing.h,
                    } as CSSProperties
                  }
                />
              ) : null;
            })()
          : null}
        {ordered.map((item) => {
          const widget = byId.get(item.id);
          if (!widget) return null;
          const active = drag?.id === item.id;
          return (
            <section
              key={item.id}
              aria-labelledby={`${id}-${item.id}`}
              className={styles.widget}
              data-active={active || undefined}
              style={
                active
                  ? // Following the pointer; the placeholder shows where
                    // it lands, and it glides there on release.
                    {
                      left: drag.rect.left,
                      top: drag.rect.top,
                      width: drag.rect.width,
                      height: drag.rect.height,
                    }
                  : ({
                      "--x": item.x,
                      "--y": item.y,
                      "--w": item.w,
                      "--h": item.h,
                    } as CSSProperties)
              }
            >
              <header
                className={styles.head}
                onPointerDown={(event) => begin(event, item.id, "move")}
                onPointerMove={move}
                onPointerUp={finish}
                onPointerCancel={() => setDrag(null)}
              >
                {arranging ? (
                  <button
                    type="button"
                    className={styles.handle}
                    data-handle={item.id}
                    aria-label={`Arrange ${widget.title}`}
                    aria-describedby={`${id}-hint`}
                    onKeyDown={(event) => onHandleKey(event, item.id)}
                  >
                    <GripVertical aria-hidden="true" />
                  </button>
                ) : null}
                <h3 id={`${id}-${item.id}`} className={styles.title}>
                  {widget.title}
                </h3>
                {editing ? (
                  <Button
                    size="icon"
                    variant="quiet"
                    className={styles.remove}
                    aria-label={`Remove ${widget.title}`}
                    onClick={() => {
                      change(remove(history.present, item.id));
                      setMessage(
                        `Removed ${widget.title}. Add widget brings it back.`,
                      );
                    }}
                  >
                    <X aria-hidden="true" />
                  </Button>
                ) : null}
              </header>
              <div className={styles.body}>{widget.content}</div>
              {arranging ? (
                <span
                  className={styles.resize}
                  aria-hidden="true"
                  onPointerDown={(event) => begin(event, item.id, "resize")}
                  onPointerMove={move}
                  onPointerUp={finish}
                  onPointerCancel={() => setDrag(null)}
                />
              ) : null}
            </section>
          );
        })}
      </div>

      <p id={`${id}-hint`} className={styles.hidden}>
        Arrow keys move the widget one cell; Shift and the arrow keys resize it.
      </p>
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}

components/workspaces/dashboard-grid/dashboard-grid.module.css

@layer composition {
  .dashboard {
    display: grid;
    gap: var(--space-4);
    min-inline-size: 0;
  }

  .toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2);
  }
  .toolbar svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .toolbarEnd {
    margin-inline-start: auto;
  }
  .note {
    margin: 0;
    color: var(--ink-3);
    font-size: var(--text-12);
  }
  .menu {
    max-inline-size: 300px;
  }
  .menuItem {
    display: grid;
    gap: 1px;
    block-size: auto;
    padding-block: var(--space-2);
  }
  .menuTitle {
    font-weight: var(--weight-medium);
  }
  .menuNote {
    color: var(--ink-3);
    font-size: var(--text-12);
    white-space: normal;
  }

  /* ── The grid ── */
  /* Widgets are placed absolutely from the layout, so a change of place is
     a transition: widgets glide out of the way and into their cells, and a
     glide interrupted mid-way turns smoothly toward its new end. */
  .grid {
    --gap: var(--space-4);
    position: relative;
    gap: var(--gap);
    block-size: calc(var(--rows) * (var(--row-h) + var(--gap)) - var(--gap));
    min-block-size: calc(var(--row-h) * 2);
  }
  .grid > .widget,
  .placeholder {
    position: absolute;
    left: calc(var(--x) * (100% + var(--gap)) / var(--columns));
    top: calc(var(--y) * (var(--row-h) + var(--gap)));
    width: calc(var(--w) * (100% + var(--gap)) / var(--columns) - var(--gap));
    height: calc(var(--h) * (var(--row-h) + var(--gap)) - var(--gap));
    transition:
      left var(--dur-2) var(--ease),
      top var(--dur-2) var(--ease),
      width var(--dur-2) var(--ease),
      height var(--dur-2) var(--ease);
  }
  /* The widget under the pointer follows it exactly. */
  .grid > .widget[data-active] {
    transition: none;
  }
  /* Where the dragged widget will land. */
  .placeholder {
    border: 2px dashed var(--accent);
    border-radius: var(--radius-3);
    background: var(--accent-tint);
  }
  @media (prefers-reduced-motion: reduce) {
    .grid > .widget,
    .placeholder {
      transition: none;
    }
  }
  /* Arranging shows the cells, so a drop has something to snap to. */
  .dashboard[data-editing] .grid {
    border-radius: var(--radius-3);
    background-image: linear-gradient(
      to right,
      var(--line) 1px,
      transparent 1px
    );
    background-size: calc((100% + var(--space-4)) / var(--columns)) 100%;
    background-position: calc(-1 * var(--space-4) / 2) 0;
    outline: 1px dashed var(--line-strong);
    outline-offset: var(--space-2);
  }

  /* Narrow: one column, reading order, each widget as tall as it was. */
  .stack {
    display: grid;
    gap: var(--space-4);
  }
  .stack > .widget {
    block-size: calc(var(--h) * var(--row-h) + (var(--h) - 1) * var(--space-4));
    min-block-size: calc(var(--row-h) * 2);
  }

  .empty {
    display: grid;
    block-size: 100%;
    place-items: center;
    padding: var(--space-6);
    border: 1px dashed var(--line-strong);
    border-radius: var(--radius-3);
    color: var(--ink-3);
    font-size: var(--text-13);
    text-align: center;
  }

  /* ── A widget ── */
  .widget {
    position: relative;
    display: grid;
    grid-template-rows: auto minmax(0, 1fr);
    min-inline-size: 0;
    border: 1px solid var(--line);
    border-radius: var(--radius-3);
    background: var(--surface-panel);
    overflow: hidden;
  }
  .widget[data-active] {
    z-index: var(--z-raised);
    border-color: var(--accent);
    box-shadow:
      0 0 0 1px var(--accent),
      0 10px 24px var(--alpha-b-13);
  }
  .head {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    min-block-size: 40px;
    padding: var(--space-2) var(--space-2) var(--space-2) var(--space-4);
  }
  .dashboard[data-editing] .head {
    padding-inline-start: var(--space-2);
    cursor: grab;
    touch-action: none;
    user-select: none;
  }
  .widget[data-active] .head {
    cursor: grabbing;
  }
  .handle {
    display: grid;
    place-items: center;
    inline-size: 24px;
    block-size: 24px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-1);
    background: none;
    color: var(--ink-3);
    cursor: inherit;
  }
  .handle:hover {
    background: var(--surface-hover);
    color: var(--ink);
  }
  .handle:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 1px;
  }
  .handle svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .title {
    overflow: hidden;
    margin: 0;
    font-size: var(--text-13);
    font-weight: var(--weight-medium);
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .remove {
    margin-inline-start: auto;
    cursor: pointer;
  }
  .remove svg {
    inline-size: 15px;
    block-size: 15px;
  }
  .body {
    min-block-size: 0;
    padding: 0 var(--space-4) var(--space-4);
    overflow: auto;
  }
  /* The corner that resizes: a pointer target, drawn as two short lines. */
  .resize {
    position: absolute;
    right: 0;
    bottom: 0;
    inline-size: 20px;
    block-size: 20px;
    cursor: nwse-resize;
    touch-action: none;
  }
  .resize::after {
    content: "";
    position: absolute;
    right: 5px;
    bottom: 5px;
    inline-size: 8px;
    block-size: 8px;
    border-right: 2px solid var(--ink-4);
    border-bottom: 2px solid var(--ink-4);
    border-bottom-right-radius: 2px;
  }
  .resize:hover::after,
  .widget[data-active] .resize::after {
    border-color: var(--accent);
  }

  .hidden {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
}

components/workspaces/_kernel/grid.ts

/* A dashboard layout: rectangles on a grid of columns and rows. Plain data
   and pure functions, framework-free and identical in both apps; the grid
   renderer only draws it, turns pointer travel into cells, and calls these.
   Every change returns a new layout, so history can keep the old one.

   Placement is free — gaps stay where a person leaves them — and a widget
   moved or grown onto others pushes them down, never over or aside. Tidy
   (compact) closes the gaps on request. */

export type GridItem = Readonly<{
  id: string;
  x: number;
  y: number;
  w: number;
  h: number;
}>;

export type Layout = readonly GridItem[];

/** How small or large one widget may be, in cells. */
export type SizeLimits = Readonly<{
  minW?: number;
  minH?: number;
  maxW?: number;
  maxH?: number;
}>;

export const DEFAULT_COLUMNS = 12;

export const overlaps = (a: GridItem, b: GridItem) =>
  a.id !== b.id &&
  a.x < b.x + b.w &&
  a.x + a.w > b.x &&
  a.y < b.y + b.h &&
  a.y + a.h > b.y;

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

/** An item made to fit: whole cells, within its limits, inside the
 *  columns, never above the first row. */
export function fit(
  item: GridItem,
  columns: number,
  limits: SizeLimits = {},
): GridItem {
  const w = clamp(
    Math.round(item.w),
    Math.max(1, limits.minW ?? 1),
    Math.min(columns, limits.maxW ?? columns),
  );
  const h = clamp(
    Math.round(item.h),
    Math.max(1, limits.minH ?? 1),
    limits.maxH ?? Number.MAX_SAFE_INTEGER,
  );
  return {
    id: item.id,
    w,
    h,
    x: clamp(Math.round(item.x), 0, columns - w),
    y: Math.max(0, Math.round(item.y)),
  };
}

/** Puts `item` where it says and pushes whatever it lands on straight down,
 *  and whatever those land on, until nothing overlaps. */
export function place(layout: Layout, item: GridItem): Layout {
  const items = new Map<string, GridItem>(
    layout.map((entry) => [entry.id, entry.id === item.id ? item : entry]),
  );
  if (!items.has(item.id)) items.set(item.id, item);
  const push = (mover: GridItem) => {
    const hit = [...items.values()]
      .filter((other) => overlaps(mover, other))
      .sort((a, b) => a.y - b.y || a.x - b.x);
    for (const other of hit) {
      const current = items.get(other.id)!;
      // An earlier push may already have moved it clear.
      if (!overlaps(mover, current)) continue;
      const moved = { ...current, y: mover.y + mover.h };
      items.set(moved.id, moved);
      push(moved);
    }
  };
  push(item);
  // Keep the caller's order, so ids render in a stable order.
  const order = layout.some((entry) => entry.id === item.id)
    ? layout.map((entry) => entry.id)
    : [...layout.map((entry) => entry.id), item.id];
  return order.map((id) => items.get(id)!);
}

const same = (a: Layout, b: Layout) =>
  a.length === b.length &&
  a.every(
    (item, index) =>
      item.id === b[index].id &&
      item.x === b[index].x &&
      item.y === b[index].y &&
      item.w === b[index].w &&
      item.h === b[index].h,
  );

/** Moves or resizes one item to `to` (fitted), pushing others down. The
 *  same layout back when nothing changed. */
export function change(
  layout: Layout,
  id: string,
  to: Partial<Omit<GridItem, "id">>,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const current = layout.find((item) => item.id === id);
  if (!current) return layout;
  const next = place(layout, fit({ ...current, ...to }, columns, limits));
  return same(next, layout) ? layout : next;
}

export type Nudge = "left" | "right" | "up" | "down";

/** The keyboard's move: one cell in a direction, or with `resize`, one cell
 *  wider, narrower, taller or shorter. */
export function nudge(
  layout: Layout,
  id: string,
  direction: Nudge,
  resize = false,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const item = layout.find((entry) => entry.id === id);
  if (!item) return layout;
  const d = direction === "left" || direction === "up" ? -1 : 1;
  const horizontal = direction === "left" || direction === "right";
  const to = resize
    ? horizontal
      ? { w: item.w + d }
      : { h: item.h + d }
    : horizontal
      ? { x: item.x + d }
      : { y: item.y + d };
  // Up into another widget: jump above it rather than push it down (which
  // would leave this one where it was, relative to it).
  if (!resize && direction === "up") {
    const above = layout
      .filter(
        (other) =>
          other.id !== id &&
          other.x < item.x + item.w &&
          other.x + other.w > item.x &&
          other.y + other.h === item.y,
      )
      .sort((a, b) => a.y - b.y)[0];
    if (above) return change(layout, id, { y: above.y }, columns, limits);
  }
  // Down onto another widget: the mirror, that one jumps above this one.
  if (!resize && direction === "down") {
    const below = layout
      .filter(
        (other) =>
          other.id !== id &&
          other.x < item.x + item.w &&
          other.x + other.w > item.x &&
          other.y === item.y + item.h,
      )
      .sort((a, b) => a.x - b.x)[0];
    if (below) return change(layout, below.id, { y: item.y }, columns);
  }
  return change(layout, id, to, columns, limits);
}

/** Where a pointer gesture puts an item: its starting rectangle plus the
 *  cells travelled, moved (`move`) or sized from its far corner
 *  (`resize`), applied to the layout as it was when the gesture began. */
export function gesture(
  baseline: Layout,
  id: string,
  mode: "move" | "resize",
  cells: Readonly<{ dx: number; dy: number }>,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): Layout {
  const start = baseline.find((item) => item.id === id);
  if (!start) return baseline;
  return change(
    baseline,
    id,
    mode === "move"
      ? { x: start.x + cells.dx, y: start.y + cells.dy }
      : { w: start.w + cells.dx, h: start.h + cells.dy },
    columns,
    limits,
  );
}

/** Pointer travel in pixels as whole cells. `pitch` is a cell plus its
 *  gap; half a cell is enough to snap to the next. */
export const toCells = (
  dx: number,
  dy: number,
  pitch: Readonly<{ x: number; y: number }>,
) => ({
  dx: pitch.x > 0 ? Math.round(dx / pitch.x) : 0,
  dy: pitch.y > 0 ? Math.round(dy / pitch.y) : 0,
});

/** The first place, reading top to bottom and left to right, where a
 *  `w` × `h` item fits without overlapping anything. */
export function firstFit(
  layout: Layout,
  w: number,
  h: number,
  columns = DEFAULT_COLUMNS,
): Readonly<{ x: number; y: number }> {
  const width = Math.min(w, columns);
  const bottom = layout.reduce(
    (max, item) => Math.max(max, item.y + item.h),
    0,
  );
  for (let y = 0; y <= bottom; y += 1)
    for (let x = 0; x + width <= columns; x += 1) {
      const probe = { id: "\u0000", x, y, w: width, h };
      if (!layout.some((item) => overlaps(probe, item))) return { x, y };
    }
  return { x: 0, y: bottom };
}

export function add(
  layout: Layout,
  id: string,
  size: Readonly<{ w: number; h: number }>,
  columns = DEFAULT_COLUMNS,
): Layout {
  if (layout.some((item) => item.id === id)) return layout;
  const at = firstFit(layout, size.w, size.h, columns);
  return [...layout, { id, ...at, w: Math.min(size.w, columns), h: size.h }];
}

export const remove = (layout: Layout, id: string): Layout =>
  layout.filter((item) => item.id !== id);

/** Closes vertical gaps: each item, top to bottom, rises until it meets
 *  another or the first row. Columns never change. */
export function compact(layout: Layout): Layout {
  const settled: GridItem[] = [];
  const byId = new Map<string, GridItem>();
  for (const item of readingOrder(layout)) {
    let next = { ...item, y: 0 };
    while (settled.some((other) => overlaps(next, other)))
      next = { ...next, y: next.y + 1 };
    // Never lower than it was.
    if (next.y > item.y) next = item;
    settled.push(next);
    byId.set(next.id, next);
  }
  const result = layout.map((item) => byId.get(item.id)!);
  return same(result, layout) ? layout : result;
}

/** Top to bottom, then left to right: the order a narrow screen stacks
 *  widgets in, and the order they are read in. */
export const readingOrder = (layout: Layout): GridItem[] =>
  [...layout].sort((a, b) => a.y - b.y || a.x - b.x);

/** Rows the layout spans. */
export const rows = (layout: Layout) =>
  layout.reduce((max, item) => Math.max(max, item.y + item.h), 0);

/** Where an item is, as a person hears it. */
export const describePlace = (item: GridItem) =>
  `column ${item.x + 1}, row ${item.y + 1}, ${item.w} wide and ${item.h} tall`;

// ── Pixels, for a gesture that follows the pointer ───────────────────────

/** A rectangle in pixels, from the grid's top left corner. */
export type Rect = Readonly<{
  left: number;
  top: number;
  width: number;
  height: number;
}>;

/** One column and one row, each with the gap after it; and the gap. */
export type Pitch = Readonly<{ x: number; y: number; gap: number }>;

/** Where an item's cells are, in pixels. */
export const cellRect = (item: GridItem, pitch: Pitch): Rect => ({
  left: item.x * pitch.x,
  top: item.y * pitch.y,
  width: item.w * pitch.x - pitch.gap,
  height: item.h * pitch.y - pitch.gap,
});

/** A pointer gesture as it is drawn and as it lands. The widget follows
 *  the pointer freely (`rect`), clamped: a move stays inside the columns
 *  and below the top; a resize stays within the widget's limits and the
 *  right edge. `cells` is how far that is in whole cells, which is where it
 *  lands (see gesture). */
export function drift(
  item: GridItem,
  mode: "move" | "resize",
  dx: number,
  dy: number,
  pitch: Pitch,
  columns = DEFAULT_COLUMNS,
  limits: SizeLimits = {},
): { rect: Rect; cells: { dx: number; dy: number } } {
  const start = cellRect(item, pitch);
  const inner = columns * pitch.x - pitch.gap;
  if (mode === "move") {
    const left = clamp(start.left + dx, 0, inner - start.width);
    const top = Math.max(0, start.top + dy);
    return {
      rect: { ...start, left, top },
      cells: toCells(left - start.left, top - start.top, pitch),
    };
  }
  const span = (cells: number, step: number) => cells * step - pitch.gap;
  const minWidth = span(Math.max(1, limits.minW ?? 1), pitch.x);
  const maxWidth = Math.min(
    span(Math.min(columns, limits.maxW ?? columns), pitch.x),
    inner - start.left,
  );
  const minHeight = span(Math.max(1, limits.minH ?? 1), pitch.y);
  const maxHeight =
    limits.maxH === undefined
      ? Number.POSITIVE_INFINITY
      : span(limits.maxH, pitch.y);
  const width = clamp(start.width + dx, minWidth, Math.max(minWidth, maxWidth));
  const height = clamp(start.height + dy, minHeight, maxHeight);
  return {
    rect: { ...start, width, height },
    cells: toCells(width - start.width, height - start.height, pitch),
  };
}