{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "patchbay-ascii-cable",
  "title": "Patchbay ASCII Cable",
  "description": "A patchbay with real, persistent, user-authored topology: drag from one jack to another to create a patch, routed orthogonally onto the shared monospace glyph grid, and once connected a small pulse travels the cable on a loop. Grabbing a jack that already carries a patch unplugs it (the classic pull-the-plug gesture), leaving a loose end you can drop on a new jack or nowhere at all.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/patchbay-ascii-cable/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// AsciiPatchbay — a patchbay whose distinguishing mechanic is PERSISTENT,\n// user-authored topology: dragging from one jack to another creates a patch\n// (arbitrary jack-to-jack, not a fixed slider ratio or a one-shot transfer\n// tube), the cable is routed orthogonally onto the same shared monospace\n// glyph grid diagram-ascii-flow's router uses, and once connected a small\n// glyph pulse travels the length of the cable on a loop. Grabbing a jack\n// that already carries a patch unplugs it immediately (classic patchbay\n// behavior — picking up the far end of a live cable disconnects it) and\n// hands you a loose plug to drop on a new jack or nowhere at all. Every\n// jack is a real button; Enter/Space \"arms\" it, a second Enter/Space on a\n// different jack completes the patch, and pressing the same jack again\n// cancels — so the whole mechanic is reachable without a pointer.\n// ---------------------------------------------------------------------------\n\nexport interface PatchbayJack {\n  id: string;\n  label: string;\n  row: \"top\" | \"bottom\";\n  col: number; // 0-based slot index within its row\n}\n\nexport interface AsciiPatchbayProps {\n  /** the patchable jacks */\n  jacks?: PatchbayJack[];\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst DEFAULT_JACKS: PatchbayJack[] = [\n  { id: \"a\", label: \"A\", row: \"top\", col: 0 },\n  { id: \"b\", label: \"B\", row: \"top\", col: 1 },\n  { id: \"c\", label: \"C\", row: \"top\", col: 2 },\n  { id: \"1\", label: \"1\", row: \"bottom\", col: 0 },\n  { id: \"2\", label: \"2\", row: \"bottom\", col: 1 },\n  { id: \"3\", label: \"3\", row: \"bottom\", col: 2 },\n];\n\nconst COLS = 26;\nconst ROWS = 12;\nconst CELL_W = 13;\nconst CELL_H = 17;\nconst JACK_W = 3;\nconst JACK_H = 2;\nconst SLOT_GAP = 9;\nconst TOP_ROW = 1;\nconst BOTTOM_ROW = 9;\n\nfunction jackCol(col: number): number {\n  return 1 + col * SLOT_GAP;\n}\nfunction jackRow(row: \"top\" | \"bottom\"): number {\n  return row === \"top\" ? TOP_ROW : BOTTOM_ROW;\n}\n// The point just outside the jack's box in its natural cabling direction —\n// down for a top-row jack, up for a bottom-row one.\nfunction anchor(jack: PatchbayJack): [number, number] {\n  const col = jackCol(jack.col) + Math.floor(JACK_W / 2);\n  const row = jack.row === \"top\" ? jackRow(jack.row) + JACK_H : jackRow(jack.row) - 1;\n  return [col, row];\n}\n\n// Direction bits and glyph table — identical scheme to diagram-ascii-flow's\n// shared router, reimplemented here since each component folder is\n// self-contained (no shared lib file across registry entries).\nconst N = 1;\nconst E = 2;\nconst S = 4;\nconst W = 8;\nconst GLYPH: Record<number, string> = {\n  0: \" \",\n  1: \"│\",\n  2: \"─\",\n  4: \"│\",\n  8: \"─\",\n  5: \"│\",\n  10: \"─\",\n  3: \"└\",\n  9: \"┘\",\n  6: \"┌\",\n  12: \"┐\",\n  7: \"├\",\n  13: \"┤\",\n  14: \"┬\",\n  11: \"┴\",\n  15: \"┼\",\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\n// Two-point single-bend orthogonal path: straight if already aligned, else a\n// vertical-horizontal-vertical jog through the midpoint row — cables drop\n// out of the jack, jog across, then drop into the target jack.\nfunction orthogonalPath(a: [number, number], b: [number, number]): [number, number][] {\n  if (a[0] === b[0] || a[1] === b[1]) return [a, b];\n  const midY = Math.round((a[1] + b[1]) / 2);\n  return [a, [a[0], midY], [b[0], midY], b];\n}\n\nfunction flattenPath(points: [number, number][]): [number, number][] {\n  const cells: [number, number][] = [points[0]];\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    while (x !== ex || y !== ey) {\n      x += dx;\n      y += dy;\n      cells.push([x, y]);\n    }\n  }\n  return cells;\n}\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;\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 k1 = `${x},${y}`;\n      const k2 = `${nx},${ny}`;\n      grid.set(k1, (grid.get(k1) ?? 0) | out);\n      grid.set(k2, (grid.get(k2) ?? 0) | into);\n      x = nx;\n      y = ny;\n    }\n  }\n}\n\n// No JS token reads here on purpose — see diagram-ascii-flow's component.tsx\n// for the full story. Short version: this component used to hold\n// getComputedStyle(--foreground/--background/etc) in state and apply it via\n// inline `style`, and that's a real bug, not a style choice — SSR always\n// renders with no `document`, so the FIRST markup bakes in the light-theme\n// fallback hex, and React's hydration does not force a mismatched inline\n// style property to the client's value the way it does for text content. On\n// a genuinely dark-themed load, a value that never CHANGES across renders\n// (this component's own render logic recomputes the same \"correct\" dark hex\n// every time) never gets patched into the DOM, because React only writes an\n// attribute when it differs from the PREVIOUS render's value, not from\n// what's actually painted — confirmed live, reproducibly, on the sibling\n// component. Tailwind classes bound to the same custom properties sidestep\n// the whole bug class: the cascade resolves --background per theme at PAINT\n// time, no JS or hydration step involved.\n\ntype Patch = [string, string];\n\nexport function AsciiPatchbay({ jacks = DEFAULT_JACKS, className = \"\" }: AsciiPatchbayProps) {\n  const [patches, setPatches] = useState<Patch[]>([\n    [\"a\", \"2\"],\n    [\"c\", \"1\"],\n  ]);\n  const [armedId, setArmedId] = useState<string | null>(null);\n  const [livePointer, setLivePointer] = useState<[number, number] | null>(null);\n  const [hoverId, setHoverId] = useState<string | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const draggingRef = useRef(false);\n  const gestureRef = useRef<{ id: string; moved: boolean; ownArm: boolean } | null>(null);\n  const suppressClickRef = useRef(false);\n  const markerRefs = useRef<Record<number, HTMLSpanElement | null>>({});\n  const reducedRef = useRef(false);\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const sync = () => {\n      reducedRef.current = mq.matches;\n    };\n    sync();\n    mq.addEventListener(\"change\", sync);\n    return () => mq.removeEventListener(\"change\", sync);\n  }, []);\n\n  const byId = useMemo(() => new Map(jacks.map((j) => [j.id, j])), [jacks]);\n  const partnerOf = (id: string) => {\n    for (const [a, b] of patches) {\n      if (a === id) return b;\n      if (b === id) return a;\n    }\n    return null;\n  };\n\n  const removePatchFor = (id: string) => {\n    setPatches((cur) => cur.filter(([a, b]) => a !== id && b !== id));\n  };\n\n  const completePatch = (targetId: string, from: string) => {\n    setPatches((cur) => cur.filter(([a, b]) => a !== from && b !== from && a !== targetId && b !== targetId).concat([[from, targetId]]));\n    setArmedId(null);\n  };\n\n  const cellFromClientPoint = (clientX: number, clientY: number): [number, number] | null => {\n    const rect = containerRef.current?.getBoundingClientRect();\n    if (!rect) return null;\n    const x = Math.max(0, Math.min(COLS - 1, Math.round((clientX - rect.left) / CELL_W)));\n    const y = Math.max(0, Math.min(ROWS - 1, Math.round((clientY - rect.top) / CELL_H)));\n    return [x, y];\n  };\n\n  // A gesture's outcome depends on whether the pointer actually moved, not\n  // just on where it started/ended — a plain tap (down+up with no movement,\n  // exactly what a synthetic/keyboard-equivalent click produces) must never\n  // destructively unplug an existing patch; only a real drag-away does.\n  const onJackPointerDown = (jack: PatchbayJack) => (e: React.PointerEvent<HTMLButtonElement>) => {\n    if (e.button !== 0) return;\n    (e.target as HTMLElement).setPointerCapture(e.pointerId);\n    const ownArm = !armedId;\n    gestureRef.current = { id: jack.id, moved: false, ownArm };\n    draggingRef.current = true;\n    if (ownArm) {\n      setArmedId(jack.id);\n      setLivePointer(anchor(jack));\n    }\n  };\n\n  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    const g = gestureRef.current;\n    if (g && g.ownArm && !g.moved) {\n      g.moved = true;\n      // first real movement of a fresh pickup: NOW unplug, giving the\n      // dragged cable's far end live visual feedback for the rest of the\n      // gesture rather than silently vanishing at pointerdown.\n      if (partnerOf(g.id)) removePatchFor(g.id);\n    } else if (g) {\n      g.moved = true;\n    }\n    if (armedId) {\n      const cell = cellFromClientPoint(e.clientX, e.clientY);\n      if (cell) setLivePointer(cell);\n    }\n  };\n\n  const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!draggingRef.current) return;\n    draggingRef.current = false;\n    setLivePointer(null);\n    const g = gestureRef.current;\n    gestureRef.current = null;\n    suppressClickRef.current = true;\n    if (!armedId) return;\n    const target = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;\n    const targetId = target?.closest<HTMLElement>(\"[data-patchbay-jack]\")?.dataset.patchbayJack ?? null;\n    if (targetId && targetId !== armedId) {\n      completePatch(targetId, armedId);\n    } else if (!g?.moved) {\n      // a tap that never moved: stay armed, awaiting a second jack — this is\n      // the two-step click/keyboard flow's first half, not a cancel.\n      return;\n    } else {\n      // a real drag that ended on empty space (or dropped back on itself):\n      // cancel — the plug (already detached, if it had a patch) stays loose.\n      setArmedId(null);\n    }\n  };\n\n  const onJackClick = (jack: PatchbayJack) => () => {\n    // reached only via genuine keyboard activation (Enter/Space) — a\n    // pointer-driven click's trailing synthetic click is always suppressed\n    // above, since that whole gesture was already handled in onPointerUp.\n    if (suppressClickRef.current) {\n      suppressClickRef.current = false;\n      return;\n    }\n    if (!armedId) {\n      setArmedId(jack.id);\n    } else if (armedId === jack.id) {\n      setArmedId(null);\n    } else {\n      completePatch(jack.id, armedId);\n    }\n  };\n\n  const grid = useMemo(() => {\n    const g = new Map<string, number>();\n    for (const [a, b] of patches) {\n      const ja = byId.get(a);\n      const jb = byId.get(b);\n      if (!ja || !jb) continue;\n      tracePolyline(orthogonalPath(anchor(ja), anchor(jb)), g);\n    }\n    if (armedId && livePointer) {\n      const ja = byId.get(armedId);\n      if (ja) tracePolyline(orthogonalPath(anchor(ja), livePointer), g);\n    }\n    return g;\n  }, [patches, byId, armedId, livePointer]);\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++) line += GLYPH[grid.get(`${x},${y}`) ?? 0] ?? \" \";\n      out.push(line);\n    }\n    return out;\n  }, [grid]);\n\n  // Pulse: one small rAF loop, only while at least one patch exists and\n  // motion isn't reduced, moving a marker span along each patch's flattened\n  // cell path directly via refs (no per-frame React state).\n  useEffect(() => {\n    if (reducedRef.current || patches.length === 0) return;\n    const paths = patches.map(([a, b]) => {\n      const ja = byId.get(a);\n      const jb = byId.get(b);\n      return ja && jb ? flattenPath(orthogonalPath(anchor(ja), anchor(jb))) : [];\n    });\n    let raf = 0;\n    const period = 1400;\n    const start = performance.now();\n    const tick = (now: number) => {\n      const frac = ((now - start) % period) / period;\n      paths.forEach((cells, i) => {\n        const marker = markerRefs.current[i];\n        if (!marker || cells.length === 0) return;\n        const idx = Math.min(cells.length - 1, Math.floor(frac * cells.length));\n        const [cx, cy] = cells[idx];\n        marker.style.left = `${cx * CELL_W}px`;\n        marker.style.top = `${cy * CELL_H}px`;\n      });\n      raf = requestAnimationFrame(tick);\n    };\n    raf = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(raf);\n  }, [patches, byId]);\n\n  return (\n    <div className={`ns-pac font-mono ${className}`}>\n      <style>{CSS}</style>\n      <div\n        ref={containerRef}\n        className=\"ns-pac-canvas relative select-none\"\n        style={{ width: COLS * CELL_W, height: ROWS * CELL_H, maxWidth: \"100%\" }}\n        onPointerMove={onPointerMove}\n        onPointerUp={onPointerUp}\n      >\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        {patches.map(([a], i) => (\n          <span\n            key={`${a}-${i}`}\n            ref={(el) => {\n              markerRefs.current[i] = el;\n            }}\n            aria-hidden\n            className=\"ns-pac-pulse pointer-events-none absolute rounded-full bg-ns-accent\"\n            style={{ width: 6, height: 6, marginLeft: CELL_W / 2 - 3, marginTop: CELL_H / 2 - 3 }}\n          />\n        ))}\n\n        {jacks.map((jack) => {\n          const col = jackCol(jack.col);\n          const row = jackRow(jack.row);\n          const armed = armedId === jack.id;\n          const patched = !!partnerOf(jack.id);\n          const hovered = hoverId === jack.id;\n          return (\n            <button\n              key={jack.id}\n              type=\"button\"\n              data-patchbay-jack={jack.id}\n              className={`ns-pac-jack absolute flex items-center justify-center border bg-background text-[11px] transition-colors duration-150 ${\n                armed\n                  ? \"border-ns-accent text-foreground\"\n                  : hovered\n                    ? \"border-ns-accent/40 text-foreground\"\n                    : patched\n                      ? \"border-border text-foreground\"\n                      : \"border-border text-ns-muted\"\n              }`}\n              style={{\n                left: col * CELL_W,\n                top: row * CELL_H,\n                width: JACK_W * CELL_W,\n                height: JACK_H * CELL_H,\n              }}\n              aria-label={`Jack ${jack.label}${patched ? `, patched to ${byId.get(partnerOf(jack.id)!)?.label}` : \", unpatched\"}. Press Enter to ${\n                armedId && armedId !== jack.id ? \"complete the patch\" : armed ? \"cancel\" : \"arm this jack\"\n              }.`}\n              aria-pressed={armed}\n              onPointerEnter={() => setHoverId(jack.id)}\n              onPointerLeave={() => setHoverId((c) => (c === jack.id ? null : c))}\n              onPointerDown={onJackPointerDown(jack)}\n              onClick={onJackClick(jack)}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\") setArmedId(null);\n              }}\n            >\n              {jack.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        {armedId ? (\n          <span data-patchbay-armed>\n            Armed: <strong className=\"text-foreground\">{byId.get(armedId)?.label}</strong> — select another jack to\n            patch, or press Escape to cancel.\n          </span>\n        ) : (\n          <span>Drag from one jack to another to patch them, or press Enter to arm.</span>\n        )}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-pac-jack:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: 2px; }\n.ns-pac-pulse { animation: ns-pac-blink 1.4s ease-in-out infinite; }\n@media (prefers-reduced-motion: reduce) {\n  .ns-pac-pulse { display: none; }\n}\n@keyframes ns-pac-blink {\n  0%, 100% { opacity: 0.35; }\n  50% { opacity: 1; }\n}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/patchbay-ascii-cable.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": [
      "patchbay",
      "cable",
      "graph",
      "ascii",
      "box-drawing",
      "drag",
      "keyboard-navigation"
    ],
    "instruction": "Build a patchbay from a `jacks` prop (PatchbayJack[] — `{id, label, row: 'top'|'bottom', col}`), defaulting to 3 top jacks (A/B/C) and 3 bottom jacks (1/2/3) with two already-patched pairs. State is a `patches` array of `[jackId, jackId]` pairs — the real, persistent data model, not a visual-only highlight. ROUTING: each jack has a fixed `anchor` point one cell outside its box in its natural cabling direction (straight down for a top-row jack, straight up for a bottom-row one); a patch between two jacks is drawn with `orthogonalPath(anchorA, anchorB)` — a straight line if the anchors already share a row or column, otherwise a vertical-horizontal-vertical jog through the midpoint row — walked cell-by-cell into a shared direction-bit grid (`tracePolyline`, the same N/E/S/W-mask-to-box-glyph technique diagram-ascii-flow's router uses, reimplemented locally since each component folder is self-contained) so that where two patches' cables cross or run parallel, the shared cells resolve to the correct junction glyph (┼ ├ ┤ ┬ ┴) rather than one path overwriting the other. Every character is rendered as its own fixed-width span (never one flowing text string per row) so the cable grid stays pixel-aligned with the absolutely-positioned jack buttons regardless of the actual monospace glyph advance width. THE PATCH GESTURE: pointerdown on a jack starts a gesture without immediately mutating anything; only once the pointer actually MOVES does a fresh pickup (i.e. no jack was already armed) unplug that jack's existing patch, if it had one, and start drawing a live preview cable from its anchor to the current pointer cell — a plain tap that never moves must never destructively unplug, or a keyboard-equivalent click would silently break a live connection. Releasing over a different jack completes the patch (dropping any prior patch either endpoint held); releasing over empty space after a real drag cancels, leaving the plug loose; releasing back on the same jack with no movement leaves it armed, which is also exactly the state a keyboard Enter/Space produces. KEYBOARD: every jack is a real, always-focusable button with an `aria-label` stating its patched partner or 'unpatched' plus the next available action; Enter/Space on an unarmed jack arms it (a `data-patchbay-armed` readout names it beneath the canvas), Enter/Space on a second, different jack completes the patch exactly like a drag-drop would, Enter/Space on the already-armed jack or Escape cancels. PULSE: once a patch exists, a single rAF loop (skipped entirely under `prefers-reduced-motion: reduce`, whose cable still renders, just motionless) walks a small `--ns-accent` marker span along each patch's flattened cell path on a 1.4s loop, writing its position straight to the DOM via a ref — no per-frame React state. Hover and keyboard focus both shift a jack's border toward `--ns-accent`; an armed jack's border is a persistent `--ns-accent`; a patched-but-idle jack's label is `--foreground` rather than `--ns-muted`. 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. Zero dependencies, pure DOM + CSS."
  },
  "type": "registry:ui"
}