{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "knob",
  "title": "Knob",
  "description": "A rotary control drawn in SVG, with vertical, horizontal or circular dragging, fine adjustment, reset, a bipolar origin, an optional click sound and a volume dial skin with a numbered scale and an aluminium cap, and a mini aluminium cap for small knobs.",
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "@audiocn/core",
    "@audiocn/use-audio-config",
    "@audiocn/use-audio-context"
  ],
  "files": [
    {
      "path": "components/ui/knob.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useEffectEvent,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type {\n  ComponentProps,\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent,\n  ReactNode,\n  RefObject,\n} from \"react\";\n\nimport { useAudioConfig } from \"@/hooks/use-audio-config\";\nimport type { AudioSize } from \"@/hooks/use-audio-config\";\nimport { useProvidedAudioContext } from \"@/hooks/use-audio-context\";\nimport { createClickSound } from \"@/lib/audio/click\";\nimport type { ClickSound } from \"@/lib/audio/click\";\nimport { clamp } from \"@/lib/audio/decibels\";\nimport { linearTaper, logTaper } from \"@/lib/audio/taper\";\nimport type { Taper } from \"@/lib/audio/types\";\nimport { cn } from \"@/lib/utils\";\n\nconst VIEWBOX = 100;\nconst CENTER = 50;\nconst RADIUS = 40;\nconst FINE_FACTOR = 0.1;\nconst PRECISION = 1e6;\nconst DEGREES_TO_RADIANS = Math.PI / 180;\nconst HALF_TURN = 180;\n\nexport type KnobChangeReason =\n  | \"drag\"\n  | \"keyboard\"\n  | \"wheel\"\n  | \"reset\"\n  | \"input\";\n\nexport interface KnobChangeDetails {\n  reason: KnobChangeReason;\n  event?: Event;\n}\n\ninterface KnobContextValue {\n  value: number;\n  position: number;\n  originPosition: number;\n  arc: number;\n  labelId: string;\n  disabled: boolean;\n  format: (value: number) => string;\n  parse: (text: string) => number | null;\n  /** Characters in the widest value, for a steady value label. */\n  valueWidth: number;\n  /** A typed value is being entered in KnobValue. */\n  editing: boolean;\n  setEditing: (editing: boolean) => void;\n}\n\nconst KnobContext = createContext<KnobContextValue | null>(null);\n\nconst useKnob = (part: string) => {\n  const context = useContext(KnobContext);\n  if (!context) {\n    throw new Error(`${part} must be used inside Knob.`);\n  }\n  return context;\n};\n\nexport interface DialProviderProps {\n  /** The turn shown by the cap and pointer: degrees clockwise from 12 o'clock. */\n  angle: number;\n  /** The cap inside reports its outer radius, or null when it goes away. */\n  onCapRadiusChange?: (radius: number | null) => void;\n  children?: ReactNode;\n}\n\ntype DialContextValue = Omit<DialProviderProps, \"children\">;\n\nconst DialContext = createContext<DialContextValue | null>(null);\n\n/**\n * Lets KnobCap and KnobPointer turn inside any dial, not only a Knob. A dial\n * that hugs its cap sizes its drag area from `onCapRadiusChange`.\n */\nexport const DialProvider = ({\n  angle,\n  onCapRadiusChange,\n  children,\n}: DialProviderProps) => {\n  const value = useMemo(\n    () => ({ angle, onCapRadiusChange }),\n    [angle, onCapRadiusChange]\n  );\n  return <DialContext.Provider value={value}>{children}</DialContext.Provider>;\n};\n\nconst useDial = (part: string) => {\n  const context = useContext(DialContext);\n  if (!context) {\n    throw new Error(`${part} must be used inside a dial, like KnobDial.`);\n  }\n  return context;\n};\n\ninterface KnobDialContextValue {\n  change: (value: number, details: KnobChangeDetails) => void;\n  commit: (value: number) => void;\n  dragDirection: \"vertical\" | \"horizontal\" | \"circular\";\n  fineStep: number;\n  largeStep: number;\n  latestRef: RefObject<number>;\n  max: number;\n  min: number;\n  quantize: (value: number, increment: number) => number;\n  resetValue: number;\n  sensitivity: number;\n  step: number;\n  taper: Taper;\n  allowWheel: boolean;\n  /** KnobScale reports its long ticks, so the click sound lands on them. */\n  setDetents: (positions: readonly number[] | null) => void;\n}\n\nconst KnobDialContext = createContext<KnobDialContextValue | null>(null);\n\nconst useKnobDial = () => {\n  const context = useContext(KnobDialContext);\n  if (!context) {\n    throw new Error(\"KnobDial must be used inside Knob.\");\n  }\n  return context;\n};\n\nconst roundValue = (value: number) => Math.round(value * PRECISION) / PRECISION;\n\n/** Points along the range sampled to find the widest value text. */\nconst WIDTH_SAMPLES = 24;\n\n/** Characters in the widest value along the range, so the value keeps one width. */\nconst widestValue = (\n  format: (value: number) => string,\n  taper: Taper,\n  snap: (value: number) => number\n) => {\n  let widest = 0;\n  for (let index = 0; index <= WIDTH_SAMPLES; index += 1) {\n    const sample = snap(taper.toValue(index / WIDTH_SAMPLES));\n    widest = Math.max(widest, format(sample).length);\n  }\n  return widest;\n};\n\nconst NUMBER = /[-+]?(?:\\d+\\.?\\d*|\\.\\d+)/u;\nconst THOUSANDS = /\\d\\s*k/iu;\nconst THOUSAND = 1000;\n\n/** The first number in the text. \"−\" counts as a minus and \"k\" as thousands. */\nexport const parseKnobValue = (text: string): number | null => {\n  const normalized = text.replaceAll(\"\\u2212\", \"-\");\n  const match = NUMBER.exec(normalized);\n  if (!match) {\n    return null;\n  }\n  const number = Number(match[0]);\n  return THOUSANDS.test(normalized) ? number * THOUSAND : number;\n};\n\nconst angleFor = (position: number, arc: number) => -arc / 2 + position * arc;\n\n/** Coordinates are rounded so server and browser trigonometry agree on hydration. */\nconst COORDINATE_PRECISION = 1e4;\nconst roundCoordinate = (value: number) =>\n  Math.round(value * COORDINATE_PRECISION) / COORDINATE_PRECISION;\n\nconst pointAt = (angle: number, radius: number) => {\n  const radians = angle * DEGREES_TO_RADIANS;\n  return {\n    x: roundCoordinate(CENTER + radius * Math.sin(radians)),\n    y: roundCoordinate(CENTER - radius * Math.cos(radians)),\n  };\n};\n\nconst arcPath = (fromAngle: number, toAngle: number, radius = RADIUS) => {\n  const start = Math.min(fromAngle, toAngle);\n  const end = Math.max(fromAngle, toAngle);\n  if (end - start < 0.01) {\n    return \"\";\n  }\n  const from = pointAt(start, radius);\n  const to = pointAt(end, radius);\n  const largeArc = end - start > HALF_TURN ? 1 : 0;\n  return `M ${from.x} ${from.y} A ${radius} ${radius} 0 ${largeArc} 1 ${to.x} ${to.y}`;\n};\n\n/** A ref that always holds the latest value, for event handlers. */\nconst useLatest = <T,>(value: T): RefObject<T> => {\n  const ref = useRef(value);\n  useLayoutEffect(() => {\n    ref.current = value;\n  });\n  return ref;\n};\n\nconst keyTarget = (\n  key: string,\n  current: number,\n  increment: number,\n  dial: KnobDialContextValue\n): number | null => {\n  const targets: Record<string, number> = {\n    ArrowDown: current - increment,\n    ArrowLeft: current - increment,\n    ArrowRight: current + increment,\n    ArrowUp: current + increment,\n    End: dial.max,\n    Home: dial.min,\n    PageDown: current - dial.largeStep,\n    PageUp: current + dial.largeStep,\n  };\n  return targets[key] ?? null;\n};\n\nconst incrementFor = (\n  event: { altKey: boolean; shiftKey: boolean },\n  dial: KnobDialContextValue\n) => {\n  if (event.altKey) {\n    return dial.fineStep;\n  }\n  return event.shiftKey ? dial.largeStep : dial.step;\n};\n\ninterface DragState {\n  x: number;\n  y: number;\n  position: number;\n  /** The pointer's angle around the dial, or null too close to its centre. */\n  angle: number | null;\n}\n\n/** Share of the dial's radius around its centre where the angle is too jumpy to read. */\nconst DEAD_ZONE = 0.25;\n\n/** The pointer's angle around the dial centre, clockwise from 12 o'clock. */\nconst pointerAngle = (\n  event: { clientX: number; clientY: number },\n  element: HTMLElement\n): number | null => {\n  const rect = element.getBoundingClientRect();\n  const x = event.clientX - (rect.left + rect.width / 2);\n  const y = event.clientY - (rect.top + rect.height / 2);\n  if (Math.hypot(x, y) < (rect.width / 2) * DEAD_ZONE) {\n    return null;\n  }\n  return Math.atan2(x, -y) / DEGREES_TO_RADIANS;\n};\n\n/** The pointer's angle for circular drags; other drags only need its position. */\nconst dragAngle = (\n  event: PointerEvent<HTMLDivElement>,\n  dial: KnobDialContextValue\n) =>\n  dial.dragDirection === \"circular\"\n    ? pointerAngle(event, event.currentTarget)\n    : null;\n\n/** The shortest turn from one angle to another, in -180..180 degrees. */\nconst turnBetween = (from: number, to: number) =>\n  ((to - from + HALF_TURN * 3) % (HALF_TURN * 2)) - HALF_TURN;\n\n/**\n * The next position, from the movement since the last pointer event, so\n * pressing or releasing Shift mid-drag changes the speed without a jump.\n * Circular drags add the turn since the last event, so the knob turns from\n * where it is grabbed and stops at its ends instead of wrapping across the gap.\n */\nconst dragPosition = (\n  event: PointerEvent<HTMLDivElement>,\n  last: DragState,\n  angle: number | null,\n  dial: KnobDialContextValue,\n  arc: number\n) => {\n  const fine = event.shiftKey ? FINE_FACTOR : 1;\n  if (dial.dragDirection === \"circular\") {\n    if (last.angle === null || angle === null) {\n      return last.position;\n    }\n    const turn = turnBetween(last.angle, angle);\n    return clamp(last.position + (turn / arc) * fine, 0, 1);\n  }\n  const delta =\n    dial.dragDirection === \"vertical\"\n      ? last.y - event.clientY\n      : event.clientX - last.x;\n  return clamp(last.position + (delta / dial.sensitivity) * fine, 0, 1);\n};\n\nconst useDialWheel = (\n  elementRef: RefObject<HTMLDivElement | null>,\n  dial: KnobDialContextValue,\n  disabled: boolean\n) => {\n  const { allowWheel } = dial;\n  const onWheel = useEffectEvent(\n    (event: WheelEvent, element: HTMLDivElement) => {\n      // Shift turns the wheel sideways on some systems.\n      const delta = event.deltaY || event.deltaX;\n      if (disabled || document.activeElement !== element || delta === 0) {\n        return;\n      }\n      event.preventDefault();\n      const fine = event.shiftKey || event.altKey;\n      const increment = fine ? dial.fineStep : dial.step;\n      const direction = delta < 0 ? 1 : -1;\n      const next = dial.quantize(\n        dial.latestRef.current + direction * increment,\n        increment\n      );\n      dial.change(next, { event, reason: \"wheel\" });\n      dial.commit(next);\n    }\n  );\n\n  // A non-passive listener blocks scrolling, so only attach one when the\n  // wheel is allowed.\n  useEffect(() => {\n    const element = elementRef.current;\n    if (!(element && allowWheel)) {\n      return;\n    }\n    const listener = (event: WheelEvent) => {\n      onWheel(event, element);\n    };\n    element.addEventListener(\"wheel\", listener, { passive: false });\n    return () => {\n      element.removeEventListener(\"wheel\", listener);\n    };\n  }, [allowWheel, elementRef]);\n};\n\nexport const KnobDial = ({\n  className,\n  children,\n  style,\n  ...props\n}: ComponentProps<\"div\">) => {\n  const { arc, disabled, format, labelId, position, setEditing, value } =\n    useKnob(\"KnobDial\");\n  const dial = useKnobDial();\n  const dialRef = useRef<HTMLDivElement>(null);\n  const dragRef = useRef<DragState | null>(null);\n  const [dragging, setDragging] = useState(false);\n  useDialWheel(dialRef, dial, disabled);\n  const dialAngle = angleFor(position, arc);\n\n  const reset = () => {\n    dial.change(dial.resetValue, { reason: \"reset\" });\n    dial.commit(dial.resetValue);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (disabled) {\n      return;\n    }\n    if (event.key === \"Enter\") {\n      event.preventDefault();\n      setEditing(true);\n      return;\n    }\n    const increment = incrementFor(event, dial);\n    const next = keyTarget(event.key, dial.latestRef.current, increment, dial);\n    if (next === null) {\n      return;\n    }\n    event.preventDefault();\n    const quantized = dial.quantize(next, Math.min(increment, dial.step));\n    dial.change(quantized, { event: event.nativeEvent, reason: \"keyboard\" });\n    dial.commit(quantized);\n  };\n\n  const handlePointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (disabled || event.button !== 0) {\n      return;\n    }\n    if (event.altKey) {\n      event.preventDefault();\n      reset();\n      return;\n    }\n    event.currentTarget.setPointerCapture(event.pointerId);\n    event.currentTarget.focus();\n    dragRef.current = {\n      angle: dragAngle(event, dial),\n      position: dial.taper.toPosition(dial.latestRef.current),\n      x: event.clientX,\n      y: event.clientY,\n    };\n    setDragging(true);\n  };\n\n  const handlePointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    const last = dragRef.current;\n    if (!last) {\n      return;\n    }\n    const angle = dragAngle(event, dial);\n    const next = dragPosition(event, last, angle, dial, arc);\n    dragRef.current = {\n      angle,\n      position: next,\n      x: event.clientX,\n      y: event.clientY,\n    };\n    const increment = event.shiftKey ? dial.fineStep : dial.step;\n    dial.change(dial.quantize(dial.taper.toValue(next), increment), {\n      event: event.nativeEvent,\n      reason: \"drag\",\n    });\n  };\n\n  const endDrag = (event: PointerEvent<HTMLDivElement>) => {\n    if (!dragRef.current) {\n      return;\n    }\n    dragRef.current = null;\n    setDragging(false);\n    if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n    }\n    dial.commit(dial.latestRef.current);\n  };\n\n  return (\n    <div\n      aria-disabled={disabled || undefined}\n      aria-labelledby={labelId}\n      aria-valuemax={dial.max}\n      aria-valuemin={dial.min}\n      aria-valuenow={value}\n      aria-valuetext={format(value)}\n      className={cn(\n        \"group/knob-dial pointer-events-none relative size-(--knob-size) touch-none rounded-full outline-none data-dragging:pointer-events-auto\",\n        {\n          \"data-dragging:cursor-ew-resize\": dial.dragDirection === \"horizontal\",\n          \"data-dragging:cursor-grabbing\": dial.dragDirection === \"circular\",\n          \"data-dragging:cursor-ns-resize\": dial.dragDirection === \"vertical\",\n        },\n        className\n      )}\n      data-dragging={dragging ? \"\" : undefined}\n      data-slot=\"knob-dial\"\n      onDoubleClick={() => {\n        if (!disabled) {\n          reset();\n        }\n      }}\n      onKeyDown={handleKeyDown}\n      onLostPointerCapture={endDrag}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={endDrag}\n      ref={dialRef}\n      role=\"slider\"\n      style={\n        {\n          \"--knob-angle\": `${dialAngle}deg`,\n          ...style,\n        } as CSSProperties\n      }\n      tabIndex={disabled ? -1 : 0}\n      {...props}\n    >\n      <svg\n        aria-hidden\n        className=\"size-full overflow-visible\"\n        viewBox={`0 0 ${VIEWBOX} ${VIEWBOX}`}\n      >\n        <DialProvider angle={dialAngle}>{children}</DialProvider>\n        <circle\n          className=\"[pointer-events:all] cursor-grab touch-none group-aria-disabled/knob-dial:cursor-default group-data-dragging/knob-dial:cursor-[inherit]\"\n          cx={CENTER}\n          cy={CENTER}\n          data-slot=\"knob-hit-area\"\n          fill=\"none\"\n          r={CENTER}\n        />\n      </svg>\n    </div>\n  );\n};\n\nexport const KnobTrack = ({ className, ...props }: ComponentProps<\"path\">) => {\n  const { arc } = useKnob(\"KnobTrack\");\n  return (\n    <path\n      className={cn(\"stroke-input\", className)}\n      d={arcPath(-arc / 2, arc / 2)}\n      data-slot=\"knob-track\"\n      fill=\"none\"\n      strokeLinecap=\"round\"\n      strokeWidth={8}\n      {...props}\n    />\n  );\n};\n\nexport const KnobRange = ({ className, ...props }: ComponentProps<\"path\">) => {\n  const { arc, originPosition, position } = useKnob(\"KnobRange\");\n  return (\n    <path\n      className={cn(\"stroke-primary\", className)}\n      d={arcPath(angleFor(originPosition, arc), angleFor(position, arc))}\n      data-slot=\"knob-range\"\n      fill=\"none\"\n      strokeLinecap=\"round\"\n      strokeWidth={8}\n      {...props}\n    />\n  );\n};\n\nexport const KnobPointer = ({\n  className,\n  ...props\n}: ComponentProps<\"line\">) => {\n  const { angle } = useDial(\"KnobPointer\");\n  const inner = pointAt(angle, RADIUS * 0.3);\n  const outer = pointAt(angle, RADIUS * 0.72);\n  return (\n    <>\n      <circle\n        className=\"fill-muted stroke-border\"\n        cx={CENTER}\n        cy={CENTER}\n        r={RADIUS * 0.8}\n        strokeWidth={1}\n      />\n      <line\n        className={cn(\"stroke-foreground\", className)}\n        data-slot=\"knob-pointer\"\n        strokeLinecap=\"round\"\n        strokeWidth={6}\n        x1={inner.x}\n        x2={outer.x}\n        y1={inner.y}\n        y2={outer.y}\n        {...props}\n      />\n    </>\n  );\n};\n\n/** Radii of the volume dial, in view box units. */\nconst SCALE = {\n  label: 47.5,\n  labelSize: 4.5,\n  majorInner: 34.5,\n  majorOuter: 43.5,\n  minorInner: 35.5,\n  minorOuter: 40.5,\n} as const;\n/**\n * Radii of the cap, in view box units: inside a KnobScale by default, or\n * filling the inside of a KnobTrack when mini. The shadow starts at `haloFrom`\n * of the halo's radius.\n */\nconst CAP = {\n  default: {\n    bezel: 25.5,\n    face: 22.5,\n    halo: 34,\n    haloFrom: 0.6,\n    rim: 23.3,\n  },\n  mini: {\n    bezel: 32,\n    face: 28.2,\n    halo: 36,\n    haloFrom: 0.8,\n    rim: 29.2,\n  },\n} as const;\n/** The default cap's indicator dot. */\nconst CAP_DOT = { distance: 17, radius: 1.6 } as const;\n/** The mini cap's engraved indicator line, which reads at small sizes. */\nconst CAP_LINE = { inner: 8.5, lip: 0.6, outer: 23, width: 3.4 } as const;\n/** Ticks this close to the lit range's ends still count as lit. */\nconst TICK_EPSILON = 1e-9;\n\nexport interface KnobScaleProps extends Omit<ComponentProps<\"g\">, \"format\"> {\n  /** Divisions across the arc; draws `ticks + 1` marks. Default 50. */\n  ticks?: number;\n  /** Every nth tick is long. Default 5. */\n  majorEvery?: number;\n  /** Every nth tick is numbered; 0 hides the numbers. Default 10. */\n  labelEvery?: number;\n  /** Text for each number. Default: the knob's `format`. */\n  format?: (value: number) => string;\n}\n\n/** Tick marks and numbers around the dial, lit from `origin` to the value. */\nexport const KnobScale = ({\n  ticks = 50,\n  majorEvery = 5,\n  labelEvery = 10,\n  format: formatProp,\n  className,\n  ...props\n}: KnobScaleProps) => {\n  const { arc, format, originPosition, position } = useKnob(\"KnobScale\");\n  const { setDetents, taper } = useKnobDial();\n  const formatLabel = formatProp ?? format;\n  const litFrom = Math.min(originPosition, position) - TICK_EPSILON;\n  const litTo = Math.max(originPosition, position) + TICK_EPSILON;\n  const count = Math.max(1, Math.round(ticks));\n  // One step for the long ticks drawn and the detents they click on.\n  const majorStep = Math.max(1, Math.round(majorEvery));\n\n  useEffect(() => {\n    const majors: number[] = [];\n    for (let index = 0; index <= count; index += majorStep) {\n      majors.push(index / count);\n    }\n    setDetents(majors);\n    return () => {\n      setDetents(null);\n    };\n  }, [count, majorStep, setDetents]);\n\n  const marks = Array.from({ length: count + 1 }, (_, index) => {\n    const tickPosition = index / count;\n    const angle = angleFor(tickPosition, arc);\n    const major = index % majorStep === 0;\n    const inner = pointAt(angle, major ? SCALE.majorInner : SCALE.minorInner);\n    const outer = pointAt(angle, major ? SCALE.majorOuter : SCALE.minorOuter);\n    const lit = tickPosition >= litFrom && tickPosition <= litTo;\n    return (\n      <line\n        className=\"stroke-muted-foreground/45 data-active:stroke-foreground data-major:stroke-muted-foreground data-major:data-active:stroke-foreground\"\n        data-active={lit ? \"\" : undefined}\n        data-major={major ? \"\" : undefined}\n        data-slot=\"knob-tick\"\n        key={index}\n        strokeWidth={major ? 1.1 : 0.55}\n        x1={inner.x}\n        x2={outer.x}\n        y1={inner.y}\n        y2={outer.y}\n      />\n    );\n  });\n\n  const labels =\n    labelEvery > 0\n      ? Array.from(\n          { length: Math.floor(count / labelEvery) + 1 },\n          (_, index) => {\n            const tickPosition = (index * labelEvery) / count;\n            const angle = angleFor(tickPosition, arc);\n            const { x, y } = pointAt(angle, SCALE.label);\n            return (\n              <text\n                className=\"fill-muted-foreground\"\n                data-slot=\"knob-scale-label\"\n                dominantBaseline=\"central\"\n                fontSize={SCALE.labelSize}\n                key={tickPosition}\n                textAnchor=\"middle\"\n                transform={`rotate(${angle} ${x} ${y})`}\n                x={x}\n                y={y}\n              >\n                {formatLabel(roundValue(taper.toValue(tickPosition)))}\n              </text>\n            );\n          }\n        )\n      : null;\n\n  return (\n    <g className={className} data-slot=\"knob-scale\" {...props}>\n      {marks}\n      {labels}\n    </g>\n  );\n};\n\n/** An id that is safe inside `url(#…)`. */\nconst useSvgId = () => `knob${useId().replaceAll(/[^\\w-]/gu, \"\")}`;\n\nconst knobCapVariants = cva(\n  \"[--knob-cap-metal:var(--color-white)] [--knob-cap-shade:var(--color-black)]\",\n  {\n    defaultVariants: { variant: \"default\" },\n    variants: {\n      variant: {\n        default: \"[--knob-cap-pitch:0.3px]\",\n        mini: \"[--knob-cap-pitch:0.9px]\",\n      },\n    },\n  }\n);\n\ntype KnobCapVariant = \"default\" | \"mini\";\n\n/** The value marker: a dot on the default cap, an engraved line on the mini one. */\nconst KnobCapIndicator = ({\n  angle,\n  variant,\n}: {\n  angle: number;\n  variant: KnobCapVariant;\n}) => {\n  if (variant === \"default\") {\n    const dot = pointAt(angle, CAP_DOT.distance);\n    return (\n      <circle\n        className=\"fill-(--knob-cap-shade)/85 stroke-(--knob-cap-shade)/45\"\n        cx={dot.x}\n        cy={dot.y}\n        data-slot=\"knob-cap-dot\"\n        r={CAP_DOT.radius}\n        strokeWidth={0.35}\n      />\n    );\n  }\n  const inner = pointAt(angle, CAP_LINE.inner);\n  const outer = pointAt(angle, CAP_LINE.outer);\n  return (\n    <>\n      {/* Light from above catches the groove's lower wall. */}\n      <line\n        className=\"stroke-(--knob-cap-metal)/70\"\n        strokeLinecap=\"round\"\n        strokeWidth={CAP_LINE.width}\n        x1={inner.x}\n        x2={outer.x}\n        y1={inner.y + CAP_LINE.lip}\n        y2={outer.y + CAP_LINE.lip}\n      />\n      <line\n        className=\"stroke-(--knob-cap-shade)/80\"\n        data-slot=\"knob-cap-pointer\"\n        strokeLinecap=\"round\"\n        strokeWidth={CAP_LINE.width}\n        x1={inner.x}\n        x2={outer.x}\n        y1={inner.y}\n        y2={outer.y}\n      />\n    </>\n  );\n};\n\nexport interface KnobCapProps extends ComponentProps<\"g\"> {\n  /**\n   * `default` sits inside a KnobScale and marks the value with a dot. `mini`\n   * fills the inside of a KnobTrack and marks it with an engraved line, for\n   * small knobs. Default `default`.\n   */\n  variant?: KnobCapVariant;\n}\n\n/**\n * A brushed aluminium cap in a dark bezel, with a marker that turns with the\n * value. The metal is `--knob-cap-metal` shaded by `--knob-cap-shade`.\n */\nexport const KnobCap = ({\n  variant = \"default\",\n  className,\n  ...props\n}: KnobCapProps) => {\n  const { angle, onCapRadiusChange } = useDial(\"KnobCap\");\n  const id = useSvgId();\n  const cap = CAP[variant];\n\n  // Reported before paint, so a dial sizes its drag area from the first frame.\n  const reportRadius = useEffectEvent((radius: number | null) => {\n    onCapRadiusChange?.(radius);\n  });\n  useLayoutEffect(() => {\n    reportRadius(cap.bezel);\n    return () => {\n      reportRadius(null);\n    };\n  }, [cap.bezel]);\n\n  return (\n    <g\n      className={cn(knobCapVariants({ variant }), className)}\n      data-slot=\"knob-cap\"\n      data-variant={variant}\n      {...props}\n    >\n      <defs>\n        <filter height=\"100%\" id={`${id}-grain`} width=\"100%\" x=\"0\" y=\"0\">\n          <feTurbulence\n            baseFrequency={0.9}\n            numOctaves={3}\n            seed={7}\n            type=\"fractalNoise\"\n          />\n          <feColorMatrix type=\"saturate\" values=\"0\" />\n          <feComposite in2=\"SourceGraphic\" operator=\"in\" />\n        </filter>\n        <radialGradient id={`${id}-halo`}>\n          <stop\n            className=\"[stop-color:var(--knob-cap-shade)]\"\n            offset={cap.haloFrom}\n            stopOpacity={0.55}\n          />\n          <stop\n            className=\"[stop-color:var(--knob-cap-shade)]\"\n            offset=\"1\"\n            stopOpacity={0}\n          />\n        </radialGradient>\n        {/* Light from above: the bezel brightens at the top, the rim at the bottom. */}\n        <linearGradient id={`${id}-bezel`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop\n            className=\"[stop-color:var(--knob-cap-metal)]\"\n            offset=\"0\"\n            stopOpacity={0.16}\n          />\n          <stop\n            className=\"[stop-color:var(--knob-cap-metal)]\"\n            offset=\"1\"\n            stopOpacity={0}\n          />\n        </linearGradient>\n        <linearGradient id={`${id}-rim`} x1=\"0\" x2=\"0\" y1=\"0\" y2=\"1\">\n          <stop\n            className=\"[stop-color:var(--knob-cap-shade)]\"\n            offset=\"0\"\n            stopOpacity={0.62}\n          />\n          <stop\n            className=\"[stop-color:var(--knob-cap-shade)]\"\n            offset=\"1\"\n            stopOpacity={0.08}\n          />\n        </linearGradient>\n      </defs>\n      <circle cx={CENTER} cy={CENTER} fill={`url(#${id}-halo)`} r={cap.halo} />\n      <circle\n        className=\"fill-(--knob-cap-shade)/85 stroke-(--knob-cap-metal)/15\"\n        cx={CENTER}\n        cy={CENTER}\n        r={cap.bezel}\n        strokeWidth={0.4}\n      />\n      <circle\n        cx={CENTER}\n        cy={CENTER}\n        fill={`url(#${id}-bezel)`}\n        r={cap.bezel}\n      />\n      <circle\n        className=\"fill-(--knob-cap-metal)\"\n        cx={CENTER}\n        cy={CENTER}\n        r={cap.rim}\n      />\n      <circle cx={CENTER} cy={CENTER} fill={`url(#${id}-rim)`} r={cap.rim} />\n      {/* SVG has no conic gradient, so the face is HTML with a CSS one. */}\n      <foreignObject\n        height={cap.face * 2}\n        width={cap.face * 2}\n        x={CENTER - cap.face}\n        y={CENTER - cap.face}\n      >\n        <div\n          className=\"size-full rounded-full bg-(--knob-cap-metal) bg-[repeating-radial-gradient(circle,var(--knob-cap-brush)_0_var(--knob-cap-pitch),transparent_var(--knob-cap-pitch)_calc(var(--knob-cap-pitch)*2)),conic-gradient(from_15deg,var(--knob-cap-sheen),transparent_9%,var(--knob-cap-sheen)_21%,transparent_32%,var(--knob-cap-sheen-deep)_46%,transparent_58%,var(--knob-cap-sheen)_70%,transparent_83%,var(--knob-cap-sheen))] [--knob-cap-brush:color-mix(in_oklab,var(--knob-cap-shade)_7%,transparent)] [--knob-cap-sheen-deep:color-mix(in_oklab,var(--knob-cap-shade)_50%,transparent)] [--knob-cap-sheen:color-mix(in_oklab,var(--knob-cap-shade)_34%,transparent)]\"\n          data-slot=\"knob-cap-face\"\n        />\n      </foreignObject>\n      <circle\n        cx={CENTER}\n        cy={CENTER}\n        data-slot=\"knob-cap-grain\"\n        filter={`url(#${id}-grain)`}\n        opacity={0.25}\n        pointerEvents=\"none\"\n        r={cap.face}\n        transform={`rotate(${angle} ${CENTER} ${CENTER})`}\n      />\n      <KnobCapIndicator angle={angle} variant={variant} />\n    </g>\n  );\n};\n\nconst focusDial = (from: HTMLElement) => {\n  from\n    .closest(\"[data-slot='knob']\")\n    ?.querySelector<HTMLElement>(\"[data-slot='knob-dial']\")\n    ?.focus();\n};\n\n/** The inline editor KnobValue shows while a value is typed. */\nconst KnobValueInput = ({\n  className,\n  style,\n}: Pick<ComponentProps<\"input\">, \"className\" | \"style\">) => {\n  const { format, parse, setEditing, value } = useKnob(\"KnobValue\");\n  const dial = useKnobDial();\n  const [draft, setDraft] = useState(() => format(value));\n  const doneRef = useRef(false);\n  const focusInput = useCallback((node: HTMLInputElement | null) => {\n    node?.focus();\n    node?.select();\n  }, []);\n\n  const finish = (apply: boolean) => {\n    if (doneRef.current) {\n      return;\n    }\n    doneRef.current = true;\n    setEditing(false);\n    const parsed = apply ? parse(draft) : null;\n    if (parsed === null || Number.isNaN(parsed)) {\n      return;\n    }\n    const next = dial.quantize(\n      clamp(parsed, dial.min, dial.max),\n      Math.min(dial.fineStep, dial.step)\n    );\n    dial.change(next, { reason: \"input\" });\n    dial.commit(next);\n  };\n\n  return (\n    <input\n      aria-label=\"Value\"\n      className={cn(\n        \"bg-background ring-ring/50 focus-visible:ring-foreground h-4 w-(--knob-value-width) min-w-0 rounded-sm p-0 text-center font-mono text-xs tabular-nums ring-1 outline-none focus-visible:ring-2\",\n        className\n      )}\n      data-slot=\"knob-value-input\"\n      onBlur={() => finish(true)}\n      onChange={(event) => setDraft(event.target.value)}\n      onKeyDown={(event) => {\n        if (event.key !== \"Enter\" && event.key !== \"Escape\") {\n          return;\n        }\n        event.preventDefault();\n        const input = event.currentTarget;\n        finish(event.key === \"Enter\");\n        focusDial(input);\n      }}\n      ref={focusInput}\n      style={style}\n      value={draft}\n    />\n  );\n};\n\nexport interface KnobValueProps extends ComponentProps<\"span\"> {\n  /** Double-click the value, or press Enter on the dial, to type one. Default true. */\n  editable?: boolean;\n}\n\nexport const KnobValue = ({\n  editable = true,\n  className,\n  style,\n  onDoubleClick,\n  ...props\n}: KnobValueProps) => {\n  const { disabled, editing, format, setEditing, value, valueWidth } =\n    useKnob(\"KnobValue\");\n  const widthStyle = {\n    \"--knob-value-width\": `${valueWidth}ch`,\n    ...style,\n  } as CSSProperties;\n\n  if (editable && editing) {\n    return <KnobValueInput className={className} style={widthStyle} />;\n  }\n\n  return (\n    <span\n      className={cn(\n        \"text-muted-foreground inline-block min-w-(--knob-value-width) text-center font-mono text-xs whitespace-nowrap tabular-nums\",\n        editable && !disabled && \"cursor-text\",\n        className\n      )}\n      data-slot=\"knob-value\"\n      onDoubleClick={(event) => {\n        onDoubleClick?.(event);\n        if (editable && !disabled) {\n          setEditing(true);\n        }\n      }}\n      style={widthStyle}\n      {...props}\n    >\n      {format(value)}\n    </span>\n  );\n};\n\nexport const KnobLabel = ({\n  className,\n  onDoubleClick,\n  ...props\n}: ComponentProps<\"span\">) => {\n  const { disabled, labelId, setEditing } = useKnob(\"KnobLabel\");\n  return (\n    <span\n      className={cn(\"text-xs font-medium\", className)}\n      data-slot=\"knob-label\"\n      id={labelId}\n      onDoubleClick={(event) => {\n        onDoubleClick?.(event);\n        if (!disabled) {\n          setEditing(true);\n        }\n      }}\n      {...props}\n    />\n  );\n};\n\nconst knobVariants = cva(\n  \"group/knob inline-flex flex-col items-center gap-1.5 select-none data-disabled:opacity-50\",\n  {\n    defaultVariants: { size: \"default\" },\n    variants: {\n      size: {\n        default: \"[--knob-size:3rem]\",\n        lg: \"[--knob-size:4rem]\",\n        sm: \"[--knob-size:2.25rem]\",\n      },\n    },\n  }\n);\n\nexport interface KnobProps extends Omit<\n  ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> {\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number, details: KnobChangeDetails) => void;\n  onValueCommitted?: (value: number) => void;\n  /** Default 0. */\n  min?: number;\n  /** Default 100. */\n  max?: number;\n  /** Default 1. */\n  step?: number;\n  /** Default 10. */\n  largeStep?: number;\n  /** Shift+drag, Shift+wheel and Alt+arrow. Default: `step / 10`. */\n  fineStep?: number;\n  /** Double-click or Alt+click restores this. Default `defaultValue` or `min`. */\n  resetValue?: number;\n  /** Where the arc starts; the centre for bipolar knobs. Default `min`. */\n  origin?: number;\n  /** Sweep in degrees. Default 270. */\n  arc?: number;\n  /** How dragging turns the knob. Default `vertical`. */\n  dragDirection?: \"vertical\" | \"horizontal\" | \"circular\";\n  /** Pixels of vertical or horizontal drag for the full range. Default 200. */\n  sensitivity?: number;\n  /** Default `linear`. */\n  scale?: \"linear\" | \"log\";\n  /** The wheel adjusts the value while focused. Default false. */\n  allowWheel?: boolean;\n  /**\n   * Plays a soft click on each graduation: KnobScale's long ticks, or every\n   * `largeStep` without a scale. Pass a `ClickSound` to replace the tick.\n   * Default false.\n   */\n  clickSound?: boolean | ClickSound;\n  format?: (value: number) => string;\n  /** Reads a typed value. Default: the first number, with \"k\" as thousands. */\n  parse?: (text: string) => number | null;\n  size?: AudioSize;\n  disabled?: boolean;\n}\n\n/** Shortest gap between clicks, so a fast turn ticks instead of buzzing. */\nconst CLICK_INTERVAL_MS = 30;\nconst knobClick = createClickSound();\n\n/** Positions this close to a detent count as on it. */\nconst DETENT_EPSILON = 1e-9;\n\n/** Whether moving between positions reaches or passes a detent. */\nconst reachesDetent = (detents: readonly number[], from: number, to: number) =>\n  detents.some((detent) =>\n    from < to\n      ? detent > from + DETENT_EPSILON && detent <= to + DETENT_EPSILON\n      : detent < from - DETENT_EPSILON && detent >= to - DETENT_EPSILON\n  );\n\n/** Whether moving between values reaches or passes a multiple of `every`. */\nconst reachesMultiple = (\n  from: number,\n  to: number,\n  min: number,\n  every: number\n) => {\n  const start = roundValue((from - min) / every);\n  const end = roundValue((to - min) / every);\n  return from < to\n    ? Math.floor(end) > Math.floor(start)\n    : Math.ceil(start) > Math.ceil(end);\n};\n\ninterface KnobValueOptions {\n  value: number | undefined;\n  defaultValue: number | undefined;\n  resetValue: number;\n  min: number;\n  max: number;\n  onValueChange: KnobProps[\"onValueChange\"];\n  /** Whether a change between two values should click; null for silence. */\n  clicksBetween: ((from: number, to: number) => boolean) | null;\n  playClick: (() => void) | null;\n}\n\n/** Controlled or uncontrolled value with change notifications. */\nconst useKnobValue = ({\n  value: valueProp,\n  defaultValue,\n  resetValue,\n  min,\n  max,\n  onValueChange,\n  clicksBetween,\n  playClick,\n}: KnobValueOptions) => {\n  const [uncontrolled, setUncontrolled] = useState(() =>\n    clamp(defaultValue ?? resetValue, min, max)\n  );\n  const value = valueProp ?? uncontrolled;\n  const latestRef = useLatest(value);\n  const controlled = valueProp !== undefined;\n\n  const change = useCallback(\n    (next: number, details: KnobChangeDetails) => {\n      const previous = latestRef.current;\n      if (next === previous) {\n        return;\n      }\n      latestRef.current = next;\n      if (!controlled) {\n        setUncontrolled(next);\n      }\n      onValueChange?.(next, details);\n      if (clicksBetween?.(previous, next)) {\n        playClick?.();\n      }\n    },\n    [clicksBetween, controlled, latestRef, onValueChange, playClick]\n  );\n\n  return { change, latestRef, value };\n};\n\nconst soundFor = (clickSound: boolean | ClickSound): ClickSound | null => {\n  if (clickSound === true) {\n    return knobClick;\n  }\n  if (clickSound === false) {\n    return null;\n  }\n  return clickSound;\n};\n\n/**\n * Plays the click through the provided audio context, or the shared one,\n * which is only created when a click plays. Null for silence.\n */\nconst useKnobClick = (clickSound: boolean | ClickSound) => {\n  const context = useProvidedAudioContext();\n  const sound = soundFor(clickSound);\n  return useMemo(\n    () =>\n      sound &&\n      (() => {\n        sound.play({ context, minIntervalMs: CLICK_INTERVAL_MS });\n      }),\n    [context, sound]\n  );\n};\n\n/** Size and disabled, from props or the surrounding strip. */\nconst useKnobSettings = (\n  size: AudioSize | undefined,\n  disabled: boolean | undefined\n) => {\n  const config = useAudioConfig();\n  return {\n    disabled: disabled ?? config.disabled ?? false,\n    size: size ?? config.size ?? \"default\",\n  };\n};\n\nexport const Knob = ({\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommitted,\n  min = 0,\n  max = 100,\n  step = 1,\n  largeStep = 10,\n  fineStep,\n  resetValue,\n  origin,\n  arc = 270,\n  dragDirection = \"vertical\",\n  sensitivity = 200,\n  scale = \"linear\",\n  allowWheel = false,\n  clickSound = false,\n  format = String,\n  parse = parseKnobValue,\n  size: sizeProp,\n  disabled: disabledProp,\n  className,\n  children,\n  ...props\n}: KnobProps) => {\n  const { disabled, size } = useKnobSettings(sizeProp, disabledProp);\n  const reset = resetValue ?? defaultValue ?? min;\n  const labelId = useId();\n  const taper: Taper = useMemo(\n    () => (scale === \"log\" ? logTaper(min, max) : linearTaper(min, max)),\n    [max, min, scale]\n  );\n\n  // Clicks land on KnobScale's long ticks, or every largeStep without one.\n  const [detents, setDetents] = useState<readonly number[] | null>(null);\n  const playClick = useKnobClick(clickSound);\n  const clicksBetween = useMemo(() => {\n    if (!playClick) {\n      return null;\n    }\n    if (detents) {\n      return (from: number, to: number) =>\n        reachesDetent(detents, taper.toPosition(from), taper.toPosition(to));\n    }\n    return (from: number, to: number) =>\n      reachesMultiple(from, to, min, largeStep);\n  }, [detents, largeStep, min, playClick, taper]);\n\n  const { change, latestRef, value } = useKnobValue({\n    clicksBetween,\n    defaultValue,\n    max,\n    min,\n    onValueChange,\n    playClick,\n    resetValue: reset,\n    value: valueProp,\n  });\n\n  const quantize = useCallback(\n    (next: number, increment: number) =>\n      roundValue(\n        clamp(min + Math.round((next - min) / increment) * increment, min, max)\n      ),\n    [max, min]\n  );\n\n  const position = taper.toPosition(value);\n  const originValue = clamp(origin ?? min, min, max);\n  const originPosition = taper.toPosition(originValue);\n\n  const [editing, setEditing] = useState(false);\n\n  const valueWidth = useMemo(\n    () => widestValue(format, taper, (next) => quantize(next, step)),\n    [format, quantize, step, taper]\n  );\n\n  const contextValue = useMemo<KnobContextValue>(\n    () => ({\n      arc,\n      disabled,\n      editing,\n      format,\n      labelId,\n      originPosition,\n      parse,\n      position,\n      setEditing,\n      value,\n      valueWidth,\n    }),\n    [\n      arc,\n      disabled,\n      editing,\n      format,\n      labelId,\n      originPosition,\n      parse,\n      position,\n      value,\n      valueWidth,\n    ]\n  );\n\n  const dialContext = useMemo<KnobDialContextValue>(\n    () => ({\n      allowWheel,\n      change,\n      commit: (next: number) => onValueCommitted?.(next),\n      dragDirection,\n      fineStep: fineStep ?? step * FINE_FACTOR,\n      largeStep,\n      latestRef,\n      max,\n      min,\n      quantize,\n      resetValue: reset,\n      sensitivity,\n      setDetents,\n      step,\n      taper,\n    }),\n    [\n      allowWheel,\n      change,\n      dragDirection,\n      fineStep,\n      largeStep,\n      latestRef,\n      max,\n      min,\n      onValueCommitted,\n      quantize,\n      reset,\n      sensitivity,\n      step,\n      taper,\n    ]\n  );\n\n  return (\n    <KnobContext.Provider value={contextValue}>\n      <KnobDialContext.Provider value={dialContext}>\n        <div\n          className={cn(knobVariants({ size }), className)}\n          data-at-origin={value === originValue ? \"\" : undefined}\n          data-disabled={disabled ? \"\" : undefined}\n          data-size={size}\n          data-slot=\"knob\"\n          {...props}\n        >\n          {children ?? (\n            <KnobDial>\n              <KnobTrack />\n              <KnobRange />\n              <KnobPointer />\n            </KnobDial>\n          )}\n        </div>\n      </KnobDialContext.Provider>\n    </KnobContext.Provider>\n  );\n};\n\nexport { knobCapVariants, knobVariants };\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}