{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-cc-knob-strip",
  "type": "registry:ui",
  "title": "CC Knob Strip",
  "description": "A row of MIDI CC chips you can turn and reorder. Turning is the ordinary knob gesture; reordering starts from a separate grip handle and works with pointer or arrow keys.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-knob.json"
  ],
  "files": [
    {
      "path": "components/ui/cc-knob-strip.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/cc-knob-strip\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 * CCKnobStrip — a row of MIDI CC chips you can turn, reorder, and bind.\n *\n * Each chip is a mini knob plus a CC badge. Turning is the ordinary knob\n * interaction; reordering is a separate gesture started from the grip handle,\n * so a drag never gets confused with a value change.\n *\n * The reorder implementation is deliberately plain: pointer events plus a\n * midpoint comparison against the other chips' rects. No HTML5 drag-and-drop\n * (which has no touch support and an awkward image API) and no dependency.\n *\n * Keyboard reordering works too — focus a grip and press the arrow keys.\n *\n * The badge started life as decoration: a number printed on the chip, with\n * nothing behind it. It is now a real binding when the caller supplies one —\n * pass `assignment` per control and `onAssign`/`onClear` on the strip and each\n * chip becomes a drop target for the CC source chips, with the same badge, the\n * same clear affordance and the same activity pulse a mapped Knob or Fader\n * wears. Callers that pass none of that get exactly the old strip, printed\n * number included.\n */\n\nimport { useId } from \"react\"\nimport type { CSSProperties } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport type { ControlSkin } from \"maruaudio/ui/shared/control-skin\"\nimport type { ValueFormatter } from \"maruaudio/ui/shared/format\"\nimport {\n  CcBadge,\n  ccActivityRingStyle,\n  ccDropTargetStyle,\n  useCcActivityPulse,\n  useCcDropTarget,\n  useCcMappingFlash,\n  type CcAssignment,\n  type CcDndController,\n} from \"maruaudio/ui/shared/cc-dnd\"\nimport { Knob } from \"@/components/ui/knob\"\nimport {\n  useStableIdReorder,\n  type StableReorderGripProps,\n  type StableReorderItemProps,\n} from \"maruaudio/ui/shared/stable-reorder\"\n\nexport interface CCControl {\n  /** Stable identity — used as the React key and in reorder callbacks. */\n  id: string\n  /** MIDI CC number shown on the badge (0-127 conventionally). */\n  cc: number\n  /** Short caption under the knob. */\n  label?: string\n  value?: number\n  min?: number\n  max?: number\n  step?: number\n  defaultValue?: number\n  format?: ValueFormatter\n  /**\n   * The live binding for this chip, when the host is tracking one. Given, the\n   * badge shows it (and its clear button) instead of the printed `cc`.\n   */\n  assignment?: CcAssignment | null\n  /**\n   * Incoming traffic for this chip's controller: the raw 0-127 value (any\n   * change pulses) or `true` for a single pulse.\n   */\n  activity?: boolean | number\n}\n\nexport interface CCKnobStripProps {\n  /** The chips, in display order. */\n  controls: ReadonlyArray<CCControl>\n  /** Fired when a chip's knob moves. */\n  onChange?: (id: string, value: number, control: CCControl) => void\n  /**\n   * Fired when a chip is dropped in a new slot. You receive the full\n   * reordered id list plus the from/to indices.\n   */\n  onReorder?: (order: ReadonlyArray<string>, from: number, to: number) => void\n  /**\n   * A CC source chip was dropped on a chip. Supplying this is what turns the\n   * strip into a live binding surface: without it (and without any control\n   * carrying an `assignment`) no chip registers as a drop target at all.\n   */\n  onAssign?: (id: string, assignment: CcAssignment, control: CCControl) => void\n  /** A chip's badge was cleared. Omit to render read-only badges. */\n  onClear?: (id: string, control: CCControl) => void\n  /** Share a drag controller other than the ambient one. */\n  ccController?: CcDndController\n  /** Turn off reordering and hide the grips. */\n  reorderable?: boolean\n  /** Knob diameter inside each chip. */\n  knobSize?: number\n  /**\n   * Visual finish for the chips' knobs, shared with every other control in the\n   * kit. A strip is a composition, so it forwards a skin rather than inventing\n   * a look of its own — a `vintage` strip is made of `vintage` knobs.\n   */\n  knobSkin?: ControlSkin\n\n  /** `value` keeps pure configuration surfaces from looking like playable knobs. */\n  presentation?: \"knob\" | \"value\"\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function CCKnobStrip({\n  controls,\n  onChange,\n  onReorder,\n  onAssign,\n  onClear,\n  ccController,\n  reorderable = true,\n  knobSize = 40,\n  knobSkin,\n  presentation = \"knob\",\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: CCKnobStripProps) {\n  // Prefix for drop-target ids, so two strips on a page stay distinct while\n  // each chip's id stays stable across reorders (it is keyed by control id).\n  const stripId = useId()\n  const reorder = useStableIdReorder({\n    ids: controls.map((control) => control.id),\n    disabled: disabled || !reorderable,\n    keyboardMode: \"immediate\",\n    getItemLabel: (id) =>\n      controls.find((control) => control.id === id)?.label ?? id,\n    onReorder: ({ order, from, to }) => onReorder?.(order, from, to),\n  })\n\n  return (\n    <div\n      ref={reorder.setContainerRef}\n      className={className}\n      role=\"group\"\n      aria-label={ariaLabel ?? \"MIDI CC controls\"}\n      style={{\n        display: \"flex\",\n        flexWrap: \"wrap\",\n        gap: 8,\n        alignItems: \"stretch\",\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {controls.map((control, index) => (\n        <CCKnobStripChip\n          key={control.id}\n          control={control}\n          index={index}\n          targetId={`${stripId}-${control.id}`}\n          reorderItemProps={reorder.getItemProps(control.id)}\n          isDragging={reorder.activeId === control.id}\n          isReorderTarget={\n            reorder.insertionIndex === index &&\n            reorder.activeId !== null &&\n            reorder.activeId !== control.id\n          }\n          reorderable={reorderable}\n          knobSize={knobSize}\n          knobSkin={knobSkin}\n          presentation={presentation}\n          disabled={disabled}\n          ccController={ccController}\n          onChange={onChange}\n          onAssign={onAssign}\n          onClear={onClear}\n          gripProps={reorder.getGripProps(control.id)}\n        />\n      ))}\n    </div>\n  )\n}\n\ninterface CCKnobStripChipProps {\n  control: CCControl\n  index: number\n  targetId: string\n  isDragging: boolean\n  isReorderTarget: boolean\n  reorderable: boolean\n  knobSize: number\n  knobSkin: ControlSkin | undefined\n  presentation: \"knob\" | \"value\"\n  disabled: boolean\n  ccController?: CcDndController\n  onChange?: CCKnobStripProps[\"onChange\"]\n  onAssign?: CCKnobStripProps[\"onAssign\"]\n  onClear?: CCKnobStripProps[\"onClear\"]\n  reorderItemProps: StableReorderItemProps\n  gripProps: StableReorderGripProps\n}\n\n/**\n * One chip.\n *\n * Its own component because each chip owns a drop-target registration, and a\n * hook cannot be called in a loop. The chip — not the knob inside it — is the\n * target: dropping anywhere on the tile binds it, and a caller who also passes\n * `midi` to a knob elsewhere gets the nesting rule (smallest rect wins) rather\n * than an ambiguity.\n */\nfunction CCKnobStripChip({\n  control,\n  targetId,\n  isDragging,\n  isReorderTarget,\n  reorderable,\n  knobSize,\n  knobSkin,\n  presentation,\n  disabled,\n  ccController,\n  onChange,\n  onAssign,\n  onClear,\n  reorderItemProps,\n  gripProps,\n}: CCKnobStripChipProps) {\n  const assignment = control.assignment ?? null\n  // Opt-in: a strip with no assignment handling stays the old cosmetic strip\n  // and never lights up under a drag.\n  const bindable = Boolean(onAssign) || assignment !== null\n\n  const pulse = useCcActivityPulse(control.activity)\n  const ccTarget = useCcDropTarget({\n    id: targetId,\n    label: control.label ?? `CC ${control.cc}`,\n    onCcDrop: (dropped) => onAssign?.(control.id, dropped, control),\n    enabled: bindable && !disabled,\n    controller: ccController,\n  })\n\n  const flash = useCcMappingFlash(assignment, ccTarget, ccController)\n  const highlight = ccDropTargetStyle({ ...ccTarget, flash })\n\n  return (\n    <div\n      ref={ccTarget.ref}\n      data-uikit-cc-chip=\"\"\n      {...(flash ? { \"data-uikit-cc-flash\": \"\" } : {})}\n      {...reorderItemProps}\n      {...(bindable ? ccTarget.targetProps : {})}\n      style={{\n        position: \"relative\",\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n        gap: 4,\n        padding: \"8px 10px 6px\",\n        borderRadius: token.radius,\n        background: token.surface,\n        border: `1px solid ${isReorderTarget || ccTarget.isOver ? token.accent : token.border}`,\n        boxShadow: isDragging\n          ? `0 8px 18px rgba(0,0,0,0.45), 0 0 0 1px ${token.accent}`\n          : (highlight.boxShadow ?? \"0 1px 2px rgba(0,0,0,0.25)\"),\n        opacity: isDragging ? 0.85 : 1,\n        transform: isDragging ? \"scale(1.04)\" : \"none\",\n        transition: isDragging\n          ? \"none\"\n          : \"transform 120ms ease-out, border-color 120ms ease-out, box-shadow 120ms ease-out\",\n        zIndex: isDragging ? 2 : 1,\n      }}\n    >\n      {/* Activity ring — an overlay, so a pulse never nudges the row. */}\n      <span aria-hidden style={ccActivityRingStyle(pulse)} />\n\n      {/* CC badge + grip */}\n      <div\n        style={{\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 5,\n          alignSelf: \"stretch\",\n          justifyContent: \"space-between\",\n        }}\n      >\n        {assignment ? (\n          <CcBadge\n            assignment={assignment}\n            active={pulse}\n            onClear={onClear ? () => onClear(control.id, control) : undefined}\n            controller={ccController}\n            sourceTargetId={targetId}\n            onMove={onClear ? () => onClear(control.id, control) : undefined}\n          />\n        ) : (\n          <span\n            style={{\n              fontFamily: token.fontMono,\n              fontSize: 9,\n              fontWeight: 700,\n              letterSpacing: \"0.04em\",\n              color: token.accent,\n              background: `color-mix(in srgb, ${token.accent} 15%, transparent)`,\n              border: `1px solid color-mix(in srgb, ${token.accent} 35%, transparent)`,\n              borderRadius: 4,\n              padding: \"1px 4px\",\n              whiteSpace: \"nowrap\",\n            }}\n          >\n            CC{control.cc}\n          </span>\n        )}\n\n        {reorderable && (\n          <span\n            {...gripProps}\n            className=\"uikit-control\"\n            style={{\n              display: \"flex\",\n              cursor: disabled\n                ? \"not-allowed\"\n                : isDragging\n                  ? \"grabbing\"\n                  : \"grab\",\n              color: token.textMuted,\n              lineHeight: 0,\n              padding: 1,\n            }}\n          >\n            <GripGlyph />\n          </span>\n        )}\n      </div>\n\n      {presentation === \"knob\" ? (\n        <Knob\n          size={knobSize}\n          variant={knobSkin}\n          value={control.value}\n          defaultValue={control.defaultValue ?? control.min ?? 0}\n          min={control.min ?? 0}\n          max={control.max ?? 127}\n          step={control.step ?? 1}\n          format={control.format}\n          disabled={disabled}\n          hideValue\n          aria-label={control.label ?? `CC ${control.cc}`}\n          onChange={(next) => onChange?.(control.id, next, control)}\n        />\n      ) : null}\n\n      <span\n        style={{\n          fontFamily: token.fontMono,\n          fontSize: presentation === \"value\" ? 14 : 10,\n          fontVariantNumeric: \"tabular-nums\",\n          color: token.text,\n        }}\n      >\n        {control.format\n          ? control.format(control.value ?? 0)\n          : Math.round(control.value ?? 0)}\n      </span>\n\n      {control.label && (\n        <span\n          style={{\n            fontSize: 9,\n            fontWeight: 600,\n            letterSpacing: \"0.06em\",\n            textTransform: \"uppercase\",\n            color: token.textMuted,\n            whiteSpace: \"nowrap\",\n          }}\n        >\n          {control.label}\n        </span>\n      )}\n    </div>\n  )\n}\n\nfunction GripGlyph() {\n  return (\n    <svg\n      width=\"10\"\n      height=\"12\"\n      viewBox=\"0 0 10 12\"\n      fill=\"currentColor\"\n      aria-hidden\n    >\n      {[2, 6, 10].map((y) =>\n        [2, 7].map((x) => <circle key={`${x}-${y}`} cx={x} cy={y} r=\"1\" />)\n      )}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/cc-knob-strip.tsx"
    }
  ]
}
