{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-cc-binding-editor",
  "type": "registry:ui",
  "title": "CC Binding Editor",
  "description": "Portable control-level MIDI CC menu and binding editor: assign a controller number and channel by right-clicking a control, then edit range, response curve, learn, or remove. The existing badge and drag-drop source strip remain optional paths through the same `midi` callbacks.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/cc-binding-editor.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/cc-binding-editor\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 */\n/**\n * cc-binding-editor — the rest of a MIDI binding, edited from its badge.\n *\n * Dragging a chip onto a knob makes a binding out of the two things the gesture\n * can express: a controller and a control. Everything else a binding can say —\n * which channel it listens on, how far up the parameter it sweeps, whether it\n * runs backwards, and what taper it takes on the way — has no gesture, and\n * without a panel it can only be written by hand into a preset file. This is\n * that panel.\n *\n * It is deliberately opened from the badge rather than from a settings drawer.\n * The badge is already the thing that says \"this control is mapped\", it is\n * already on screen next to the control, and it already had a clear button —\n * so making it the door to the binding costs no new furniture, and the clear\n * button moves inside where it stops competing for nine pixels of tag.\n *\n * Three rules the implementation follows and a reader should be able to check:\n *\n *   - **No layout shift.** The popover is absolutely positioned out of flow.\n *     Opening an editor on one knob of a dense panel must not move the others.\n *   - **Nothing is invented.** The response preview is drawn from `ccCurveAt`\n *     and `ccBindingValue` in `lib/cc-dnd` — the same expressions the engine\n *     resolves a controller message with — so the shape you pick is the shape\n *     you were shown, including an inverted `min > max` sweep.\n *   - **Every field is clearable.** An empty range box does not mean zero, it\n *     means \"the parameter's own bound\", and it is sent as a patch key present\n *     with `undefined` (see `CcBindingPatch`). A binding you have opened and\n *     closed is byte-identical to one you never touched.\n *\n * Learn lives here too, because \"which controller *is* this pedal\" is a\n * question you ask while already looking at the binding. The button only asks\n * the host to listen; the listening state, its timeout and its cancellation are\n * `createCcLearn`'s, in `lib/cc-dnd`.\n */\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\"\nimport { createPortal } from \"react-dom\"\nimport type {\n  ButtonHTMLAttributes,\n  CSSProperties,\n  HTMLAttributes,\n  ReactNode,\n} from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  CcBadge,\n  ccBadgeSlot,\n  CC_BADGE_ATTR,\n  CC_CURVE_NAMES,\n  CC_LEARN_KEEP_ATTR,\n  ccBindingRange,\n  ccBindingValue,\n  ccDescription,\n  ccLabel,\n  ccRoleShortLabel,\n  useCcBlink,\n  type CcBadgeSlotProps,\n  type CcBindingDetail,\n  type CcBindingPatch,\n  type CcCurveName,\n  type CcParamRange,\n  type CcTakeoverModeName,\n  type CcValueModeName,\n  type CcDropTargetBinding,\n  type ControlMidiProps,\n} from \"maruaudio/ui/shared/cc-dnd\"\nimport {\n  contextMenuPortalTheme,\n  ContextMenuTarget,\n  type ContextMenuAction,\n} from \"maruaudio/ui/shared/context-menu-target\"\n\n/** The parameter range assumed when the host does not describe one. */\nconst UNIT_RANGE: CcParamRange = { min: 0, max: 1 }\n\nconst CURVE_LABELS: Record<CcCurveName, string> = {\n  linear: \"Linear\",\n  exp: \"Exp\",\n  log: \"Log\",\n  \"s-curve\": \"S\",\n}\n\nconst VALUE_MODE_LABELS: Record<CcValueModeName, string> = {\n  absolute: \"Absolute\",\n  \"relative-signed-bit\": \"Relative: signed bit\",\n  \"relative-binary-offset\": \"Relative: binary offset\",\n  \"relative-twos-complement\": \"Relative: two's complement\",\n  toggle: \"Toggle\",\n}\n\nconst TAKEOVER_LABELS: Record<CcTakeoverModeName, string> = {\n  jump: \"Jump\",\n  pickup: \"Pickup\",\n  scaled: \"Scaled\",\n}\n\nexport interface CcBindingEditorProps extends CcBadgeSlotProps {\n  /** Close the panel: Escape, a press outside, or the Done button. */\n  onClose?: () => void\n  /** Heading, when the host has a better name than the controller's. */\n  title?: string\n  style?: CSSProperties\n}\n\n/**\n * The panel itself, without a trigger.\n *\n * Separate from `CcBindingBadge` so a host with its own disclosure — a plugin\n * whose mapping list opens one of these in a sidebar — can render it directly\n * and keep its own open state.\n */\nexport function CcBindingEditor({\n  assignment,\n  binding,\n  range = UNIT_RANGE,\n  onBindingChange,\n  onClear,\n  onLearn,\n  learnUnavailableReason,\n  learning = false,\n  routingOnly = false,\n  onClose,\n  title,\n  style,\n}: CcBindingEditorProps) {\n  const headingId = useId()\n  const ccInputRef = useRef<HTMLInputElement | null>(null)\n  const blink = useCcBlink(learning)\n\n  // Focus lands on the controller number, which is both the first field and\n  // the one most likely to be wrong on a binding you just came to fix.\n  useEffect(() => {\n    ccInputRef.current?.focus()\n    ccInputRef.current?.select()\n  }, [])\n\n  const patch = (next: CcBindingPatch) => onBindingChange?.(next)\n\n  const bounds = ccBindingRange(binding, range)\n  const inverted = bounds.min > bounds.max\n\n  return (\n    <div\n      role=\"dialog\"\n      aria-labelledby={headingId}\n      onKeyDown={(event) => {\n        if (event.key !== \"Escape\") return\n        event.stopPropagation()\n        onClose?.()\n      }}\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: 8,\n        width: 272,\n        padding: 10,\n        borderRadius: token.radius,\n        background: token.surface,\n        border: `1px solid ${learning && blink ? token.accent : token.border}`,\n        boxShadow: \"0 14px 34px rgba(0,0,0,0.5)\",\n        color: token.text,\n        fontFamily: token.font,\n        fontSize: 11,\n        textAlign: \"left\",\n        transition: \"border-color 260ms ease-out\",\n        ...style,\n      }}\n    >\n      <div style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n        <span\n          id={headingId}\n          style={{\n            fontSize: 10,\n            fontWeight: 700,\n            letterSpacing: \"0.08em\",\n            textTransform: \"uppercase\",\n            color: token.textMuted,\n          }}\n        >\n          {title ?? `${ccLabel(assignment)} binding`}\n        </span>\n        {onClose && (\n          <button\n            type=\"button\"\n            className=\"uikit-control\"\n            aria-label=\"Close binding editor\"\n            onClick={onClose}\n            style={{\n              marginLeft: \"auto\",\n              appearance: \"none\",\n              border: \"none\",\n              background: \"transparent\",\n              color: token.textMuted,\n              cursor: \"pointer\",\n              font: \"inherit\",\n              fontSize: 12,\n              lineHeight: 1,\n              padding: 2,\n            }}\n          >\n            ×\n          </button>\n        )}\n      </div>\n\n      {routingOnly && (\n        <p style={{ margin: 0, color: token.textMuted, lineHeight: 1.4 }}>\n          This VST3 host resolves MIDI CC through IMidiMapping. In-face Learn,\n          source filtering, relative modes, takeover, custom ranges, curves and\n          fan-out are unavailable here. Controller number and channel are\n          supported here; the DAW applies them through its MIDI mapping.\n        </p>\n      )}\n\n      <div style={{ display: \"flex\", gap: 6 }}>\n        <Field label=\"CC\">\n          <NumberBox\n            ref={ccInputRef}\n            ariaLabel=\"Controller number, 0 to 127\"\n            value={assignment.cc}\n            min={0}\n            max={127}\n            step={1}\n            onCommit={(value) => {\n              if (value === null) return\n              patch({ cc: Math.min(127, Math.max(0, Math.round(value))) })\n            }}\n          />\n        </Field>\n        <Field label=\"Channel\">\n          <select\n            className=\"uikit-control\"\n            aria-label=\"MIDI channel\"\n            value={binding?.channel ?? \"\"}\n            onChange={(event) =>\n              patch({\n                channel:\n                  event.target.value === \"\"\n                    ? undefined\n                    : Number(event.target.value),\n              })\n            }\n            style={{\n              ...boxStyle,\n              width: \"100%\",\n              // A select with a native arrow is the one control here worth\n              // leaving alone: every platform draws its own and they are all\n              // more legible than a redrawn one at 10px.\n              appearance: \"auto\",\n            }}\n          >\n            <option value=\"\">Omni</option>\n            {Array.from({ length: 16 }, (_, index) => index + 1).map(\n              (channel) => (\n                <option key={channel} value={channel}>\n                  {channel}\n                </option>\n              )\n            )}\n          </select>\n        </Field>\n      </div>\n\n      {assignment.sourceLabel ? (\n        <p style={{ margin: 0, color: token.textMuted, fontSize: 9 }}>\n          Input: {assignment.sourceLabel}\n        </p>\n      ) : null}\n\n      <div style={{ display: \"flex\", gap: 6 }}>\n        <Field label=\"Input mode\">\n          <select\n            className=\"uikit-control\"\n            aria-label=\"Controller value mode\"\n            value={binding?.valueMode ?? \"absolute\"}\n            disabled={routingOnly}\n            onChange={(event) => {\n              const value = event.target.value as CcValueModeName\n              patch({ valueMode: value === \"absolute\" ? undefined : value })\n            }}\n            style={{ ...boxStyle, width: \"100%\", appearance: \"auto\" }}\n          >\n            {Object.entries(VALUE_MODE_LABELS).map(([value, label]) => (\n              <option key={value} value={value}>\n                {label}\n              </option>\n            ))}\n          </select>\n        </Field>\n        <Field label=\"Takeover\">\n          <select\n            className=\"uikit-control\"\n            aria-label=\"Absolute controller takeover mode\"\n            value={binding?.takeover ?? \"jump\"}\n            disabled={\n              routingOnly || (binding?.valueMode ?? \"absolute\") !== \"absolute\"\n            }\n            onChange={(event) => {\n              const value = event.target.value as CcTakeoverModeName\n              patch({ takeover: value === \"jump\" ? undefined : value })\n            }}\n            style={{ ...boxStyle, width: \"100%\", appearance: \"auto\" }}\n          >\n            {Object.entries(TAKEOVER_LABELS).map(([value, label]) => (\n              <option key={value} value={value}>\n                {label}\n              </option>\n            ))}\n          </select>\n        </Field>\n      </div>\n\n      <div style={{ display: \"flex\", alignItems: \"flex-end\", gap: 6 }}>\n        <Field label={`Min${range.unit ? ` (${range.unit})` : \"\"}`}>\n          <NumberBox\n            ariaLabel=\"Range floor, in the parameter's own units. Empty is the parameter's minimum.\"\n            value={binding?.min}\n            disabled={routingOnly}\n            placeholder={String(round(range.min))}\n            {...(range.step !== undefined ? { step: range.step } : {})}\n            onCommit={(value) => patch({ min: value ?? undefined })}\n          />\n        </Field>\n        <Field label={`Max${range.unit ? ` (${range.unit})` : \"\"}`}>\n          <NumberBox\n            ariaLabel=\"Range ceiling. Below the floor inverts the sweep. Empty is the parameter's maximum.\"\n            value={binding?.max}\n            disabled={routingOnly}\n            placeholder={String(round(range.max))}\n            {...(range.step !== undefined ? { step: range.step } : {})}\n            onCommit={(value) => patch({ max: value ?? undefined })}\n          />\n        </Field>\n        <button\n          type=\"button\"\n          className=\"uikit-control\"\n          aria-label=\"Invert the sweep by swapping the range ends\"\n          aria-pressed={inverted}\n          disabled={routingOnly}\n          onClick={() => patch({ min: bounds.max, max: bounds.min })}\n          style={{\n            ...boxStyle,\n            width: 26,\n            flex: \"none\",\n            cursor: \"pointer\",\n            color: inverted ? token.accent : token.textMuted,\n            borderColor: inverted ? token.accent : token.border,\n          }}\n        >\n          ⇄\n        </button>\n      </div>\n\n      <div style={{ display: \"flex\", gap: 8, alignItems: \"center\" }}>\n        <div\n          role=\"radiogroup\"\n          aria-label=\"Response curve\"\n          style={{ display: \"flex\", flexWrap: \"wrap\", gap: 3, flex: 1 }}\n        >\n          {CC_CURVE_NAMES.map((curve) => {\n            const selected = (binding?.curve ?? \"linear\") === curve\n            return (\n              <button\n                key={curve}\n                type=\"button\"\n                role=\"radio\"\n                aria-checked={selected}\n                className=\"uikit-control\"\n                disabled={routingOnly}\n                onClick={() =>\n                  patch({ curve: curve === \"linear\" ? undefined : curve })\n                }\n                style={{\n                  ...boxStyle,\n                  width: \"auto\",\n                  padding: \"2px 6px\",\n                  cursor: \"pointer\",\n                  color: selected ? token.accent : token.textMuted,\n                  borderColor: selected ? token.accent : token.border,\n                  background: selected\n                    ? `color-mix(in srgb, ${token.accent} 16%, transparent)`\n                    : \"transparent\",\n                }}\n              >\n                {CURVE_LABELS[curve]}\n              </button>\n            )\n          })}\n        </div>\n        <CcCurvePreview binding={binding} range={range} />\n      </div>\n\n      <div style={{ display: \"flex\", gap: 6 }}>\n        <button\n          type=\"button\"\n          className=\"uikit-control\"\n          onClick={() =>\n            patch({ enabled: binding?.enabled === false ? undefined : false })\n          }\n          aria-pressed={binding?.enabled !== false}\n          style={{\n            ...boxStyle,\n            flex: 1,\n            width: \"auto\",\n            cursor: \"pointer\",\n            color: binding?.enabled === false ? token.textMuted : token.accent,\n          }}\n        >\n          {binding?.enabled === false ? \"Enable\" : \"Enabled\"}\n        </button>\n        {onLearn && (\n          <button\n            type=\"button\"\n            className=\"uikit-control\"\n            // Pressing this must not read as a click-away: it is the control\n            // that starts and stops the listen. See CC_LEARN_KEEP_ATTR.\n            {...{ [CC_LEARN_KEEP_ATTR]: \"\" }}\n            onClick={onLearn}\n            aria-pressed={learning}\n            style={{\n              ...boxStyle,\n              flex: 1,\n              width: \"auto\",\n              cursor: \"pointer\",\n              color: learning ? token.accent : token.text,\n              borderColor: learning && blink ? token.accent : token.border,\n              background: learning\n                ? `color-mix(in srgb, ${token.accent} 18%, transparent)`\n                : \"transparent\",\n              transition: \"border-color 260ms ease-out\",\n            }}\n          >\n            {learning ? \"Listening…\" : \"Learn\"}\n          </button>\n        )}\n        {(learnUnavailableReason || (routingOnly && !onLearn)) && (\n          <button\n            type=\"button\"\n            className=\"uikit-control\"\n            disabled\n            aria-description={learnUnavailableReason}\n            style={{\n              ...boxStyle,\n              flex: 1,\n              width: \"auto\",\n              cursor: \"not-allowed\",\n              color: token.textMuted,\n            }}\n          >\n            Learn unavailable\n          </button>\n        )}\n        {binding?.sourceId && onBindingChange ? (\n          <button\n            type=\"button\"\n            className=\"uikit-control\"\n            onClick={() => patch({ sourceId: undefined })}\n            style={{ ...boxStyle, flex: 1, width: \"auto\", cursor: \"pointer\" }}\n          >\n            Any device\n          </button>\n        ) : null}\n        {onClear && (\n          <button\n            type=\"button\"\n            className=\"uikit-control\"\n            onClick={() => {\n              onClear()\n              onClose?.()\n            }}\n            aria-label={`Remove the ${ccDescription(assignment)} binding`}\n            style={{\n              ...boxStyle,\n              flex: 1,\n              width: \"auto\",\n              cursor: \"pointer\",\n              color: token.textMuted,\n            }}\n          >\n            Remove\n          </button>\n        )}\n      </div>\n\n      <p\n        style={{\n          margin: 0,\n          color: token.textMuted,\n          fontSize: 9,\n          lineHeight: 1.45,\n        }}\n      >\n        {learning\n          ? \"Move a control. Esc cancels.\"\n          : learnUnavailableReason\n            ? learnUnavailableReason\n            : \"Range uses parameter units; blank uses the full range.\"}\n      </p>\n    </div>\n  )\n}\n\nexport interface CcCurvePreviewProps {\n  binding?: CcBindingDetail\n  range?: CcParamRange\n  width?: number\n  height?: number\n}\n\n/**\n * The binding's response, plotted.\n *\n * Controller travel runs left to right, parameter value bottom to top, and the\n * line is `ccBindingValue` sampled — so an inverted range visibly falls and an\n * exp taper visibly hugs the floor. The dashed diagonal is what a plain linear\n * full-range binding would do, which is what makes \"this one is shaped\" legible\n * at 44 pixels.\n */\nexport function CcCurvePreview({\n  binding,\n  range = UNIT_RANGE,\n  width = 44,\n  height = 34,\n}: CcCurvePreviewProps) {\n  const span = range.max - range.min\n  const points: string[] = []\n  const steps = 24\n  for (let index = 0; index <= steps; index += 1) {\n    const t = index / steps\n    const value = ccBindingValue(t * 127, binding, range)\n    const normalized = span === 0 ? 0 : (value - range.min) / span\n    const x = t * (width - 2) + 1\n    const y = height - 1 - Math.min(1, Math.max(0, normalized)) * (height - 2)\n    points.push(`${x.toFixed(2)},${y.toFixed(2)}`)\n  }\n\n  return (\n    <svg\n      width={width}\n      height={height}\n      viewBox={`0 0 ${width} ${height}`}\n      aria-hidden\n      style={{\n        flex: \"none\",\n        borderRadius: 3,\n        border: `1px solid ${token.border}`,\n        background: `color-mix(in srgb, ${token.accent} 6%, transparent)`,\n      }}\n    >\n      <line\n        x1={1}\n        y1={height - 1}\n        x2={width - 1}\n        y2={1}\n        stroke={token.border}\n        strokeWidth={1}\n        strokeDasharray=\"2 2\"\n      />\n      <polyline\n        points={points.join(\" \")}\n        fill=\"none\"\n        stroke={token.accent}\n        strokeWidth={1.5}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  )\n}\n\nexport interface CcBindingBadgeProps extends CcBadgeSlotProps {\n  /** Heading override, forwarded to the editor. */\n  title?: string\n}\n\n/**\n * The badge, with the editor behind it.\n *\n * This is what a host passes to `ControlMidiProps.renderBadge`, which is why\n * its props are exactly `CcBadgeSlotProps`: a control builds that slot from the\n * one `midi` group it already takes, so wiring the editor into a whole panel is\n * a single `renderBadge={(slot) => <CcBindingBadge {...slot} />}`.\n *\n * The popover closes on Escape and on a press anywhere outside it, and hands\n * focus back to the badge on the way out — a disclosure that drops focus to the\n * document body strands a keyboard user in the middle of a panel.\n */\nexport function CcBindingBadge({ title, ...slot }: CcBindingBadgeProps) {\n  const [open, setOpen] = useState(false)\n  const rootRef = useRef<HTMLSpanElement | null>(null)\n  const editable = Boolean(slot.onBindingChange || slot.onLearn)\n\n  const close = () => {\n    setOpen(false)\n    rootRef.current\n      ?.querySelector<HTMLButtonElement>(\"button[aria-haspopup='dialog']\")\n      ?.focus()\n  }\n\n  // Press-outside and Escape. Bound on the document rather than on the panel so\n  // a press on the *control* — the far more likely miss than a press on empty\n  // page — also closes it.\n  useEffect(() => {\n    if (!open || typeof window === \"undefined\") return\n    const onDown = (event: PointerEvent) => {\n      const node = event.target as Node | null\n      if (node && rootRef.current?.contains(node)) return\n      setOpen(false)\n    }\n    const onKey = (event: KeyboardEvent) => {\n      if (event.key === \"Escape\") close()\n    }\n    window.addEventListener(\"pointerdown\", onDown)\n    window.addEventListener(\"keydown\", onKey)\n    return () => {\n      window.removeEventListener(\"pointerdown\", onDown)\n      window.removeEventListener(\"keydown\", onKey)\n    }\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [open])\n\n  // A learn started from the editor keeps the panel open — you are watching the\n  // binding change — but a learn that *ends* leaves it exactly as it was.\n  const badge = (\n    <CcBadge\n      assignment={slot.assignment}\n      active={slot.active}\n      {...(slot.learning !== undefined ? { learning: slot.learning } : {})}\n      {...(slot.binding ? { binding: slot.binding } : {})}\n      {...(slot.range ? { range: slot.range } : {})}\n      movesAtomically={slot.movesAtomically}\n      {...(slot.controller ? { controller: slot.controller } : {})}\n      {...(slot.targetId ? { sourceTargetId: slot.targetId } : {})}\n      {...(slot.onClear ? { onMove: slot.onClear } : {})}\n      {...(slot.designer ? { designer: true } : {})}\n      {...(editable\n        ? {\n            onOpen: () => setOpen((prev) => !prev),\n            // Remove lives in the editor; map mode adds × on the badge.\n            ...(slot.onClear ? { onForget: slot.onClear } : {}),\n          }\n        : slot.onClear\n          ? { onClear: slot.onClear }\n          : {})}\n    />\n  )\n\n  if (!editable) return badge\n\n  return (\n    <span\n      ref={rootRef}\n      {...{ [CC_BADGE_ATTR]: \"editor\" }}\n      style={{ position: \"relative\", display: \"inline-flex\" }}\n    >\n      {badge}\n      {open && (\n        <span\n          style={{\n            position: \"absolute\",\n            top: \"calc(100% + 4px)\",\n            right: 0,\n            zIndex: 40,\n            // Out of flow entirely: the control underneath keeps its geometry,\n            // and the popover does not inherit the badge's 9px type.\n            display: \"block\",\n          }}\n        >\n          <CcBindingEditor\n            {...slot}\n            {...(title ? { title } : {})}\n            onClose={close}\n          />\n        </span>\n      )}\n    </span>\n  )\n}\n\n/**\n * The control's current mapping in a few words: \"MW (CC 1)\", \"CC 74\",\n * \"CC 74 · ch 2\", or \"not mapped\". The badge's own vocabulary, so the menu\n * header and the badge never disagree.\n */\nexport function ccControlMappingSummary(\n  assignment: ControlMidiProps[\"assignment\"]\n): string {\n  if (!assignment) return \"not mapped\"\n  const channel =\n    assignment.channel !== undefined ? ` · ch ${assignment.channel}` : \"\"\n  return assignment.role\n    ? `${ccRoleShortLabel(assignment.role)} (CC ${assignment.cc})${channel}`\n    : `CC ${assignment.cc}${channel}`\n}\n\n/**\n * A control's right-click \"MIDI CC\" submenu. Menu actions are structural; the\n * host's existing callbacks own all writes.\n *\n * Deliberately short (Felix, 2026-10-26): a header naming the control and its\n * mapping (\"Width — MW (CC 1)\"), then the actions for THIS control —\n * mapped: Learn new CC, Edit range…, Remove mapping; unmapped: Learn MIDI CC\n * (and Assign CC number…, which works before any MIDI device is granted) —\n * and one \"All mappings…\". Module-wide actions (Reset module defaults, Clear\n * all, the source palette) live in the mappings dialog and the bar's MIDI\n * tab, so the submenu is EXCLUSIVE: it replaces, rather than composes with,\n * the face's ambient MIDI CC submenu. The parameter path is not shown; it is\n * an identifier, not something a player reads.\n */\nexport function ccControlMenuActions(\n  midi: ControlMidiProps,\n  openEditor: (mode: \"assign\" | \"edit\") => void,\n  presentation?: CcControlPresentation\n): ContextMenuAction[] {\n  const mapped = midi.assignment !== null && midi.assignment !== undefined\n  const actions: ContextMenuAction[] = []\n  if (presentation?.label)\n    actions.push({\n      id: \"control-mapping\",\n      label: `${presentation.label} — ${ccControlMappingSummary(midi.assignment)}`,\n      onSelect: () => {},\n      disabled: true,\n    })\n  if (midi.onLearn)\n    actions.push({\n      id: \"learn-cc\",\n      label: mapped ? \"Learn new CC\" : \"Learn MIDI CC\",\n      onSelect: midi.onLearn,\n    })\n  else if (midi.learnUnavailableReason || midi.routingOnly)\n    actions.push({\n      id: \"learn-unavailable\",\n      label: midi.learnUnavailableReason ?? \"Host owns MIDI Learn.\",\n      onSelect: () => {},\n      disabled: true,\n    })\n  if (!mapped && midi.onAssign)\n    actions.push({\n      id: \"assign-cc\",\n      label: \"Assign CC number…\",\n      onSelect: () => openEditor(\"assign\"),\n    })\n  if (mapped && midi.onBindingChange)\n    actions.push({\n      id: \"edit-cc\",\n      label: \"Edit range…\",\n      onSelect: () => openEditor(\"edit\"),\n    })\n  if (mapped && midi.onClear)\n    actions.push({\n      id: \"remove-cc\",\n      label: \"Remove mapping\",\n      onSelect: midi.onClear,\n      destructive: true,\n    })\n  if (midi.onOpenMappings)\n    actions.push({\n      id: \"all-mappings\",\n      label: \"All mappings…\",\n      onSelect: midi.onOpenMappings,\n      separatorBefore: actions.length > 0,\n    })\n  const hasAction = actions.some((action) => action.id !== \"control-mapping\")\n  return hasAction\n    ? [\n        {\n          id: \"midi-cc\",\n          label: \"MIDI CC\",\n          submenu: {\n            ariaLabel: presentation?.label\n              ? `${presentation.label} MIDI CC mapping`\n              : \"Parameter MIDI CC mapping\",\n            actions,\n            exclusive: true,\n          },\n        },\n      ]\n    : []\n}\n\n/** Value context shown before the mapping actions in a control's menu. */\nexport interface CcControlPresentation {\n  readonly label?: string\n  readonly value?: string\n  /** Stable parameter path, kept secondary to the musician-facing name/value. */\n  readonly path?: string\n  /** Optional compact range summary in parameter units. */\n  readonly range?: string\n}\n\nexport interface CcControlMenuTargetProps\n  extends\n    Omit<HTMLAttributes<HTMLElement>, \"children\" | \"style\">,\n    Pick<\n      ButtonHTMLAttributes<HTMLButtonElement>,\n      \"type\" | \"disabled\" | \"form\" | \"formAction\" | \"name\" | \"value\"\n    > {\n  midi: ControlMidiProps\n  target: CcDropTargetBinding\n  children: ReactNode\n  as?: \"button\" | \"div\" | \"span\" | \"label\"\n  style?: CSSProperties\n  allowEditableContextMenu?: boolean\n  /** Parameter name/value/path shown before the mapping actions. */\n  presentation?: CcControlPresentation\n  /**\n   * Passed through to the wrapper, for a caller that previously rendered its\n   * own element and is adopting this one. A host-side adapter's layout classes\n   * and its `data-audio-param-path` are load-bearing — param-addressed tooling\n   * and e2e selectors read that attribute — so adopting the menu must not cost\n   * them.\n   */\n  className?: string\n  \"data-audio-param-path\"?: string\n  readonly [dataAttribute: `data-${string}`]: unknown\n}\n\n/** One drop target and one context menu, shared by knobs, faders and pads. */\nexport function CcControlMenuTarget({\n  midi,\n  target,\n  children,\n  as,\n  style,\n  allowEditableContextMenu,\n  presentation,\n  className,\n  \"data-audio-param-path\": paramPath,\n  ...frameProps\n}: CcControlMenuTargetProps) {\n  const [editor, setEditor] = useState<\"assign\" | \"edit\" | null>(null)\n  const rootRef = useRef<HTMLElement | null>(null)\n  const actions = ccControlMenuActions(midi, setEditor, presentation)\n  const attachTarget = useCallback(\n    (element: HTMLElement | null) => {\n      rootRef.current = element\n      target.ref(element)\n    },\n    [target.ref]\n  )\n  const portalRoot = rootRef.current?.ownerDocument.body\n\n  return (\n    <>\n      <ContextMenuTarget\n        as={as ?? \"span\"}\n        actions={actions}\n        includeAmbientActions\n        targetRef={attachTarget}\n        allowEditableContextMenu={allowEditableContextMenu}\n        {...frameProps}\n        {...(className ? { className } : {})}\n        {...(paramPath ? { \"data-audio-param-path\": paramPath } : {})}\n        {...target.targetProps}\n        style={{\n          position: \"relative\",\n          ...(as === undefined || as === \"span\"\n            ? { display: \"inline-flex\" }\n            : {}),\n          ...style,\n        }}\n      >\n        {children}\n      </ContextMenuTarget>\n      {editor && rootRef.current && portalRoot\n        ? createPortal(\n            <span style={contextMenuPortalTheme(rootRef.current)}>\n              <CcControlMenuPanel\n                anchor={rootRef.current}\n                midi={midi}\n                mode={editor}\n                onClose={() => setEditor(null)}\n              />\n            </span>,\n            portalRoot\n          )\n        : null}\n    </>\n  )\n}\n\n/** The same editor panel can be anchored by a single control or an XY axis menu. */\nexport function CcControlMenuPanel({\n  anchor,\n  midi,\n  mode,\n  onClose,\n}: {\n  anchor?: HTMLElement | null\n  midi: ControlMidiProps\n  mode: \"assign\" | \"edit\"\n  onClose: () => void\n}) {\n  const [draftCcText, setDraftCcText] = useState(\"\")\n  const [draftChannel, setDraftChannel] = useState<number | null>(null)\n  const panelRef = useRef<HTMLSpanElement | null>(null)\n  const inputRef = useRef<HTMLInputElement | null>(null)\n  const [position, setPosition] = useState<{\n    left: number\n    top: number\n  } | null>(null)\n  const assignment = midi.assignment ?? null\n  const parsedCc = Number(draftCcText)\n  const draftCc =\n    draftCcText.trim() !== \"\" &&\n    Number.isInteger(parsedCc) &&\n    parsedCc >= 0 &&\n    parsedCc <= 127\n      ? parsedCc\n      : null\n\n  useEffect(() => {\n    if (mode === \"assign\") inputRef.current?.focus()\n  }, [mode])\n\n  useEffect(() => {\n    const view = anchor?.ownerDocument.defaultView ?? window\n    const dismiss = (event: PointerEvent) => {\n      if (\n        event.target instanceof Node &&\n        panelRef.current?.contains(event.target)\n      )\n        return\n      onClose()\n    }\n    const escape = (event: globalThis.KeyboardEvent) => {\n      if (event.key === \"Escape\") onClose()\n    }\n    view.addEventListener(\"pointerdown\", dismiss)\n    view.addEventListener(\"keydown\", escape)\n    return () => {\n      view.removeEventListener(\"pointerdown\", dismiss)\n      view.removeEventListener(\"keydown\", escape)\n    }\n  }, [anchor, onClose])\n\n  useLayoutEffect(() => {\n    if (!anchor || !panelRef.current) return\n    const view = anchor.ownerDocument.defaultView\n    if (!view) return\n    const anchorBox = anchor.getBoundingClientRect()\n    const panelBox = panelRef.current.getBoundingClientRect()\n    const gap = 4\n    const viewportGap = 8\n    const left = Math.max(\n      viewportGap,\n      Math.min(\n        anchorBox.right - panelBox.width,\n        view.innerWidth - panelBox.width - viewportGap\n      )\n    )\n    const below = anchorBox.bottom + gap\n    const top = Math.max(\n      viewportGap,\n      below + panelBox.height <= view.innerHeight - viewportGap\n        ? below\n        : anchorBox.top - panelBox.height - gap\n    )\n    setPosition({ left, top })\n  }, [anchor, mode])\n\n  return (\n    <span\n      ref={panelRef}\n      data-cc-menu-panel\n      style={{\n        position: anchor ? \"fixed\" : \"absolute\",\n        ...(anchor\n          ? {\n              left: position?.left ?? 0,\n              top: position?.top ?? 0,\n              visibility: position ? \"visible\" : \"hidden\",\n            }\n          : { top: \"calc(100% + 4px)\", right: 0 }),\n        zIndex: 2147483646,\n        display: \"block\",\n      }}\n    >\n      {mode === \"edit\" && assignment ? (\n        <CcBindingEditor\n          {...ccBadgeSlot(midi, assignment, false)}\n          onClose={onClose}\n        />\n      ) : mode === \"assign\" ? (\n        <div\n          role=\"dialog\"\n          aria-label=\"Assign MIDI CC\"\n          style={{\n            display: \"flex\",\n            flexDirection: \"column\",\n            gap: 8,\n            width: 220,\n            padding: 10,\n            borderRadius: token.radius,\n            border: `1px solid ${token.border}`,\n            background: token.surface,\n            boxShadow: \"0 14px 34px rgba(0,0,0,0.5)\",\n            color: token.text,\n            fontFamily: token.font,\n            fontSize: 11,\n          }}\n        >\n          <strong>Assign MIDI CC</strong>\n          <div style={{ display: \"flex\", gap: 6 }}>\n            <Field label=\"CC number\">\n              <input\n                ref={inputRef}\n                type=\"number\"\n                className=\"uikit-control\"\n                aria-label=\"Controller number, 0 to 127\"\n                min={0}\n                max={127}\n                step={1}\n                value={draftCcText}\n                onChange={(event) => setDraftCcText(event.target.value)}\n                style={boxStyle}\n              />\n            </Field>\n            <Field label=\"Channel\">\n              <select\n                aria-label=\"MIDI channel\"\n                value={draftChannel ?? \"\"}\n                onChange={(event) =>\n                  setDraftChannel(\n                    event.target.value === \"\"\n                      ? null\n                      : Number(event.target.value)\n                  )\n                }\n                style={{ ...boxStyle, width: \"100%\", appearance: \"auto\" }}\n              >\n                <option value=\"\">Omni</option>\n                {Array.from({ length: 16 }, (_, index) => index + 1).map(\n                  (channel) => (\n                    <option key={channel} value={channel}>\n                      {channel}\n                    </option>\n                  )\n                )}\n              </select>\n            </Field>\n          </div>\n          <div style={{ display: \"flex\", justifyContent: \"flex-end\", gap: 6 }}>\n            <button type=\"button\" onClick={onClose}>\n              Cancel\n            </button>\n            <button\n              type=\"button\"\n              disabled={draftCc === null}\n              onClick={() => {\n                if (draftCc === null) return\n                midi.onAssign?.({\n                  cc: draftCc,\n                  ...(draftChannel === null ? {} : { channel: draftChannel }),\n                })\n                onClose()\n              }}\n            >\n              Assign\n            </button>\n          </div>\n        </div>\n      ) : null}\n    </span>\n  )\n}\n\n/* ------------------------------------------------------------------ chrome */\n\nconst boxStyle: CSSProperties = {\n  width: \"100%\",\n  boxSizing: \"border-box\",\n  appearance: \"none\",\n  background: \"transparent\",\n  border: `1px solid ${token.border}`,\n  borderRadius: 4,\n  color: token.text,\n  fontFamily: token.fontMono,\n  fontSize: 10,\n  fontVariantNumeric: \"tabular-nums\",\n  padding: \"2px 4px\",\n  textAlign: \"center\",\n}\n\nfunction Field({ label, children }: { label: string; children: ReactNode }) {\n  return (\n    <label\n      style={{ flex: 1, display: \"flex\", flexDirection: \"column\", gap: 2 }}\n    >\n      <span\n        style={{\n          fontSize: 8,\n          fontWeight: 700,\n          letterSpacing: \"0.08em\",\n          textTransform: \"uppercase\",\n          color: token.textMuted,\n        }}\n      >\n        {label}\n      </span>\n      {children}\n    </label>\n  )\n}\n\ninterface NumberBoxProps {\n  value?: number\n  ariaLabel: string\n  placeholder?: string\n  min?: number\n  max?: number\n  step?: number\n  disabled?: boolean\n  /** `null` means the box was emptied — clear the field to its default. */\n  onCommit: (value: number | null) => void\n  ref?: React.Ref<HTMLInputElement>\n}\n\n/**\n * A numeric field that can be empty.\n *\n * The draft lives in local state so a half-typed \"-\" or \"1.\" is not thrown at\n * the host, and the empty string is a real value meaning \"no override\" — which\n * is why this is not just an `<input type=\"number\" value={n}>`. The draft\n * re-syncs whenever the host's value changes underneath it (a learn landing, a\n * preset loading), except while the box has focus, where re-syncing would\n * fight the person typing in it.\n */\nfunction NumberBox({\n  value,\n  ariaLabel,\n  placeholder,\n  min,\n  max,\n  step,\n  disabled,\n  onCommit,\n  ref,\n}: NumberBoxProps) {\n  const [draft, setDraft] = useState(() =>\n    value === undefined ? \"\" : String(round(value))\n  )\n  const [focused, setFocused] = useState(false)\n\n  useEffect(() => {\n    if (focused) return\n    setDraft(value === undefined ? \"\" : String(round(value)))\n  }, [value, focused])\n\n  return (\n    <input\n      ref={ref}\n      type=\"number\"\n      className=\"uikit-control\"\n      aria-label={ariaLabel}\n      value={draft}\n      {...(placeholder !== undefined ? { placeholder } : {})}\n      {...(min !== undefined ? { min } : {})}\n      {...(max !== undefined ? { max } : {})}\n      {...(step !== undefined ? { step } : {})}\n      disabled={disabled}\n      onFocus={() => setFocused(true)}\n      onBlur={() => setFocused(false)}\n      onChange={(event) => {\n        const next = event.target.value\n        setDraft(next)\n        if (next.trim() === \"\") {\n          onCommit(null)\n          return\n        }\n        const parsed = Number(next)\n        if (Number.isFinite(parsed)) onCommit(parsed)\n      }}\n      style={boxStyle}\n    />\n  )\n}\n\n/** Keep readouts short without lying about a value the user typed. */\nfunction round(value: number): number {\n  return Math.abs(value) >= 100 ? Math.round(value) : Number(value.toFixed(3))\n}\n",
      "type": "registry:component",
      "target": "@ui/cc-binding-editor.tsx"
    }
  ]
}
