{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-fm-routing-matrix",
  "type": "registry:ui",
  "title": "FM Routing Matrix",
  "description": "A controlled, keyboard-accessible editor for directed operator modulation, self-feedback and independent audio outputs. Stable operator IDs and configurable ranges let instruments persist their own routing state.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/fm-routing-matrix.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/fm-routing-matrix\n * Copyright © [Maru](https://maru.audio)\n *\n * Maru UI Kit — https://maru.audio/ui-kit\n *\n * Installed from the Maru UI registry. This file is yours now: edit it\n * freely, it will not be overwritten unless you re-run the add command.\n *\n * Keep this item’s companion files and CSS imports together. Dependencies\n * declared by the registry are installed with the source.\n */\nimport { useId, useRef, useState } from \"react\"\nimport type { CSSProperties, KeyboardEvent } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  fmRoutingTargetKey,\n  getFmRoutingAmount,\n  resolveFmRoutingRange,\n  setFmRoutingAmount,\n} from \"maruaudio/ui/shared/fm-routing-model\"\nimport type {\n  FmRoutingAmountRange,\n  FmRoutingAvailability,\n  FmRoutingChange,\n  FmRoutingOperator,\n  FmRoutingTarget,\n  FmRoutingValue,\n} from \"maruaudio/ui/shared/fm-routing-model\"\n\nexport type {\n  FmRoutingAmountRange,\n  FmRoutingAvailability,\n  FmRoutingChange,\n  FmRoutingOperator,\n  FmRoutingTarget,\n  FmRoutingValue,\n} from \"maruaudio/ui/shared/fm-routing-model\"\n\nexport interface FmRoutingMatrixProps extends FmRoutingValue {\n  /** Stable IDs allow any operator count and separate layer instances. */\n  operators: readonly FmRoutingOperator[]\n  /** Commit to the module's parameter/state owner; this control owns no patch. */\n  onChange: (next: FmRoutingValue, change: FmRoutingChange) => void\n  onChangeStart?: (target: FmRoutingTarget) => void\n  onChangeEnd?: (next: FmRoutingValue, change: FmRoutingChange) => void\n  /** Called synchronously inside the editing gesture, for host audio unlock. */\n  onInteraction?: () => void\n  amountRange?: FmRoutingAmountRange\n  outputRange?: FmRoutingAmountRange\n  /** All routes, including diagonal feedback and cycles, are enabled by default. */\n  routePolicy?: (sourceId: string, targetId: string) => FmRoutingAvailability\n  outputPolicy?: (operatorId: string) => FmRoutingAvailability\n  disabled?: boolean\n  outputLabel?: string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/**\n * A controlled FM input controller. Rows are sources, columns are destinations;\n * the extra output column controls the audio mix. Enter/Space toggles a cell,\n * arrows move focus, and the inspector below edits its exact signed amount.\n * DSP timing, feedback safety and persistence always belong to the instrument.\n */\nexport function FmRoutingMatrix({\n  operators,\n  routes,\n  outputs,\n  onChange,\n  onChangeStart,\n  onChangeEnd,\n  onInteraction,\n  amountRange,\n  outputRange,\n  routePolicy,\n  outputPolicy,\n  disabled = false,\n  outputLabel = \"Output\",\n  className,\n  style,\n  \"aria-label\": ariaLabel = \"FM routing matrix\",\n}: FmRoutingMatrixProps) {\n  const helpId = useId()\n  const [selectedKey, setSelectedKey] = useState<string | null>(null)\n  const buttons = useRef(new Map<string, HTMLButtonElement>())\n  const routeRange = resolveFmRoutingRange(amountRange)\n  const mixRange = resolveFmRoutingRange(outputRange)\n  const value = { routes, outputs }\n  const ids = new Set(operators.map((operator) => operator.id))\n  if (\n    ids.size !== operators.length ||\n    operators.some((operator) => !operator.id)\n  ) {\n    throw new Error(\"FM routing operator IDs must be unique and nonempty.\")\n  }\n\n  const rows = operators.map((source) => [\n    ...operators.map((target) => ({\n      target: {\n        kind: \"route\",\n        sourceId: source.id,\n        targetId: target.id,\n      } as FmRoutingTarget,\n      label: `${source.label} modulates ${target.label}${source.id === target.id ? \" (self feedback)\" : \"\"}`,\n      range: routeRange,\n      availability: routePolicy?.(source.id, target.id),\n      color:\n        source.id === target.id\n          ? token.accent2\n          : (source.color ?? token.accent),\n    })),\n    {\n      target: { kind: \"output\", operatorId: source.id } as FmRoutingTarget,\n      label: `${source.label} to ${outputLabel}`,\n      range: mixRange,\n      availability: outputPolicy?.(source.id),\n      color: source.color ?? token.accent,\n    },\n  ])\n  const selected =\n    rows\n      .flat()\n      .find((cell) => fmRoutingTargetKey(cell.target) === selectedKey) ??\n    rows[0]?.[0]\n  const selectedTargetKey = selected\n    ? fmRoutingTargetKey(selected.target)\n    : null\n\n  function edit(target: FmRoutingTarget, amount: number) {\n    const availability = target.kind === \"route\"\n      ? routePolicy?.(target.sourceId, target.targetId)\n      : outputPolicy?.(target.operatorId)\n    if (disabled || availability?.enabled === false) return\n    const previousAmount = getFmRoutingAmount(value, target)\n    if (amount === previousAmount) return\n    const next = setFmRoutingAmount(value, target, amount)\n    const change: FmRoutingChange = { ...target, previousAmount, amount }\n    onInteraction?.()\n    onChangeStart?.(target)\n    onChange(next, change)\n    onChangeEnd?.(next, change)\n  }\n\n  function navigate(\n    event: KeyboardEvent<HTMLButtonElement>,\n    row: number,\n    column: number\n  ) {\n    let nextRow = row\n    let nextColumn = column\n    switch (event.key) {\n      case \"ArrowUp\":\n        nextRow = Math.max(0, row - 1)\n        break\n      case \"ArrowDown\":\n        nextRow = Math.min(rows.length - 1, row + 1)\n        break\n      case \"ArrowLeft\":\n        nextColumn = Math.max(0, column - 1)\n        break\n      case \"ArrowRight\":\n        nextColumn = Math.min(operators.length, column + 1)\n        break\n      case \"Home\":\n        nextColumn = 0\n        if (event.ctrlKey) nextRow = 0\n        break\n      case \"End\":\n        nextColumn = operators.length\n        if (event.ctrlKey) nextRow = rows.length - 1\n        break\n      default:\n        return\n    }\n    event.preventDefault()\n    const target = rows[nextRow]?.[nextColumn]?.target\n    if (target) buttons.current.get(fmRoutingTargetKey(target))?.focus()\n  }\n\n  return (\n    <section\n      className={className}\n      aria-label={ariaLabel}\n      style={{ color: token.text, fontFamily: token.font, ...style }}\n    >\n      <p\n        id={helpId}\n        style={{ margin: \"0 0 8px\", color: token.textMuted, fontSize: 12 }}\n      >\n        Rows send modulation to columns. Select a cell to toggle it; use arrow\n        keys to move and Amount to edit. The diagonal is self feedback.\n      </p>\n      <div style={{ overflowX: \"auto\", padding: 3 }}>\n        <table\n          aria-label={ariaLabel}\n          aria-describedby={helpId}\n          style={{ width: \"100%\", borderSpacing: 4, fontSize: 12 }}\n        >\n          <thead>\n            <tr>\n              <th\n                scope=\"col\"\n                style={{\n                  textAlign: \"left\",\n                  color: token.textMuted,\n                  fontWeight: 500,\n                }}\n              >\n                From ↓ / To →\n              </th>\n              {operators.map((operator) => (\n                <th\n                  key={operator.id}\n                  scope=\"col\"\n                  style={{ color: operator.color ?? token.text }}\n                >\n                  {operator.label}\n                </th>\n              ))}\n              <th scope=\"col\">{outputLabel}</th>\n            </tr>\n          </thead>\n          <tbody>\n            {rows.map((cells, row) => (\n              <tr key={operators[row].id}>\n                <th\n                  scope=\"row\"\n                  style={{\n                    textAlign: \"left\",\n                    whiteSpace: \"nowrap\",\n                    color: operators[row].color ?? token.text,\n                  }}\n                >\n                  {operators[row].label}\n                </th>\n                {cells.map((cell, column) => {\n                  const key = fmRoutingTargetKey(cell.target)\n                  const amount = getFmRoutingAmount(value, cell.target)\n                  const active = amount !== 0\n                  const blocked =\n                    disabled || cell.availability?.enabled === false\n                  const selectedCell = key === selectedTargetKey\n                  const reason = cell.availability?.reason\n                  return (\n                    <td key={key} style={{ padding: 0 }}>\n                      <button\n                        ref={(element) => {\n                          if (element) buttons.current.set(key, element)\n                          else buttons.current.delete(key)\n                        }}\n                        type=\"button\"\n                        tabIndex={selectedCell ? 0 : -1}\n                        aria-label={`${cell.label}: ${amount}${blocked && reason ? `. ${reason}` : \"\"}`}\n                        aria-pressed={active}\n                        aria-disabled={blocked}\n                        data-routing-source={\n                          cell.target.kind === \"route\"\n                            ? cell.target.sourceId\n                            : cell.target.operatorId\n                        }\n                        data-routing-target={\n                          cell.target.kind === \"route\"\n                            ? cell.target.targetId\n                            : \"output\"\n                        }\n                        onFocus={() => setSelectedKey(key)}\n                        onKeyDown={(event) => navigate(event, row, column)}\n                        onClick={() => {\n                          setSelectedKey(key)\n                          if (!blocked)\n                            edit(\n                              cell.target,\n                              active ? 0 : cell.range.defaultAmount\n                            )\n                        }}\n                        style={{\n                          width: \"100%\",\n                          minWidth: 40,\n                          minHeight: 36,\n                          borderRadius: token.radius,\n                          border: `1px solid ${selectedCell ? token.focus : token.border}`,\n                          boxShadow: selectedCell\n                            ? `inset 0 0 0 1px ${token.focus}`\n                            : undefined,\n                          background: active\n                            ? `color-mix(in srgb, ${cell.color} 22%, ${token.surface})`\n                            : token.surface,\n                          color: active ? token.text : token.textMuted,\n                          fontFamily: token.fontMono,\n                          fontSize: 12,\n                          cursor: blocked ? \"not-allowed\" : \"pointer\",\n                          opacity: blocked ? 0.55 : 1,\n                          outlineColor: token.focus,\n                          outlineOffset: 2,\n                        }}\n                      >\n                        {active ? Number(amount.toFixed(3)) : \"—\"}\n                      </button>\n                    </td>\n                  )\n                })}\n              </tr>\n            ))}\n          </tbody>\n        </table>\n      </div>\n      {selected ? (\n        <div\n          style={{\n            marginTop: 8,\n            display: \"flex\",\n            flexWrap: \"wrap\",\n            alignItems: \"center\",\n            gap: 10,\n            fontSize: 12,\n          }}\n        >\n          <span>{selected.label}</span>\n          <RoutingAmountInput\n            key={`${selectedTargetKey}:${getFmRoutingAmount(value, selected.target)}`}\n            amount={getFmRoutingAmount(value, selected.target)}\n            range={selected.range}\n            label={`${selected.label} amount`}\n            disabled={disabled || selected.availability?.enabled === false}\n            onInteraction={onInteraction}\n            onCommit={(amount) => edit(selected.target, amount)}\n          />\n          {selected.availability?.reason ? (\n            <span style={{ color: token.textMuted }}>\n              {selected.availability.reason}\n            </span>\n          ) : null}\n        </div>\n      ) : (\n        <p style={{ color: token.textMuted, fontSize: 12 }}>\n          No operators available.\n        </p>\n      )}\n    </section>\n  )\n}\n\n/** Local text is an uncommitted edit; only blur/Enter sends a routing change. */\nfunction RoutingAmountInput({\n  amount,\n  range,\n  label,\n  disabled,\n  onInteraction,\n  onCommit,\n}: {\n  amount: number\n  range: Required<FmRoutingAmountRange>\n  label: string\n  disabled: boolean\n  onInteraction?: () => void\n  onCommit: (amount: number) => void\n}) {\n  const [draft, setDraft] = useState(String(amount))\n  const cancelled = useRef(false)\n  return (\n    <label style={{ display: \"inline-flex\", alignItems: \"center\", gap: 6 }}>\n      Amount\n      <input\n        type=\"number\"\n        aria-label={label}\n        value={draft}\n        min={range.min}\n        max={range.max}\n        step={range.step}\n        disabled={disabled}\n        onFocus={() => {\n          cancelled.current = false\n          onInteraction?.()\n        }}\n        onChange={(event) => setDraft(event.currentTarget.value)}\n        onKeyDown={(event) => {\n          if (event.key === \"Escape\") {\n            event.preventDefault()\n            cancelled.current = true\n            setDraft(String(amount))\n            event.currentTarget.blur()\n          } else if (event.key === \"Enter\") {\n            event.preventDefault()\n            event.currentTarget.blur()\n          }\n        }}\n        onBlur={() => {\n          if (\n            cancelled.current ||\n            !draft.trim() ||\n            !Number.isFinite(Number(draft))\n          ) {\n            setDraft(String(amount))\n            return\n          }\n          const next = Math.max(range.min, Math.min(range.max, Number(draft)))\n          setDraft(String(next))\n          onCommit(next)\n        }}\n        style={{\n          width: 76,\n          padding: \"6px 8px\",\n          border: `1px solid ${token.border}`,\n          borderRadius: token.radius,\n          background: token.surface,\n          color: token.text,\n          fontFamily: token.fontMono,\n          outlineColor: token.focus,\n        }}\n      />\n    </label>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/fm-routing-matrix.tsx"
    }
  ]
}
