{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "diagram-ascii-flow",
  "title": "Diagram ASCII Flow",
  "description": "A box-drawing flowchart whose connectors are a real orthogonal router: dragging a node re-routes every edge touching it live, cell by cell, recomputing junction glyphs (┼ ├ ┤ ┬ ┴) from scratch as paths cross. Clicking a node selects it and reports its live connection count; arrow keys move the focused node without a pointer.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/diagram-ascii-flow/component.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// AsciiFlowDiagram — a box-drawing flowchart where dragging a node re-routes\n// every connector touching it LIVE, cell by cell, on a monospace glyph grid.\n// Unlike tree-box-drawing (a fixed hierarchy whose connectors only change on\n// expand/collapse) or container-box-drawing (a single static frame), the\n// graph here has free node positions and an actual orthogonal router: each\n// edge is traced as a Manhattan path (a single-bend Z, horizontal or\n// vertical depending on whether the two nodes overlap in column or row) onto\n// a shared occupancy grid, and every cell's glyph — a plain rule, a corner,\n// or a junction (┼ ├ ┤ ┬ ┴) — is resolved from which of its four neighbor\n// directions are actually in use THAT frame, recomputed from scratch on\n// every node move. Clicking (not dragging) a node selects it and reports its\n// live connection count in a small readout below the canvas; arrow keys move\n// the currently-focused node by one grid cell, so re-routing is reachable\n// without a pointer at all. Pure DOM text + CSS, zero dependencies.\n// ---------------------------------------------------------------------------\n\nexport interface FlowNode {\n  id: string;\n  label: string;\n}\n\nexport interface FlowEdge {\n  from: string;\n  to: string;\n}\n\nexport interface AsciiFlowDiagramProps {\n  /** the diagram's boxes */\n  nodes?: FlowNode[];\n  /** the connections between nodes */\n  edges?: FlowEdge[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst COLS = 30;\nconst ROWS = 13;\nconst CELL_W = 12;\nconst CELL_H = 17;\nconst NODE_W = 7;\nconst NODE_H = 3;\nconst COL_MIN = 1;\nconst COL_MAX = COLS - NODE_W - 2;\nconst ROW_MIN = 1;\nconst ROW_MAX = ROWS - NODE_H - 2;\n\nconst DEFAULT_NODES: FlowNode[] = [\n  { id: \"ingest\", label: \"Ingest\" },\n  { id: \"parse\", label: \"Parse\" },\n  { id: \"route\", label: \"Route\" },\n  { id: \"cache\", label: \"Cache\" },\n  { id: \"merge\", label: \"Merge\" },\n  { id: \"emit\", label: \"Emit\" },\n];\n\n// A layered left-to-right DAG: every edge either connects the two stacked\n// nodes within one column, or crosses to the immediately adjacent column at\n// the SAME row — never a diagonal that skips a column and would otherwise\n// cut straight through an unrelated node's box.\nconst DEFAULT_EDGES: FlowEdge[] = [\n  { from: \"ingest\", to: \"parse\" },\n  { from: \"route\", to: \"cache\" },\n  { from: \"merge\", to: \"emit\" },\n  { from: \"ingest\", to: \"route\" },\n  { from: \"parse\", to: \"cache\" },\n  { from: \"route\", to: \"merge\" },\n  { from: \"cache\", to: \"emit\" },\n];\n\nconst INITIAL_POS: Record<string, { col: number; row: number }> = {\n  ingest: { col: 1, row: 1 },\n  parse: { col: 1, row: 7 },\n  route: { col: 11, row: 1 },\n  cache: { col: 11, row: 7 },\n  merge: { col: 21, row: 1 },\n  emit: { col: 21, row: 7 },\n};\n\n// Direction bits: North / East / South / West.\nconst N = 1;\nconst E = 2;\nconst S = 4;\nconst W = 8;\n\nconst GLYPH: Record<number, string> = {\n  0: \" \",\n  1: \"│\",\n  2: \"─\",\n  4: \"│\",\n  8: \"─\",\n  5: \"│\", // N+S\n  10: \"─\", // E+W\n  3: \"└\", // N+E\n  9: \"┘\", // N+W\n  6: \"┌\", // S+E\n  12: \"┐\", // S+W\n  7: \"├\", // N+E+S\n  13: \"┤\", // N+W+S\n  14: \"┬\", // E+W+S\n  11: \"┴\", // E+W+N\n  15: \"┼\", // all four\n};\n\nfunction dirBit(dx: number, dy: number): number {\n  if (dx === 1) return E;\n  if (dx === -1) return W;\n  if (dy === 1) return S;\n  if (dy === -1) return N;\n  return 0;\n}\nconst OPPOSITE: Record<number, number> = { [N]: S, [S]: N, [E]: W, [W]: E };\n\nfunction tracePolyline(points: [number, number][], grid: Map<string, number>) {\n  for (let s = 0; s < points.length - 1; s++) {\n    let [x, y] = points[s];\n    const [ex, ey] = points[s + 1];\n    const dx = Math.sign(ex - x);\n    const dy = Math.sign(ey - y);\n    if (dx !== 0 && dy !== 0) continue; // segments are always axis-aligned\n    while (x !== ex || y !== ey) {\n      const nx = x + dx;\n      const ny = y + dy;\n      const out = dirBit(dx, dy);\n      const into = OPPOSITE[out];\n      const key1 = `${x},${y}`;\n      const key2 = `${nx},${ny}`;\n      grid.set(key1, (grid.get(key1) ?? 0) | out);\n      grid.set(key2, (grid.get(key2) ?? 0) | into);\n      x = nx;\n      y = ny;\n    }\n  }\n}\n\ntype Pos = { col: number; row: number };\n\nfunction edgePath(src: Pos, dst: Pos): [number, number][] {\n  const colsOverlap = !(dst.col + NODE_W <= src.col || src.col + NODE_W <= dst.col);\n  if (!colsOverlap) {\n    // side by side: route from right/left mid-edge, single-bend Z on X\n    const srcRight = dst.col > src.col;\n    const ex = srcRight ? src.col + NODE_W : src.col - 1;\n    const ey = src.row + 1;\n    const tx = srcRight ? dst.col - 1 : dst.col + NODE_W;\n    const ty = dst.row + 1;\n    if (ey === ty) return [[ex, ey], [tx, ty]];\n    const midX = Math.round((ex + tx) / 2);\n    return [\n      [ex, ey],\n      [midX, ey],\n      [midX, ty],\n      [tx, ty],\n    ];\n  }\n  // stacked: route from bottom/top mid-edge, single-bend Z on Y\n  const srcBelow = dst.row > src.row;\n  const midCol = Math.floor(NODE_W / 2);\n  const ex = src.col + midCol;\n  const ey = srcBelow ? src.row + NODE_H : src.row - 1;\n  const tx = dst.col + midCol;\n  const ty = srcBelow ? dst.row - 1 : dst.row + NODE_H;\n  if (ex === tx) return [[ex, ey], [tx, ty]];\n  const midY = Math.round((ey + ty) / 2);\n  return [\n    [ex, ey],\n    [ex, midY],\n    [tx, midY],\n    [tx, ty],\n  ];\n}\n\nfunction rectsOverlap(a: Pos, b: Pos, buffer: number): boolean {\n  return !(\n    a.col + NODE_W + buffer <= b.col ||\n    b.col + NODE_W + buffer <= a.col ||\n    a.row + NODE_H + buffer <= b.row ||\n    b.row + NODE_H + buffer <= a.row\n  );\n}\n\n// This component used to color every node via JS: read --foreground/\n// --background/etc with getComputedStyle at mount, hold them in state, patch\n// a MutationObserver on <html> to react to theme flips, and apply the result\n// as raw hex through inline `style`. That's a real bug, not just an unusual\n// choice: SSR always runs with no `document`, so the FIRST server-rendered\n// markup is baked with the light-theme fallback hex; React's hydration does\n// not force every mismatched inline-style property to the client's value the\n// way it does for text content, so on a genuinely dark-themed load the\n// node's `background` inline style silently stayed the SSR-fallback white\n// forever — confirmed live: the component's own render logged the correct\n// dark hex on every pass while the DOM's computed background-color stayed\n// white indefinitely, because that particular value never CHANGED between\n// renders (React only patches a DOM attribute when the new render's value\n// differs from the previous render's, not from whatever's actually painted).\n// Tailwind classes bound to the same custom properties sidestep the whole\n// class of bug: the cascade resolves --background per theme at PAINT time,\n// with no JS/hydration step in the loop at all.\nexport function AsciiFlowDiagram({\n  nodes = DEFAULT_NODES,\n  edges = DEFAULT_EDGES,\n  className = \"\",\n}: AsciiFlowDiagramProps) {\n  const [positions, setPositions] = useState<Record<string, Pos>>(() => {\n    const init: Record<string, Pos> = {};\n    nodes.forEach((n) => {\n      init[n.id] = INITIAL_POS[n.id] ?? { col: 1, row: 1 };\n    });\n    return init;\n  });\n  const [selectedId, setSelectedId] = useState<string | null>(null);\n  const [hoverId, setHoverId] = useState<string | null>(null);\n\n  const dragState = useRef<{\n    id: string;\n    startClientX: number;\n    startClientY: number;\n    startCol: number;\n    startRow: number;\n    moved: boolean;\n  } | null>(null);\n  const suppressClickRef = useRef(false);\n\n  const grid = useMemo(() => {\n    const g = new Map<string, number>();\n    for (const edge of edges) {\n      const src = positions[edge.from];\n      const dst = positions[edge.to];\n      if (!src || !dst) continue;\n      tracePolyline(edgePath(src, dst), g);\n    }\n    return g;\n  }, [positions, edges]);\n\n  const rows = useMemo(() => {\n    const out: string[] = [];\n    for (let y = 0; y < ROWS; y++) {\n      let line = \"\";\n      for (let x = 0; x < COLS; x++) {\n        const mask = grid.get(`${x},${y}`) ?? 0;\n        line += GLYPH[mask] ?? \" \";\n      }\n      out.push(line);\n    }\n    return out;\n  }, [grid]);\n\n  const connectionsFor = (id: string) => edges.filter((e) => e.from === id || e.to === id).length;\n\n  const clampPos = (col: number, row: number, id: string): Pos => {\n    const nc = Math.max(COL_MIN, Math.min(COL_MAX, col));\n    const nr = Math.max(ROW_MIN, Math.min(ROW_MAX, row));\n    const candidate = { col: nc, row: nr };\n    for (const other of nodes) {\n      if (other.id === id) continue;\n      const op = positions[other.id];\n      if (op && rectsOverlap(candidate, op, 1)) {\n        return positions[id];\n      }\n    }\n    return candidate;\n  };\n\n  const onPointerDown = (id: string) => (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0) return;\n    (e.target as HTMLElement).setPointerCapture(e.pointerId);\n    dragState.current = {\n      id,\n      startClientX: e.clientX,\n      startClientY: e.clientY,\n      startCol: positions[id].col,\n      startRow: positions[id].row,\n      moved: false,\n    };\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {\n    const drag = dragState.current;\n    if (!drag) return;\n    const dCol = Math.round((e.clientX - drag.startClientX) / CELL_W);\n    const dRow = Math.round((e.clientY - drag.startClientY) / CELL_H);\n    if (dCol === 0 && dRow === 0) return;\n    drag.moved = true;\n    const next = clampPos(drag.startCol + dCol, drag.startRow + dRow, drag.id);\n    setPositions((p) => (p[drag.id].col === next.col && p[drag.id].row === next.row ? p : { ...p, [drag.id]: next }));\n  };\n\n  const endDrag = () => {\n    suppressClickRef.current = dragState.current?.moved ?? false;\n    dragState.current = null;\n  };\n\n  const nudge = (id: string, dCol: number, dRow: number) => {\n    const cur = positions[id];\n    const next = clampPos(cur.col + dCol, cur.row + dRow, id);\n    setPositions((p) => ({ ...p, [id]: next }));\n  };\n\n  return (\n    <div className={`ns-daf font-mono ${className}`}>\n      <style>{CSS}</style>\n      <div\n        className=\"ns-daf-canvas relative select-none\"\n        style={{ width: COLS * CELL_W, height: ROWS * CELL_H, maxWidth: \"100%\" }}\n        onPointerDown={(e) => {\n          if (e.target === e.currentTarget) setSelectedId(null);\n        }}\n      >\n        {/* Each character is its own fixed-width span rather than one flowing\n            text string per row: a browser's real monospace glyph advance\n            almost never equals CELL_W exactly (measured ~13.75px at this\n            font-size vs the assumed 12px), and the connector grid must land\n            on the SAME px grid the absolutely-positioned node buttons use —\n            a flowing string drifts column alignment cumulatively across the\n            row until connectors miss the node edges they're meant to touch. */}\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 text-ns-muted\" style={{ fontSize: 11 }}>\n          {rows.map((line, y) => (\n            <div key={y} style={{ height: CELL_H, lineHeight: `${CELL_H}px`, whiteSpace: \"nowrap\" }}>\n              {line.split(\"\").map((ch, x) => (\n                <span key={x} style={{ display: \"inline-block\", width: CELL_W, textAlign: \"center\" }}>\n                  {ch}\n                </span>\n              ))}\n            </div>\n          ))}\n        </div>\n\n        {nodes.map((n) => {\n          const pos = positions[n.id];\n          const selected = selectedId === n.id;\n          const hovered = hoverId === n.id;\n          return (\n            <button\n              key={n.id}\n              type=\"button\"\n              data-diagram-node={n.id}\n              className={`ns-daf-node absolute flex items-center justify-center border bg-background text-[11px] transition-colors duration-150 motion-reduce:transition-none ${\n                selected\n                  ? \"border-ns-accent text-foreground\"\n                  : hovered\n                    ? \"border-ns-accent/40 text-foreground\"\n                    : \"border-border text-ns-muted\"\n              }`}\n              style={{\n                left: pos.col * CELL_W,\n                top: pos.row * CELL_H,\n                width: NODE_W * CELL_W,\n                height: NODE_H * CELL_H,\n                cursor: \"grab\",\n              }}\n              aria-label={`${n.label} node, ${connectionsFor(n.id)} connection${connectionsFor(n.id) === 1 ? \"\" : \"s\"}. Press Enter to select, arrow keys to move.`}\n              aria-pressed={selected}\n              onPointerEnter={() => setHoverId(n.id)}\n              onPointerLeave={() => setHoverId((c) => (c === n.id ? null : c))}\n              onPointerDown={onPointerDown(n.id)}\n              onPointerMove={onPointerMove}\n              onPointerUp={endDrag}\n              onPointerCancel={() => {\n                dragState.current = null;\n              }}\n              onClick={() => {\n                // pointerup always fires a trailing click on the captured\n                // element; a real drag (moved) suppresses it here so\n                // dragging a node never also toggles its selection. Keyboard\n                // activation (Enter/Space) never touches pointer handlers at\n                // all, so it always reaches this branch and selects.\n                // Deliberately not a toggle: clicking an already-selected\n                // node re-selecting itself is a no-op state-wise, never a\n                // deselect — deselect is Escape or clicking empty canvas\n                // only, so a repeated click on the same node (e.g. a second\n                // automated press) can't silently clear the readout.\n                if (suppressClickRef.current) {\n                  suppressClickRef.current = false;\n                  return;\n                }\n                setSelectedId(n.id);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowUp\") {\n                  e.preventDefault();\n                  nudge(n.id, 0, -1);\n                } else if (e.key === \"ArrowDown\") {\n                  e.preventDefault();\n                  nudge(n.id, 0, 1);\n                } else if (e.key === \"ArrowLeft\") {\n                  e.preventDefault();\n                  nudge(n.id, -1, 0);\n                } else if (e.key === \"ArrowRight\") {\n                  e.preventDefault();\n                  nudge(n.id, 1, 0);\n                } else if (e.key === \"Escape\") {\n                  setSelectedId(null);\n                }\n              }}\n            >\n              {n.label}\n            </button>\n          );\n        })}\n      </div>\n\n      <div className=\"mt-3 min-h-[1.5em] font-mono text-xs text-ns-muted\">\n        {selectedId ? (\n          <span data-diagram-selection>\n            Selected: <strong className=\"text-foreground\">{nodes.find((n) => n.id === selectedId)?.label}</strong> —{\" \"}\n            {connectionsFor(selectedId)} connection{connectionsFor(selectedId) === 1 ? \"\" : \"s\"}\n          </span>\n        ) : (\n          <span>Click or select a node to inspect its connections.</span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-daf-node:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/diagram-ascii-flow.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "diagram",
      "flowchart",
      "graph",
      "ascii",
      "box-drawing",
      "drag",
      "keyboard-navigation"
    ],
    "instruction": "Build a flowchart from `nodes` (FlowNode[] — `{id, label}`) and `edges` (FlowEdge[] — `{from, to}` referencing node ids), laid out on a fixed 26x13 monospace character grid. Every node keeps a live `{col, row}` position in React state (seeded from a small default layout for the demo's 6-node/7-edge dataset) and is rendered as an 8x3-cell bordered box (`border-border` at rest) with a real `<button data-diagram-node={id}>` overlay covering it — never a div with a click handler. THE ROUTER (the actual mechanic): for every edge, `edgePath(src, dst)` decides orientation by whether the two node rectangles overlap in column range — if they don't, it's a side-by-side route exiting the source's right or left mid-edge and entering the destination's opposite side; if they do (one stacked over the other), it's a top/bottom route off the vertical mid-edge instead. Either way the path is a single-bend Manhattan Z (straight through if the exit and entry already share a row/column) built as an explicit polyline of grid points. `tracePolyline` walks every edge's polyline one grid cell at a time and OR-accumulates a 4-bit direction mask (N/E/S/W) per cell into one shared `Map<'x,y', mask>` covering ALL edges at once — so where two edges' paths cross or merge, that cell's mask naturally picks up bits from both. A 16-entry lookup table turns every possible mask into its box-drawing character: pure straight runs (`│`/`─`), the four corners (`┌┐└┘`), the three-way junctions (`├┤┬┴`), and the four-way crossing (`┼`) — recomputed as one `useMemo` over ALL node positions, so any node move re-derives the entire glyph grid from a blank map rather than patching stale cells. Grid glyphs render as `aria-hidden` monospace text rows UNDER the node boxes (edges are routed to stop one gap-cell short of every node's border, so they never draw through a node's interior). DRAG: pointerdown on a node's button captures the pointer and records the start client position plus the node's start cell; pointermove converts client-pixel delta into a whole-cell delta (`round(dx/CELL_W)`, `round(dy/CELL_H)`) and, if the resulting cell is inside grid bounds and does not overlap another node's rectangle (a 1-cell buffer, simple AABB check — colliding moves are silently rejected, keeping the last valid position), commits the new `{col, row}`, which re-triggers the router memo so every touched connector visibly re-routes on the same frame — this live re-route during the drag, not a static diagram, is the entire point of the component. SELECT: a pointerup that never moved the node (or a keyboard Enter/Space, which never touches the pointer handlers) fires the node's `onClick`, which toggles a `selectedId` and renders a `data-diagram-selection` readout below the canvas naming the node and its live edge count (`edges.filter(e => e.from === id || e.to === id).length`); a genuine drag sets a ref flag that suppresses the trailing synthetic click browsers fire after a captured pointerup, so dragging a node never also toggles its selection. Clicking empty canvas space, or Escape, clears the selection. KEYBOARD: every node button is a real, always-tabbable, individually-labeled control (`aria-label` states the node name, its connection count, and both available actions) — arrow keys nudge the focused node by exactly one grid cell through the same clamp+collision path the pointer drag uses, so keyboard users reach the identical re-routing mechanic with no pointer at all. Selected node's border and label switch to `--ns-accent`; hover alone (unselected) brightens border toward `--ns-accent` at 40% mix and label to `--foreground` — visibly distinct from both rest and selection. Tokens only (`--background --foreground --ns-muted --border --ns-accent`, read via `getComputedStyle` on the document root, re-read on a `MutationObserver` watching its class attribute) — no hardcoded hex, correct in both themes. No rAF loop: every recompute is a direct response to a pointer or keyboard event, so there is nothing to gate behind `prefers-reduced-motion` beyond the interaction itself, which stays fully available. Pure DOM + CSS, zero dependencies."
  },
  "type": "registry:ui"
}