{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-radial-pad-surface",
  "type": "registry:ui",
  "title": "Radial Pad Surface",
  "description": "A playable disc: a centre pad ringed by wedge-shaped zones, labelled and lit from the state you pass in. Presses report how far from the zone's centre they landed, so a tap has a velocity. Includes a first-class look for a pad that is deliberately silent.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/radial-pad-surface.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/radial-pad-surface\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 * RadialPadSurface — a playable disc: a centre pad ringed by wedge-shaped\n * zones.\n *\n * The layout an idiophone you play with your hands actually has. A handpan is\n * a ding in the middle and eight or nine tone fields around it; a tongue drum,\n * a rotary zone selector and a circular drum-pad bank are the same picture with\n * different labels. This draws that picture and reports presses on it, and it\n * does neither with any geometry of its own — every path, every touch target\n * and every label anchor comes out of `radial-pad-geometry`, so the ink and the\n * hit areas cannot drift apart.\n *\n * PRESENTATIONAL AND CONTROLLED. It holds no note state. `zones[i].state` says\n * what zone `i` looks like right now and the consumer owns that array; presses\n * go out through `onZoneDown` / `onZoneUp`. A face that lights its pads from an\n * engine's live note map passes the map straight in, and nothing here can\n * disagree with the engine about what is sounding.\n *\n * THE `silent` STATE is the one worth explaining. A tuned percussion instrument\n * legitimately has no note under some inputs, and a pad that produces nothing\n * is indistinguishable from a broken one unless the surface says otherwise. So\n * `silent` is a first-class look — struck, acknowledged, no note there — and it\n * is deliberately not an error colour: the player did nothing wrong.\n *\n * Velocity from position comes free: `onZoneDown` reports how far from the\n * zone's own centre the press landed (`offset`, 0 at the middle) and its\n * complement (`strength`), which a face can map onto strike force. Real, in\n * that the middle of a tone field is the antinode a player aims for.\n *\n * Keyboard: each zone is a `role=\"button\"` in the tab order; Enter or Space\n * presses it and releasing the key releases the note, so a held key is a held\n * note. Presses from the keyboard report `offset` 0 — dead centre, the only\n * honest reading when there is no pointer.\n */\n\nimport { useCallback, useId, useMemo, useRef } from \"react\"\nimport type { CSSProperties, ReactNode } from \"react\"\nimport {\n  radialPadLayout,\n  radialZonePath,\n  surfacePoint,\n  toPolar,\n  zoneStrikeOffset,\n  type RadialPadLayout,\n  type RadialRingSpec,\n  type RadialZone,\n} from \"maruaudio/ui/shared/radial-pad-geometry\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  useNoteTriggerArea,\n  type NoteTriggerStart,\n} from \"maruaudio/ui/shared/use-note-trigger-area\"\n\n/** What a zone is doing, and therefore how it is drawn. */\nexport type RadialZoneState =\n  /** Present and quiet. */\n  | \"idle\"\n  /** Sounding now. */\n  | \"active\"\n  /** Pressed, and there is deliberately no note here. */\n  | \"silent\"\n  /** No note assigned at all — drawn, but not playable. */\n  | \"empty\"\n\n/** One zone's content. Index-aligned with the resolved layout. */\nexport interface RadialPadZone {\n  /** Big label in the middle of the zone — a note name, typically. */\n  label?: string\n  /** Small second line under it — a degree, a frequency, an index. */\n  sublabel?: string\n  state?: RadialZoneState\n  /** Overrides the generated accessible name. */\n  ariaLabel?: string\n}\n\n/** What a press reports back. */\nexport interface RadialPadStrike {\n  /** 0 at the zone's centre, 1 at its boundary. */\n  offset: number\n  /** `1 - offset`. The natural velocity scaler. */\n  strength: number\n  /** The resolved zone, for a face that wants its angle or radius. */\n  zone: RadialZone\n}\n\nexport interface RadialPadSurfaceProps {\n  /** Centre pad radius as a fraction of the surface radius. 0 omits it. */\n  center?: number\n  /** The rings of zones outside the centre pad. */\n  rings?: RadialRingSpec[]\n  /** viewBox side. The rendered size is CSS's business. Defaults to 240. */\n  size?: number\n  /** Inset inside the viewBox, in viewBox units. */\n  padding?: number\n  /**\n   * Zone content, index-aligned with the layout the config resolves to\n   * (centre first, then each ring in order). Short arrays leave the remaining\n   * zones empty rather than throwing — a surface half filled in during\n   * development still renders.\n   */\n  zones?: RadialPadZone[]\n  onZoneDown?: (index: number, strike: RadialPadStrike) => void\n  onZoneUp?: (index: number) => void\n  disabled?: boolean\n  /** Draw the shell rim and its inner highlight. Defaults to true. */\n  rim?: boolean\n  /** Anything to render under the zones — a texture, a hammer pattern. */\n  children?: ReactNode\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\n/** Per-state fill/stroke, all reading kit tokens so a skin restyles them. */\nfunction zoneFill(state: RadialZoneState): 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 zoneStroke(state: RadialZoneState): string {\n  return state === \"active\" ? token.accent2 : token.border\n}\n\nfunction zoneTextColor(state: RadialZoneState): string {\n  return state === \"active\" || state === \"idle\" ? token.text : token.textMuted\n}\n\nfunction RadialZoneControl({\n  zone,\n  model,\n  disabled,\n  layout,\n  svgRef,\n  onZoneDown,\n  onZoneUp,\n}: {\n  zone: RadialZone\n  model: RadialPadZone | undefined\n  disabled: boolean\n  layout: RadialPadLayout\n  svgRef: React.RefObject<SVGSVGElement | null>\n  onZoneDown?: RadialPadSurfaceProps[\"onZoneDown\"]\n  onZoneUp?: RadialPadSurfaceProps[\"onZoneUp\"]\n}) {\n  const state = model?.state ?? (model?.label ? \"idle\" : \"empty\")\n  const playable = !disabled && state !== \"empty\"\n\n  const trigger = useCallback(\n    (start: NoteTriggerStart) => {\n      let offset = 0\n      if (start.input === \"pointer\") {\n        const rect = svgRef.current?.getBoundingClientRect()\n        // No rect means the surface has not been measured yet (a synthetic\n        // event in a test, or a press in the same frame as mount). Dead centre\n        // is the honest fallback, as it is for keyboard input.\n        if (rect) {\n          const point = surfacePoint(\n            start.event.clientX,\n            start.event.clientY,\n            rect,\n            layout.size\n          )\n          offset = zoneStrikeOffset(\n            zone,\n            toPolar(point.x, point.y, layout.cx, layout.cy)\n          )\n        }\n      }\n      onZoneDown?.(zone.index, { offset, strength: 1 - offset, zone })\n    },\n    [layout, onZoneDown, svgRef, zone]\n  )\n\n  const { triggerProps } = useNoteTriggerArea({\n    disabled: !playable,\n    onTrigger: trigger,\n    onRelease: () => onZoneUp?.(zone.index),\n  })\n\n  const name =\n    model?.ariaLabel ??\n    (model?.label\n      ? `Pad ${zone.index + 1}, ${model.label}${\n          state === \"silent\" ? \", silent\" : \"\"\n        }`\n      : `Pad ${zone.index + 1}, empty`)\n  const labelY = zone.ring < 0 ? layout.cy : zone.cy\n\n  return (\n    <g\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-zone={zone.index}\n      data-ring={zone.ring}\n      data-state={state}\n      style={{ cursor: playable ? \"pointer\" : \"default\", outline: \"none\" }}\n      {...(playable ? triggerProps : {})}\n    >\n      <path\n        d={radialZonePath(layout, zone)}\n        fill={zoneFill(state)}\n        fillOpacity={state === \"active\" ? 0.9 : state === \"empty\" ? 0.15 : 0.75}\n        stroke={zoneStroke(state)}\n        strokeWidth={state === \"active\" ? 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?.label ? (\n        <text\n          x={zone.cx}\n          y={labelY + (model.sublabel ? -2 : 3)}\n          textAnchor=\"middle\"\n          fill={zoneTextColor(state)}\n          fontFamily={token.fontMono}\n          fontSize={zone.ring < 0 ? 14 : 11}\n          fontWeight={600}\n          style={{ pointerEvents: \"none\", userSelect: \"none\" }}\n        >\n          {model.label}\n        </text>\n      ) : null}\n      {model?.sublabel ? (\n        <text\n          x={zone.cx}\n          y={labelY + 9}\n          textAnchor=\"middle\"\n          fill={token.textMuted}\n          fontFamily={token.fontMono}\n          fontSize={zone.ring < 0 ? 8 : 7}\n          style={{ pointerEvents: \"none\", userSelect: \"none\" }}\n        >\n          {model.sublabel}\n        </text>\n      ) : null}\n    </g>\n  )\n}\n\nexport function RadialPadSurface({\n  center = 0.26,\n  rings,\n  size = 240,\n  padding = 4,\n  zones,\n  onZoneDown,\n  onZoneUp,\n  disabled = false,\n  rim = true,\n  children,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: RadialPadSurfaceProps) {\n  const glowId = useId()\n  const svgRef = useRef<SVGSVGElement | null>(null)\n\n  const ringSpecs = useMemo<RadialRingSpec[]>(\n    () => rings ?? [{ count: 8, inner: 0.44, outer: 0.95, gap: 5 }],\n    [rings]\n  )\n  const layout = useMemo(\n    () => radialPadLayout({ center, rings: ringSpecs, size, padding }),\n    [center, ringSpecs, size, padding]\n  )\n\n  return (\n    <svg\n      ref={svgRef}\n      viewBox={`0 0 ${layout.size} ${layout.size}`}\n      className={className}\n      style={{ display: \"block\", width: \"100%\", touchAction: \"none\", ...style }}\n      role=\"group\"\n      aria-label={ariaLabel ?? \"Radial pad surface\"}\n    >\n      <defs>\n        <radialGradient id={`${glowId}-shell`} cx=\"38%\" cy=\"30%\" r=\"80%\">\n          <stop offset=\"0%\" stopColor={token.cap} stopOpacity=\"0.95\" />\n          <stop offset=\"100%\" stopColor={token.surface} stopOpacity=\"1\" />\n        </radialGradient>\n      </defs>\n\n      {rim ? (\n        <>\n          <circle\n            cx={layout.cx}\n            cy={layout.cy}\n            r={layout.radius}\n            fill={`url(#${glowId}-shell)`}\n            stroke={token.border}\n            strokeWidth={1}\n          />\n          <circle\n            cx={layout.cx}\n            cy={layout.cy}\n            r={layout.radius - 3}\n            fill=\"none\"\n            stroke={token.border}\n            strokeOpacity={0.5}\n            strokeWidth={0.75}\n          />\n        </>\n      ) : null}\n\n      {children}\n\n      {layout.zones.map((zone) => (\n        <RadialZoneControl\n          key={zone.index}\n          zone={zone}\n          model={zones?.[zone.index]}\n          disabled={disabled}\n          layout={layout}\n          svgRef={svgRef}\n          onZoneDown={onZoneDown}\n          onZoneUp={onZoneUp}\n        />\n      ))}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/radial-pad-surface.tsx"
    }
  ]
}
