{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-drawbar-strip",
  "type": "registry:ui",
  "title": "Drawbar Strip",
  "description": "A tonewheel organ's registration control: N sliding bars with nine detents each, coloured in the classic brown/white/black families, and the registration printed underneath as the digit string players actually speak (\"888000000\"). The throw is inverted — out is loud, so the head travels down as the value rises — and the travel is detented rather than continuous, so a drag and a keypress cannot disagree about what \"6\" is. Press anywhere to jump a bar to that detent and keep dragging, wheel a notch at a time, or drive each bar from the keyboard. Layout, quantisation and key handling sit in a pure core beside it.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/drawbar-strip.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/drawbar-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 * Drawbar Strip — the tonewheel organ's registration control.\n *\n * N sliding bars with nine detents each, coloured in the classic three\n * families, and the registration printed under them as the digit string\n * players actually speak (\"888000000\"). Press anywhere on a bar to jump it to\n * that detent and keep dragging; wheel a notch at a time; full keyboard access\n * per bar.\n *\n * ## Why this is not `<Fader>` nine times\n *\n * Three things differ, and all three are the instrument rather than styling:\n *\n *   - **Inverted throw.** Out is loud. The cap travels DOWN as the value\n *     rises, because pulling a bar toward the player is what opens it.\n *   - **Detents, not travel.** Nine positions, no in-between. The pointer\n *     mapping quantises at the source so a drag and a keypress cannot disagree\n *     about what \"6\" is.\n *   - **The registration is the readout.** Nine separate value labels are not\n *     how anyone reads a setting; one nine-digit string is.\n *\n * All of that arithmetic is in `drawbar-math.ts` beside this file, tested\n * directly. This component draws, and wires events to it.\n *\n * Keyboard follows the ARIA slider convention — Up/Right raise the value — so\n * pressing Up moves the cap down. See the math file's header: that is what\n * pulling a drawbar out looks like, and inverting the keys would only invert\n * something a screen-reader user cannot see.\n */\n\nimport { useCallback, useId, useMemo, useRef } from \"react\"\nimport type { CSSProperties, KeyboardEvent as ReactKeyboardEvent } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\nimport { useSurfacePointer } from \"maruaudio/ui/shared/use-surface-pointer\"\nimport { useWheel } from \"maruaudio/ui/shared/use-wheel\"\nimport {\n  DRAWBAR_MAX_STOP,\n  clampStop,\n  registrationString,\n  stopAtPosition,\n  stopForKey,\n  stripWidth,\n  throwOffset,\n  type DrawbarGroup,\n} from \"maruaudio/ui/shared/drawbar-math\"\n\n/** One bar's identity. Values live outside, in `values`. */\nexport interface DrawbarSpec {\n  /** Printed above the bar — a footage like \"5 1/3'\". */\n  label: string\n  /** Colour family. See `drawbar-math`'s header. */\n  group: DrawbarGroup\n  /**\n   * Accessible name, when the footage alone would not be one. Defaults to\n   * `${label} drawbar`, which is what a player calls it.\n   */\n  ariaLabel?: string\n}\n\nexport interface DrawbarStripProps {\n  /** The bars, left to right. */\n  bars: readonly DrawbarSpec[]\n  /** Current stop per bar, index-aligned with `bars`. */\n  values: readonly number[]\n  /** Report an edit. Fires once per detent crossed, never per pointer event. */\n  onChange: (index: number, value: number) => void\n  /** Fires when a gesture ends, with the settled value. */\n  onChangeEnd?: (index: number, value: number) => void\n\n  /** Highest stop. 8 on every organ ever built; a prop for the curious. */\n  max?: number\n\n  /** Track length in pixels — the bar's full travel plus one cap. */\n  length?: number\n  /** Width of one bar. */\n  barWidth?: number\n  /** Space between bars. */\n  barGap?: number\n\n  /** Print the registration digits under the strip. */\n  showRegistration?: boolean\n  /** Caption for the whole group, e.g. \"Upper manual\". */\n  label?: string\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  /**\n   * Fired once, on the first gesture of any kind, synchronously inside the\n   * event — the place to resume a suspended AudioContext.\n   */\n  onInteraction?: (event: InteractionEvent) => void\n}\n\n/**\n * Cap colours per family, as CSS variables over the classic paint.\n *\n * The fallbacks ARE the instrument's colours (walnut brown, ivory, ebony), so\n * an unstyled strip reads correctly with no configuration; a consumer that\n * wants its own palette sets the three variables.\n */\nconst GROUP_COLORS: Record<DrawbarGroup, { cap: string; text: string }> = {\n  sub: {\n    cap: \"var(--uikit-drawbar-sub, linear-gradient(180deg, #8a6440, #5d4128))\",\n    text: \"var(--uikit-drawbar-sub-text, #f5e7d6)\",\n  },\n  consonant: {\n    cap: \"var(--uikit-drawbar-consonant, linear-gradient(180deg, #f4f1e8, #d6d0c2))\",\n    text: \"var(--uikit-drawbar-consonant-text, #24211b)\",\n  },\n  dissonant: {\n    cap: \"var(--uikit-drawbar-dissonant, linear-gradient(180deg, #33333a, #131317))\",\n    text: \"var(--uikit-drawbar-dissonant-text, #ece9e2)\",\n  },\n}\n\n/** Height of the head a player grips, in pixels. */\nconst CAP_HEIGHT = 30\n\ninterface DrawbarProps {\n  spec: DrawbarSpec\n  value: number\n  index: number\n  max: number\n  length: number\n  width: number\n  disabled: boolean\n  onChange: (index: number, value: number) => void\n  onChangeEnd?: (index: number, value: number) => void\n  notify: (event: InteractionEvent) => void\n}\n\n/**\n * One bar.\n *\n * A child component rather than a loop body because each bar owns pointer,\n * wheel and keyboard state, and hooks cannot be called from a map. The\n * once-only first-interaction guard is the exception and is passed down from\n * the strip, so nine bars still report one first gesture between them.\n */\nfunction Drawbar({\n  spec,\n  value,\n  index,\n  max,\n  length,\n  width,\n  disabled,\n  onChange,\n  onChangeEnd,\n  notify,\n}: DrawbarProps) {\n  const trackRef = useRef<HTMLDivElement | null>(null)\n  const stop = clampStop(value, max)\n  // Read by gesture handlers bound for the life of a drag, which must not see\n  // a stale value from the render that started them.\n  const latest = useRef(stop)\n  latest.current = stop\n\n  const travel = Math.max(0, length - CAP_HEIGHT)\n  const colors = GROUP_COLORS[spec.group]\n\n  const commit = useCallback(\n    (next: number) => {\n      const clean = clampStop(next, max)\n      if (clean === latest.current) return\n      latest.current = clean\n      onChange(index, clean)\n    },\n    [index, max, onChange],\n  )\n\n  const drag = useSurfacePointer(\n    (state) => {\n      if (state.first) notify(state.event)\n      const rect = trackRef.current?.getBoundingClientRect()\n      if (!rect) return\n      // The travel starts half a cap in, because the cap is positioned by its\n      // top edge: without the inset the extremes sit half a cap outside the\n      // track and the head never lands under the cursor.\n      commit(stopAtPosition(state.clientY, rect.top + CAP_HEIGHT / 2, travel, max))\n    },\n    {\n      disabled,\n      onEnd: () => onChangeEnd?.(index, latest.current),\n    },\n  )\n\n  useWheel(\n    trackRef,\n    (state) => {\n      notify(state.event)\n      // One notch, one detent. A drawbar has nine positions; scaling by the\n      // raw delta would skip most of them on a trackpad.\n      if (state.deltaY === 0) return\n      commit(latest.current + (state.deltaY < 0 ? 1 : -1))\n    },\n    { disabled },\n  )\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    if (disabled) return\n    notify(event)\n    const next = stopForKey(event.key, latest.current, max)\n    // A key we do not own is left alone, so the page still scrolls.\n    if (next === null) return\n    event.preventDefault()\n    commit(next)\n    onChangeEnd?.(index, next)\n  }\n\n  const offset = throwOffset(stop, travel, max)\n  const detents = useMemo(\n    () => Array.from({ length: max + 1 }, (_, i) => i),\n    [max],\n  )\n\n  return (\n    <div style={{ display: \"flex\", flexDirection: \"column\", alignItems: \"center\", gap: 4 }}>\n      <span\n        aria-hidden\n        style={{\n          fontFamily: token.font,\n          fontSize: 9,\n          fontWeight: 600,\n          letterSpacing: \"0.04em\",\n          color: token.textMuted,\n          whiteSpace: \"nowrap\",\n        }}\n      >\n        {spec.label}\n      </span>\n\n      <div\n        ref={trackRef}\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={spec.ariaLabel ?? `${spec.label} drawbar`}\n        aria-valuemin={0}\n        aria-valuemax={max}\n        aria-valuenow={stop}\n        aria-valuetext={`${stop} of ${max}`}\n        aria-orientation=\"vertical\"\n        aria-disabled={disabled || undefined}\n        onPointerDown={drag.onPointerDown}\n        onKeyDown={handleKeyDown}\n        style={{\n          position: \"relative\",\n          width,\n          height: length,\n          borderRadius: 4,\n          // The slot the bar disappears into. Darker than a fader groove\n          // because a drawbar runs in a routed channel, not on a face.\n          background: \"var(--uikit-drawbar-slot, rgba(0,0,0,0.55))\",\n          boxShadow: \"inset 0 2px 6px rgba(0,0,0,0.6)\",\n          cursor: disabled ? \"not-allowed\" : drag.dragging ? \"grabbing\" : \"pointer\",\n          outlineOffset: 2,\n          touchAction: \"none\",\n        }}\n      >\n        {/* Detent marks down the right edge — nine of them, so the throw reads\n            as clicked rather than continuous even before you touch it. */}\n        {detents.map((detent) => (\n          <span\n            key={detent}\n            aria-hidden\n            style={{\n              position: \"absolute\",\n              right: 1,\n              width: 4,\n              height: 1,\n              background: token.border,\n              opacity: detent === 0 || detent === max ? 0.9 : 0.45,\n              top: CAP_HEIGHT / 2 + throwOffset(detent, travel, max),\n            }}\n          />\n        ))}\n\n        {/* The exposed shaft: visible above the cap, and only once the bar has\n            been pulled out at all. This is the part a player reads across the\n            whole strip at a glance. */}\n        <div\n          aria-hidden\n          style={{\n            position: \"absolute\",\n            left: \"50%\",\n            transform: \"translateX(-50%)\",\n            top: 2,\n            width: Math.max(6, width * 0.42),\n            height: Math.max(0, offset),\n            borderRadius: 2,\n            background: colors.cap,\n            opacity: stop === 0 ? 0 : 1,\n          }}\n        />\n\n        {/* The head. */}\n        <div\n          aria-hidden\n          className={drag.dragging ? undefined : \"uikit-animated\"}\n          style={{\n            position: \"absolute\",\n            left: 0,\n            right: 0,\n            top: offset,\n            height: CAP_HEIGHT,\n            borderRadius: 3,\n            background: colors.cap,\n            color: colors.text,\n            border: `1px solid ${token.border}`,\n            boxShadow: drag.dragging\n              ? `0 0 0 1px ${token.accent}, 0 4px 10px rgba(0,0,0,0.55)`\n              : \"0 2px 6px rgba(0,0,0,0.5)\",\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            fontFamily: token.fontMono,\n            fontSize: 11,\n            fontVariantNumeric: \"tabular-nums\",\n          }}\n        >\n          {stop}\n        </div>\n      </div>\n    </div>\n  )\n}\n\nexport function DrawbarStrip({\n  bars,\n  values,\n  onChange,\n  onChangeEnd,\n  max = DRAWBAR_MAX_STOP,\n  length = 150,\n  barWidth = 26,\n  barGap = 6,\n  showRegistration = true,\n  label,\n  disabled = false,\n  className,\n  style,\n  onInteraction,\n}: DrawbarStripProps) {\n  const labelId = useId()\n  const { notify } = useFirstInteraction(onInteraction)\n  const width = stripWidth(bars.length, barWidth, barGap)\n  const registration = registrationString(\n    bars.map((_, index) => values[index] ?? 0),\n    max,\n  )\n\n  return (\n    <div\n      className={className}\n      role=\"group\"\n      aria-labelledby={label ? labelId : undefined}\n      aria-label={label ? undefined : \"Drawbars\"}\n      style={{\n        display: \"flex\",\n        flexDirection: \"column\",\n        alignItems: \"center\",\n        gap: 6,\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      {label && (\n        <span\n          id={labelId}\n          style={{\n            fontSize: 10,\n            fontWeight: 600,\n            letterSpacing: \"0.08em\",\n            textTransform: \"uppercase\",\n            color: token.textMuted,\n          }}\n        >\n          {label}\n        </span>\n      )}\n\n      <div style={{ display: \"flex\", gap: barGap, width, maxWidth: \"100%\" }}>\n        {bars.map((spec, index) => (\n          <Drawbar\n            key={`${spec.label}-${index}`}\n            spec={spec}\n            index={index}\n            value={values[index] ?? 0}\n            max={max}\n            length={length}\n            width={barWidth}\n            disabled={disabled}\n            onChange={onChange}\n            onChangeEnd={onChangeEnd}\n            notify={notify}\n          />\n        ))}\n      </div>\n\n      {showRegistration && (\n        // A plain span, deliberately not an `<output>` or a live region: nine\n        // bars each announce their own value already, and a live registration\n        // would re-read all nine digits on every detent of every drag.\n        <span\n          style={{\n            fontFamily: token.fontMono,\n            fontSize: 13,\n            letterSpacing: \"0.28em\",\n            fontVariantNumeric: \"tabular-nums\",\n            color: token.text,\n          }}\n        >\n          {registration}\n        </span>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/drawbar-strip.tsx"
    }
  ]
}
