{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-pad-grid",
  "type": "registry:ui",
  "title": "Pad Grid",
  "description": "A drum machine's bank of velocity pads: rows of rubber squares, labelled and lit from the state you pass in. Velocity comes from where in the pad the press landed — the top edge is a full-force hit, the bottom edge the softest one — so a tap is something a player can aim rather than a single fixed level. Choke groups are drawn as well as obeyed: pads that cut each other wear a tie mark, from the same membership function an engine uses to decide what to silence. Includes a first-class look for a pad that is deliberately silent. Layout, hit testing and the velocity law sit in a pure core beside it.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/pad-grid.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/pad-grid\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 * PadGrid — a rectangular bank of velocity pads.\n *\n * The drum machine's surface: rows of rubber squares you hit with your fingers,\n * lit from the state you pass in. It is the rectangular sibling of\n * `RadialPadSurface` and follows the same three rules:\n *\n *   - EVERY rectangle, hit target and label anchor comes out of\n *     `pad-grid-geometry`, so the ink and the touch areas cannot drift apart.\n *   - PRESENTATIONAL AND CONTROLLED. It holds no note state. `pads[i].state`\n *     says what pad `i` looks like right now, the consumer owns that array, and\n *     presses go out through `onPadDown` / `onPadUp`. A face lighting its pads\n *     from an engine's live note map passes the map straight in, and nothing\n *     here can disagree with the engine about what is sounding.\n *   - `silent` IS A FIRST-CLASS LOOK. A kit legitimately has no voice under\n *     some pad, and a pad that produces nothing is indistinguishable from a\n *     broken one unless the surface says so. It is not an error colour: the\n *     player did nothing wrong.\n *\n * VELOCITY comes from where in the pad the press landed — top edge hard, bottom\n * edge soft — and `onPadDown` reports it alongside both raw offsets. See the\n * geometry module's header for why that mapping rather than distance from the\n * centre, and why it is not simply full force every time.\n *\n * CHOKE GROUPS are drawn, not just obeyed. Pads that cut each other carry a\n * small tie mark in the corner and share an accessible description, because the\n * open hat going quiet when the closed one fires is otherwise indistinguishable\n * from a dropped note. Membership comes from `padChokeGroups`, the same function\n * an engine uses to decide what to cut, so the badge cannot claim a rule the\n * sound does not follow.\n *\n * Keyboard: each pad is a `role=\"button\"` in the tab order; Enter or Space\n * presses it and releasing the key releases it, so a held key is a held note.\n * Keyboard presses report full velocity — the only honest reading when there is\n * no pointer position to read one from.\n */\n\nimport { useCallback, useId, useMemo, useRef } from \"react\"\nimport type {\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent,\n  ReactNode,\n} from \"react\"\nimport {\n  hitTestPadGrid,\n  padChokeGroups,\n  padGridLayout,\n  padGridPoint,\n  padStrike,\n  type PadCell,\n  type PadStrike,\n} from \"maruaudio/ui/shared/pad-grid-geometry\"\nimport { token } from \"maruaudio/ui/shared/theme\"\n\n/** What a pad is doing, and therefore how it is drawn. */\nexport type PadState =\n  /** Present and quiet. */\n  | \"idle\"\n  /** Sounding now. */\n  | \"active\"\n  /** Pressed, and there is deliberately no voice here. */\n  | \"silent\"\n  /** No voice assigned at all — drawn, but not playable. */\n  | \"empty\"\n\n/** One pad's content. Index-aligned with the resolved layout, row-major. */\nexport interface PadGridPad {\n  /** Bounded display envelope (0..1). Host prepares PCM; pads never decode it. */\n  waveform?: readonly number[]\n  /** Big label in the middle of the pad — a drum name, typically. */\n  label?: string\n  /** Small second line under it — a note number, a key hint. */\n  sublabel?: string\n  state?: PadState\n  /** Visual correspondence with another view; independent of sounding state. */\n  highlighted?: boolean\n  /**\n   * Choke group. Pads sharing one cannot sound together, and are badged.\n   * Absent or null means this pad chokes nothing and nothing chokes it.\n   */\n  chokeGroup?: string | number | null\n  /** Overrides the generated accessible name. */\n  ariaLabel?: string\n}\n\nexport interface PadGridProps {\n  /** Rows of pads. Defaults to 2. */\n  rows?: number\n  /** Pads per row. Defaults to 4. */\n  columns?: number\n  /**\n   * Pad content, index-aligned with the layout, row-major from the TOP-LEFT.\n   * A face wanting the hardware's bottom-left-is-pad-1 order supplies its pads\n   * in that order — see the geometry module's header.\n   *\n   * Short arrays leave the remaining pads empty rather than throwing: a surface\n   * half filled in during development still renders.\n   */\n  pads?: PadGridPad[]\n  /** viewBox width. The rendered size is CSS's business. Defaults to 240. */\n  width?: number\n  /** viewBox height. Defaults to whatever keeps the pads square. */\n  height?: number\n  /** Gutter between pads, in viewBox units. */\n  gap?: number\n  /** Inset inside the viewBox, in viewBox units. */\n  padding?: number\n  /** Corner radius of a pad, in viewBox units. */\n  radius?: number\n  onPadDown?: (index: number, strike: PadStrike) => void\n  onPadUp?: (index: number) => void\n  /** Pointer hover or keyboard focus, cleared with null when leaving a pad. */\n  onPadHoverChange?: (index: number | null) => void\n  disabled?: boolean\n  /** Draw the choke tie marks. Defaults to true. */\n  showChoke?: boolean\n  /** Anything to render under the pads — a texture, a logo. */\n  children?: ReactNode\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nfunction padFill(state: PadState): string {\n  switch (state) {\n    case \"active\":\n      return token.accent\n    case \"silent\":\n      return token.track\n    case \"empty\":\n      return \"transparent\"\n    default:\n      return token.cap\n  }\n}\n\nfunction padTextColor(state: PadState): string {\n  return state === \"active\" || state === \"idle\" ? token.text : token.textMuted\n}\n\nexport function PadGrid({\n  rows = 2,\n  columns = 4,\n  pads,\n  width = 240,\n  height,\n  gap = 6,\n  padding = 3,\n  radius = 5,\n  onPadDown,\n  onPadUp,\n  onPadHoverChange,\n  disabled = false,\n  showChoke = true,\n  children,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: PadGridProps) {\n  const glowId = useId()\n  const svgRef = useRef<SVGSVGElement | null>(null)\n\n  const layout = useMemo(\n    () => padGridLayout({ rows, columns, width, height, gap, padding }),\n    [rows, columns, width, height, gap, padding]\n  )\n\n  // Membership is computed from the same function the engine uses, so the tie\n  // marks below cannot promise a cut the sound does not make.\n  const chokeGroups = useMemo(() => padChokeGroups(pads ?? []), [pads])\n  const chokeIndex = useMemo(() => {\n    const map = new Map<number, number>()\n    let ordinal = 0\n    for (const members of chokeGroups.values()) {\n      for (const member of members) map.set(member, ordinal)\n      ordinal += 1\n    }\n    return map\n  }, [chokeGroups])\n\n  const press = useCallback(\n    (cell: PadCell, event: PointerEvent<SVGGElement>) => {\n      if (disabled) return\n      const rect = svgRef.current?.getBoundingClientRect()\n      // No rect means the surface has not been measured yet (a synthetic event\n      // in a test, a press in the same frame as mount). Full force is the\n      // honest fallback, and it is the reading the keyboard path gives too.\n      if (!rect) {\n        onPadDown?.(cell.index, {\n          velocity: 1,\n          offsetX: 0.5,\n          offsetY: 0,\n          centerOffset: 1,\n        })\n        return\n      }\n      const point = padGridPoint(event.clientX, event.clientY, rect, layout)\n      onPadDown?.(cell.index, padStrike(cell, point.x, point.y))\n    },\n    [disabled, layout, onPadDown]\n  )\n\n  return (\n    <svg\n      ref={svgRef}\n      viewBox={`0 0 ${layout.width} ${layout.height}`}\n      className={className}\n      style={{ display: \"block\", width: \"100%\", touchAction: \"none\", ...style }}\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Pad grid\"}\n    >\n      <defs>\n        <linearGradient id={`${glowId}-cap`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0%\" stopColor={token.cap} stopOpacity=\"1\" />\n          <stop offset=\"100%\" stopColor={token.surface} stopOpacity=\"1\" />\n        </linearGradient>\n      </defs>\n\n      {children}\n\n      {layout.cells.map((cell) => {\n        const model = pads?.[cell.index]\n        const state: PadState =\n          model?.state ?? (model?.label ? \"idle\" : \"empty\")\n        const playable = !disabled && state !== \"empty\"\n        const choke = chokeIndex.get(cell.index)\n        const name =\n          model?.ariaLabel ??\n          (model?.label\n            ? `Pad ${cell.index + 1}, ${model.label}${\n                state === \"silent\" ? \", silent\" : \"\"\n              }${choke !== undefined ? `, choke group ${choke + 1}` : \"\"}`\n            : `Pad ${cell.index + 1}, empty`)\n        const labelY =\n          cell.cy +\n          (model?.waveform ? cell.height * 0.15 : model?.sublabel ? -1 : 4)\n        return (\n          <g\n            key={cell.index}\n            // An EMPTY pad is drawn but is not a control: no role, no tab stop,\n            // hidden from the accessibility tree. A screen reader announcing\n            // \"button, pad 7, empty\" would be describing the surface's shape\n            // rather than the instrument. A `disabled` pad keeps its identity —\n            // it is a real pad, temporarily unavailable — which is why the two\n            // are treated differently.\n            role={state === \"empty\" ? undefined : \"button\"}\n            aria-hidden={state === \"empty\" ? true : undefined}\n            aria-label={state === \"empty\" ? undefined : name}\n            aria-pressed={state === \"empty\" ? undefined : state === \"active\"}\n            aria-disabled={state === \"empty\" || playable ? undefined : true}\n            tabIndex={playable ? 0 : undefined}\n            data-pad={cell.index}\n            data-state={state}\n            data-highlighted={model?.highlighted ? \"\" : undefined}\n            data-choke={choke === undefined ? undefined : choke}\n            style={{\n              cursor: playable ? \"pointer\" : \"default\",\n              outline: \"none\",\n            }}\n            onPointerEnter={\n              playable ? () => onPadHoverChange?.(cell.index) : undefined\n            }\n            onPointerLeave={\n              playable ? () => onPadHoverChange?.(null) : undefined\n            }\n            onFocus={\n              playable ? () => onPadHoverChange?.(cell.index) : undefined\n            }\n            onBlur={playable ? () => onPadHoverChange?.(null) : undefined}\n            onPointerDown={\n              playable\n                ? (event) => {\n                    event.preventDefault()\n                    // Capture, so dragging off the pad still releases it.\n                    event.currentTarget.setPointerCapture?.(event.pointerId)\n                    press(cell, event)\n                  }\n                : undefined\n            }\n            onPointerUp={playable ? () => onPadUp?.(cell.index) : undefined}\n            onPointerCancel={playable ? () => onPadUp?.(cell.index) : undefined}\n            onLostPointerCapture={\n              playable ? () => onPadUp?.(cell.index) : undefined\n            }\n            onKeyDown={\n              playable\n                ? (event: KeyboardEvent<SVGGElement>) => {\n                    if (event.key !== \"Enter\" && event.key !== \" \") return\n                    if (event.repeat) return\n                    event.preventDefault()\n                    onPadDown?.(cell.index, {\n                      velocity: 1,\n                      offsetX: 0.5,\n                      offsetY: 0,\n                      centerOffset: 1,\n                    })\n                  }\n                : undefined\n            }\n            onKeyUp={\n              playable\n                ? (event: KeyboardEvent<SVGGElement>) => {\n                    if (event.key !== \"Enter\" && event.key !== \" \") return\n                    onPadUp?.(cell.index)\n                  }\n                : undefined\n            }\n          >\n            <rect\n              x={cell.x}\n              y={cell.y}\n              width={cell.width}\n              height={cell.height}\n              rx={radius}\n              ry={radius}\n              fill={state === \"idle\" ? `url(#${glowId}-cap)` : padFill(state)}\n              fillOpacity={\n                state === \"active\" ? 0.92 : state === \"empty\" ? 0.12 : 1\n              }\n              stroke={\n                state === \"active\"\n                  ? token.accent2\n                  : model?.highlighted\n                    ? token.accent\n                    : token.border\n              }\n              strokeWidth={state === \"active\" || model?.highlighted ? 1.6 : 1}\n              strokeDasharray={state === \"silent\" ? \"3 3\" : undefined}\n              style={\n                state === \"active\"\n                  ? { filter: `drop-shadow(0 0 6px ${token.accent})` }\n                  : undefined\n              }\n            />\n            {model?.highlighted && state !== \"empty\" ? (\n              <rect\n                x={cell.x}\n                y={cell.y}\n                width={cell.width}\n                height={cell.height}\n                rx={radius}\n                ry={radius}\n                fill={token.accent}\n                fillOpacity={0.16}\n                pointerEvents=\"none\"\n              />\n            ) : null}\n            {model?.waveform?.length ? (\n              <polyline\n                aria-hidden=\"true\"\n                points={model.waveform\n                  .slice(0, 128)\n                  .map(\n                    (value, index, envelope) =>\n                      `${cell.x + cell.width * 0.12 + (index / Math.max(1, envelope.length - 1)) * cell.width * 0.76},${cell.y + cell.height * 0.3 - Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)) * cell.height * 0.16}`\n                  )\n                  .join(\" \")}\n                fill=\"none\"\n                stroke={token.accent}\n                strokeWidth={1}\n                style={{ pointerEvents: \"none\" }}\n              />\n            ) : null}\n            {/* The choke tie: a short bar in the top-left corner, one per group.\n                Two pads wearing the same mark cannot sound together. */}\n            {showChoke && choke !== undefined && state !== \"empty\" ? (\n              <g style={{ pointerEvents: \"none\" }}>\n                <rect\n                  x={cell.x + 4}\n                  y={cell.y + 4}\n                  width={Math.max(4, cell.width * 0.16)}\n                  height={2}\n                  rx={1}\n                  fill={token.accent2}\n                  fillOpacity={0.75}\n                />\n                <rect\n                  x={cell.x + 4}\n                  y={cell.y + 4 + 3}\n                  width={\n                    Math.max(4, cell.width * 0.16) *\n                    (choke % 2 === 0 ? 0.55 : 1)\n                  }\n                  height={2}\n                  rx={1}\n                  fill={token.accent2}\n                  fillOpacity={0.45}\n                />\n              </g>\n            ) : null}\n            {model?.label ? (\n              <text\n                x={cell.cx}\n                y={labelY}\n                textAnchor=\"middle\"\n                fill={padTextColor(state)}\n                fontFamily={token.fontMono}\n                fontSize={Math.max(6, Math.min(12, cell.width * 0.22))}\n                fontWeight={600}\n                style={{ pointerEvents: \"none\", userSelect: \"none\" }}\n              >\n                {model.label}\n              </text>\n            ) : null}\n            {model?.sublabel ? (\n              <text\n                x={cell.cx}\n                y={model.waveform ? cell.y + cell.height * 0.84 : cell.cy + 9}\n                textAnchor=\"middle\"\n                fill={token.textMuted}\n                fontFamily={token.fontMono}\n                fontSize={Math.max(5, Math.min(9, cell.width * 0.16))}\n                style={{ pointerEvents: \"none\", userSelect: \"none\" }}\n              >\n                {model.sublabel}\n              </text>\n            ) : null}\n          </g>\n        )\n      })}\n    </svg>\n  )\n}\n\n/**\n * The pad under a client point, for a face that wants to drive the grid from a\n * pointer it is tracking itself (a drag across pads, a MIDI-learn overlay).\n *\n * Exported beside the component rather than only inside it because the answer\n * needs the layout, and re-deriving the layout at a call site is exactly the\n * drift this module exists to prevent.\n */\nexport function padGridHit(\n  config: {\n    rows: number\n    columns: number\n    width?: number\n    height?: number\n    gap?: number\n    padding?: number\n  },\n  clientX: number,\n  clientY: number,\n  rect: { left: number; top: number; width: number; height: number }\n): { cell: PadCell; strike: PadStrike } | null {\n  const layout = padGridLayout(config)\n  const point = padGridPoint(clientX, clientY, rect, layout)\n  const cell = hitTestPadGrid(layout, point.x, point.y)\n  if (!cell) return null\n  return { cell, strike: padStrike(cell, point.x, point.y) }\n}\n",
      "type": "registry:component",
      "target": "@ui/pad-grid.tsx"
    }
  ]
}
