{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-triangle-pad",
  "type": "registry:ui",
  "title": "Triangle Pad",
  "description": "A ternary control surface driven by two normalised inputs. The default apex/base formula uses Y for distance from A and X to balance B against C. An alternate formula maps 00, 01 and 10 to the corners and 0.5,0.5 to the equal blend. Both report the same post-computed three-corner weights.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/triangle-pad.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/triangle-pad\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 * TrianglePad — an agnostic ternary control surface.\n *\n * The input is two normalised slider/CC values. The surface turns them into a\n * three-weight tuple and reports both. It knows nothing about sounds, banks or\n * synthesis; callers decide what the three corners mean.\n * Optional lattice and selected-point layers let an instrument show discrete\n * material underneath the continuous control without owning the geometry.\n */\n\nimport { useCallback, useId, useRef } from \"react\"\nimport type { CSSProperties, KeyboardEvent, PointerEvent } from \"react\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  triangleLattice,\n  trianglePointToWeights,\n  triangleAxesToWeights,\n  triangleWeightsToAxes,\n  triangleWeightsToPoint,\n  DEFAULT_TRIANGLE_MAPPING,\n  type TriangleAxes,\n  type TriangleMapping,\n  type TriangleWeights,\n} from \"maruaudio/ui/shared/triangle-pad-geometry\"\n\nconst SIZE = 420\nconst PAD = 46\nconst INNER = SIZE - PAD * 2\nconst HEIGHT = INNER * (Math.sqrt(3) / 2)\nconst VIEW_HEIGHT = PAD * 2 + HEIGHT\nconst CORNERS: readonly TriangleWeights[] = [\n  [1, 0, 0],\n  [0, 1, 0],\n  [0, 0, 1],\n]\nconst DEFAULT_COLORS = [\"#f472b6\", \"#60a5fa\", \"#34d399\"] as const\n\nfunction project(weights: TriangleWeights): { x: number; y: number } {\n  const point = triangleWeightsToPoint(weights)\n  return { x: PAD + point.x * INNER, y: PAD + point.y * HEIGHT }\n}\n\nfunction mixedColor(\n  weights: TriangleWeights,\n  colors: readonly [string, string, string]\n) {\n  // Color interpolation is intentionally left to CSS when custom colours are\n  // supplied; the default RGB mix keeps the centre neutral and predictable.\n  if (colors !== DEFAULT_COLORS) return token.accent\n  const rgb = [\n    [244, 114, 182],\n    [96, 165, 250],\n    [52, 211, 153],\n  ]\n  const channels = [0, 1, 2].map((channel) =>\n    Math.round(\n      rgb.reduce(\n        (sum, color, index) => sum + color[channel]! * weights[index]!,\n        0\n      )\n    )\n  )\n  return `rgb(${channels[0]} ${channels[1]} ${channels[2]})`\n}\n\nexport interface TrianglePadProps {\n  /** Two normalised slider/CC inputs interpreted by `mapping`. */\n  value: TriangleAxes\n  /** Projection formula. Defaults to the original apex/base balance model. */\n  mapping?: TriangleMapping\n  /** Reports both the two inputs and the post-computed three-corner weights. */\n  onChange: (value: TriangleAxes, weights: TriangleWeights) => void\n  onChangeEnd?: (value: TriangleAxes, weights: TriangleWeights) => void\n  cornerLabels?: readonly [string, string, string]\n  cornerColors?: readonly [string, string, string]\n  /** Regular lattice subdivisions. 0 hides the lattice. */\n  subdivisions?: number\n  /** A separate, read-only position highlighted behind the live cursor. */\n  selectedWeights?: TriangleWeights | null\n  /**\n   * What fills the triangle's body.\n   *\n   * `\"surface\"` (the default, and what every existing caller gets) paints the\n   * theme's surface token — an opaque plate the control sits on.\n   *\n   * `\"blend\"` paints nothing of its own and instead lays the three corner\n   * colours over each other as radial gradients, so the body IS the mix the\n   * weights describe and whatever is behind the pad shows through. That is the\n   * point for an instrument whose backdrop is part of the instrument: the\n   * 2026-08-22 UX review's \"remove white from the triangle, the visualisation\n   * of colour disappeared\" is exactly this mode.\n   */\n  bodyFill?: \"surface\" | \"blend\"\n  /**\n   * Per-corner readouts drawn beside each corner label, e.g. `[\"33%\", …]`.\n   *\n   * Opt-in: without it the corners carry their labels alone, as before. It\n   * exists so a caller does not have to position three absolutely-placed spans\n   * over the SVG to say what every corner's share currently is — the geometry\n   * that knows where the corners are is in here.\n   */\n  cornerValues?: readonly [string, string, string] | null\n  /** Transient note-on positions, rendered above the cursor as fading hits. */\n  strikeMarkers?: readonly {\n    readonly id: number\n    readonly weights: TriangleWeights\n  }[]\n  /** Circular landing radius in triangle-side lengths. */\n  randomRadius?: number\n  /** Optional center for the landing radius when it differs from the cursor. */\n  randomCenterWeights?: TriangleWeights | null\n  disabled?: boolean\n  width?: number | string\n  className?: string\n  style?: CSSProperties\n  \"aria-label\"?: string\n}\n\nexport function TrianglePad({\n  value,\n  mapping = DEFAULT_TRIANGLE_MAPPING,\n  onChange,\n  onChangeEnd,\n  cornerLabels = [\"A\", \"B\", \"C\"],\n  cornerColors = DEFAULT_COLORS,\n  subdivisions = 0,\n  selectedWeights = null,\n  bodyFill = \"surface\",\n  cornerValues = null,\n  strikeMarkers = [],\n  randomRadius = 0,\n  randomCenterWeights = null,\n  disabled = false,\n  width = \"100%\",\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: TrianglePadProps) {\n  const svgRef = useRef<SVGSVGElement | null>(null)\n  const radiusClipId = useId().replace(/:/g, \"\")\n  const dragging = useRef(false)\n  const currentAxes = {\n    x: Math.min(1, Math.max(0, value.x)),\n    y: Math.min(1, Math.max(0, value.y)),\n  }\n  const currentWeights = triangleAxesToWeights(currentAxes, mapping)\n  const latest = useRef({ axes: currentAxes, weights: currentWeights })\n  latest.current = { axes: currentAxes, weights: currentWeights }\n\n  const valueAtPointer = useCallback((event: PointerEvent<SVGSVGElement>) => {\n    const rect = svgRef.current?.getBoundingClientRect()\n    if (!rect || rect.width === 0 || rect.height === 0) return null\n    const viewX = ((event.clientX - rect.left) / rect.width) * SIZE\n    const viewY = ((event.clientY - rect.top) / rect.height) * VIEW_HEIGHT\n    return trianglePointToWeights((viewX - PAD) / INNER, (viewY - PAD) / HEIGHT)\n  }, [])\n\n  const handlePointerDown = (event: PointerEvent<SVGSVGElement>) => {\n    if (disabled) return\n    dragging.current = true\n    event.currentTarget.setPointerCapture(event.pointerId)\n    const next = valueAtPointer(event)\n    if (next) onChange(triangleWeightsToAxes(next, mapping), next)\n  }\n\n  const handlePointerMove = (event: PointerEvent<SVGSVGElement>) => {\n    if (disabled || !dragging.current) return\n    const next = valueAtPointer(event)\n    if (next) onChange(triangleWeightsToAxes(next, mapping), next)\n  }\n\n  const finishPointer = (event: PointerEvent<SVGSVGElement>) => {\n    if (!dragging.current) return\n    dragging.current = false\n    if (event.currentTarget.hasPointerCapture(event.pointerId))\n      event.currentTarget.releasePointerCapture(event.pointerId)\n    onChangeEnd?.(latest.current.axes, latest.current.weights)\n  }\n\n  const handleKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {\n    if (disabled) return\n    const point = triangleWeightsToPoint(currentWeights)\n    const step = event.shiftKey ? 1 / 80 : 1 / Math.max(1, subdivisions || 20)\n    let x = point.x\n    let y = point.y\n    if (event.key === \"ArrowLeft\") x -= step\n    else if (event.key === \"ArrowRight\") x += step\n    else if (event.key === \"ArrowUp\") y -= step\n    else if (event.key === \"ArrowDown\") y += step\n    else return\n    event.preventDefault()\n    const weights = trianglePointToWeights(x, y)\n    onChange(triangleWeightsToAxes(weights, mapping), weights)\n  }\n\n  const outline = CORNERS.map((corner) => {\n    const point = project(corner)\n    return `${point.x},${point.y}`\n  }).join(\" \")\n  const cursor = project(currentWeights)\n  const selected = selectedWeights ? project(selectedWeights) : null\n  const randomCenter = randomCenterWeights\n    ? project(randomCenterWeights)\n    : cursor\n  const lattice = subdivisions > 0 ? triangleLattice(subdivisions) : []\n\n  return (\n    <svg\n      ref={svgRef}\n      viewBox={`0 0 ${SIZE} ${VIEW_HEIGHT}`}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      role=\"application\"\n      tabIndex={disabled ? -1 : 0}\n      aria-label={ariaLabel ?? \"Triangle pad\"}\n      aria-disabled={disabled || undefined}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={finishPointer}\n      onPointerCancel={finishPointer}\n      onKeyDown={handleKeyDown}\n      style={{\n        display: \"block\",\n        width,\n        maxWidth: SIZE,\n        touchAction: \"none\",\n        userSelect: \"none\",\n        outline: \"none\",\n        cursor: disabled ? \"not-allowed\" : \"crosshair\",\n        opacity: disabled ? 0.5 : 1,\n        fontFamily: token.font,\n        ...style,\n      }}\n    >\n      <defs>\n        <clipPath id={radiusClipId}>\n          <polygon points={outline} />\n        </clipPath>\n        {/* One soft lobe per corner, centred on that corner and reaching past\n            the opposite edge. Painted additively (`screen` would blow out on a\n            light face; plain alpha over alpha reads as paint mixing), they make\n            the body's colour at any point a rough barycentric mix — which is\n            what the surface MEANS. Only built in blend mode: three unused\n            gradient defs on every other pad would be pure cost. */}\n        {bodyFill === \"blend\"\n          ? CORNERS.map((corner, index) => {\n              const point = project(corner)\n              return (\n                <radialGradient\n                  key={index}\n                  id={`${radiusClipId}-corner-${index}`}\n                  gradientUnits=\"userSpaceOnUse\"\n                  cx={point.x}\n                  cy={point.y}\n                  r={INNER}\n                >\n                  <stop\n                    offset=\"0%\"\n                    stopColor={cornerColors[index]}\n                    stopOpacity={0.62}\n                  />\n                  <stop\n                    offset=\"100%\"\n                    stopColor={cornerColors[index]}\n                    stopOpacity={0}\n                  />\n                </radialGradient>\n              )\n            })\n          : null}\n      </defs>\n\n      {bodyFill === \"blend\" ? (\n        <g clipPath={`url(#${radiusClipId})`} pointerEvents=\"none\">\n          {CORNERS.map((_corner, index) => (\n            <polygon\n              key={index}\n              points={outline}\n              fill={`url(#${radiusClipId}-corner-${index})`}\n            />\n          ))}\n        </g>\n      ) : null}\n      <polygon\n        points={outline}\n        fill={bodyFill === \"blend\" ? \"none\" : token.surface}\n        stroke={token.border}\n        strokeWidth={bodyFill === \"blend\" ? 1.5 : 1}\n      />\n\n      {randomRadius > 0 && (\n        <circle\n          cx={randomCenter.x}\n          cy={randomCenter.y}\n          r={Math.min(1, randomRadius) * INNER}\n          fill={token.accent}\n          fillOpacity={0.07}\n          stroke={token.accent}\n          strokeWidth={1.5}\n          strokeDasharray=\"6 5\"\n          opacity={0.8}\n          clipPath={`url(#${radiusClipId})`}\n          pointerEvents=\"none\"\n          aria-hidden=\"true\"\n        />\n      )}\n\n      {lattice.map((weights, index) => {\n        const point = project(weights)\n        return (\n          <circle\n            key={index}\n            cx={point.x}\n            cy={point.y}\n            r={3}\n            fill=\"none\"\n            stroke={token.border}\n          />\n        )\n      })}\n\n      {selected && (\n        <circle\n          cx={selected.x}\n          cy={selected.y}\n          r={7}\n          fill=\"none\"\n          stroke={token.text}\n          strokeWidth={2}\n          opacity={0.7}\n        />\n      )}\n\n      <circle\n        cx={cursor.x}\n        cy={cursor.y}\n        r={14}\n        fill=\"none\"\n        stroke={token.text}\n        strokeWidth={1.5}\n      />\n      <circle\n        cx={cursor.x}\n        cy={cursor.y}\n        r={9}\n        fill={mixedColor(currentWeights, cornerColors)}\n      />\n\n      {strikeMarkers.map((marker) => {\n        const strike = project(marker.weights)\n        return (\n          <g key={marker.id} pointerEvents=\"none\" aria-hidden=\"true\">\n            <circle\n              cx={strike.x}\n              cy={strike.y}\n              r={12}\n              fill=\"none\"\n              stroke={token.text}\n              strokeWidth={2.5}\n              opacity={0.95}\n            >\n              <animate\n                attributeName=\"r\"\n                values=\"9;14;9\"\n                dur=\"760ms\"\n                repeatCount=\"indefinite\"\n              />\n            </circle>\n            <circle\n              cx={strike.x}\n              cy={strike.y}\n              r={6.5}\n              fill={mixedColor(marker.weights, cornerColors)}\n              stroke={token.surface}\n              strokeWidth={2}\n            />\n          </g>\n        )\n      })}\n\n      {CORNERS.map((corner, index) => {\n        const point = project(corner)\n        // Apex reads upward, the two base corners downward; the value line\n        // stacks on the far side of the label from the triangle so neither\n        // ever sits on the body it describes.\n        const apex = index === 0\n        const labelY = apex ? point.y - 18 : point.y + 26\n        const valueY = apex ? labelY - 15 : labelY + 15\n        return (\n          <g key={index}>\n            <text\n              x={point.x}\n              y={labelY}\n              textAnchor=\"middle\"\n              fill={cornerColors[index]}\n              fontSize={13}\n              fontWeight={600}\n            >\n              {cornerLabels[index]}\n            </text>\n            {cornerValues === null ? null : (\n              <text\n                x={point.x}\n                y={valueY}\n                textAnchor=\"middle\"\n                fill={cornerColors[index]}\n                fontSize={12}\n                fontWeight={700}\n                opacity={0.85}\n              >\n                {cornerValues[index]}\n              </text>\n            )}\n          </g>\n        )\n      })}\n    </svg>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/triangle-pad.tsx"
    }
  ]
}
