{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "core-knob",
  "type": "registry:ui",
  "title": "Knob",
  "description": "Shared rotary control with dial, ring, studio and text variants. Dial default: label at rest, formatted value on hover or edit. showLabelOnHover=false keeps both visible; valueReveal=edit explicitly disables hover reveal. captionWidth, labelCompressible and labelNarrow control bounded fitting and ellipsis; textStyles and unitSize style the readout. variant/skin/size select appearance independently of indicator, ticks, arc geometry, bipolar and pizza feedback. interaction, dragDistance, wheelSensitivity and cursor tune shared gestures; keyboard, reset, ARIA and MIDI/CC stay shared. Use ParameterKnob for schema-bound module parameters and renderDesign for custom drawing; KnobHeadless remains available for custom interaction structure. The UI-kit Knob catalog documents the full prop table and copyable patterns.",
  "dependencies": [
    "maruaudio@>=0.0.3 <0.1.0"
  ],
  "registryDependencies": [
    "https://pluginui.com/r/core-cc-binding-editor.json",
    "https://pluginui.com/r/core-control-label.json"
  ],
  "files": [
    {
      "path": "components/ui/knob.tsx",
      "content": "/**\n * Source: https://maru.audio/ui-kit/knob\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 * Knob — an SVG potentiometer built on KnobHeadless.\n *\n * Arc track, lit value arc, a pointer line on a raised cap. Drag to turn it\n * (vertically, horizontally, freely, or circularly), scroll the wheel, hold\n * Shift while dragging for a fine pass, or hold Shift with an arrow key to\n * advance five normal steps. A single-axis drag is judged by angle rather than\n * locked to the axis, so the diagonal drags people actually make still turn\n * the knob — see `advanceDragIntent` in knob-core. Double-click snaps back to the default. Bipolar\n * mode lights the arc out from twelve o'clock instead of from the left, which\n * is what a pan or a detune control wants.\n *\n * Visual variants share all of that: the default moulded cap, the\n * `flat` matte face, the neutral `panel` with fixed-width rounded rings, the capless `ring`, the ticked `vintage` and `stepped`\n * skirts, the always-lit `neon`, the trackless `minimal`, and the themed\n * `instrument`/`molten` panel skins and the engraved dark `studio` dial. `ring` is the capless arc-only\n * treatment: its unlit track is deliberately thinner than the active value\n * arc, and its position marker is opt-in. They differ only in looks — the\n * gesture, the ARIA contract and the theming tokens are identical across all\n * of them.\n *\n * Over the plain arc sits the optional \"pizza\" wedge: a conic-gradient slice\n * that fades in while you are actually turning the knob, so a dense panel shows\n * you which control you are on without lighting up all of them at once.\n *\n * Non-linear parameters take a `curve`: \"log\" spaces a 20 Hz–20 kHz cutoff by\n * octave; \"exp\" gives a zero-based duration more travel near short values.\n *\n * Pass the optional `midi` prop group and the knob also becomes a MIDI CC drop\n * target: drag a CC chip onto it, it wears the binding as a badge and pulses\n * when hardware moves. Everything about that is opt-in — no `midi` prop, no\n * target registration, no change to the markup.\n *\n * Styling comes from CSS custom properties — see `lib/theme.ts`.\n */\n\nimport { useCallback, useId, useMemo, useRef, useState } from \"react\"\nimport type {\n  CSSProperties,\n  KeyboardEvent,\n  PointerEvent,\n  ReactNode,\n} from \"react\"\nimport {\n  describeArc,\n  polarToCartesian,\n  sanitize,\n} from \"maruaudio/ui/shared/math\"\nimport {\n  circularAngleToValue01,\n  controlCurveMapping,\n  type ControlCurve,\n  normalizeValue01,\n  resolveKnobDragDistance,\n  resolveKnobWheelSensitivity,\n} from \"maruaudio/ui/shared/knob-core\"\nimport { useControlSkin } from \"maruaudio/ui/shared/control-skin-context\"\nimport type {\n  KnobmanVisualOptions,\n  KnobmanVisualSource,\n} from \"maruaudio/ui/shared/knobman-source\"\nimport { useKnobmanVisualRenderer } from \"maruaudio/ui/shared/knobman-visual-context\"\nimport {\n  CONTROL_SKINS,\n  evenFractions,\n  isMarkLit,\n  litGlow,\n  VINTAGE_FACE_STOPS,\n  type CapTreatment,\n  type ControlSkin,\n  type SkinTraits,\n} from \"maruaudio/ui/shared/control-skin\"\nimport {\n  defaultFormat,\n  formatControlValueLabel,\n  percent,\n  type ValueFormatter,\n} from \"maruaudio/ui/shared/format\"\nimport { FormattedControlValue } from \"@/components/ui/control-label\"\nimport { token } from \"maruaudio/ui/shared/theme\"\nimport {\n  ccActivityRingStyle,\n  useCcActivityPulse,\n  type ControlMidiProps,\n} from \"maruaudio/ui/shared/cc-dnd\"\nimport { useControlledValue } from \"maruaudio/ui/shared/use-control\"\nimport {\n  useFirstInteraction,\n  type InteractionEvent,\n} from \"maruaudio/ui/shared/use-first-interaction\"\nimport { useKnobKeyboardControls } from \"maruaudio/ui/shared/use-knob-keyboard\"\nimport {\n  KnobHeadless,\n  KnobHeadlessReadout,\n  type KnobHeadlessProps,\n} from \"maruaudio/ui/shared/knob-headless\"\n\n/** Where the value wedge is shown. */\nexport type KnobPizzaMode = \"interaction\" | \"always\" | \"never\"\n\n/** Terminal geometry for the unlit and lit arc strokes. */\nexport type KnobArcCap = \"butt\" | \"round\" | \"square\"\n\nexport type KnobVariant =\n  | \"text\"\n  | \"default\"\n  | \"small\"\n  | \"mini\"\n  | \"large\"\n  | \"minimal\"\n  | \"detailed\"\n  | \"flat\"\n  | \"panel\"\n  | \"ring\"\n  | \"vintage\"\n  | \"stepped\"\n  | \"studio\"\n  | \"neon\"\n  | \"instrument\"\n  | \"molten\"\n\n/** Semantic knob diameters for instrument layouts. */\nexport type KnobSize = \"xs\" | \"s\" | \"m\" | \"l\" | \"xl\"\n\nexport type KnobLabelPosition = \"top\" | \"bottom\" | \"left\" | \"right\" | \"split\"\n\n/** Preset readout formats, matching the common parameter kinds. */\nexport type KnobValueDisplay = \"raw\" | \"percent\" | \"db\" | \"hz\" | \"ms\"\n\n/**\n * Response curve. \"log\" wants a strictly positive range; \"exp\" includes zero.\n * A custom pair maps raw↔[0..1] (the two must be inverses of each other).\n */\nexport type KnobCurve = ControlCurve\n\n/** Text skin slots; layout applies to text-only controls, label/value to all variants. */\nexport interface KnobTextStyles {\n  layout?: CSSProperties\n  label?: CSSProperties\n  value?: CSSProperties\n  unit?: CSSProperties\n}\n\n/** A printed scale uses engine units and the control's existing response curve. */\nexport interface KnobScaleLabel {\n  value: number\n  label: string\n}\n\n/** Drawing state only. Raw values and formatting remain owned by Knob. */\nexport interface KnobDesignState {\n  value01: number\n  size: number\n  active: boolean\n  /** Incoming activity highlight, independent of a local edit gesture. */\n  activity?: boolean\n  disabled: boolean\n  formattedValue: string\n}\n\nexport interface KnobProps {\n  /** Optional author-supplied hover text; no default tooltip. */\n  title?: string\n  /** Controlled value. Omit for uncontrolled operation. */\n  value?: number\n  /** Starting value when uncontrolled, and the double-click reset target. */\n  defaultValue?: number\n  onChange?: (value: number) => void\n  /** Fired once when a drag gesture ends — good for undo coalescing. */\n  onChangeEnd?: (value: number) => void\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  min?: number\n  max?: number\n  /** Quantisation grid. 0 means continuous. */\n  step?: number\n\n  /** Visible caption; placed below the knob by default. */\n  label?: string\n  /** Stable host parameter path. Resolved automatically inside a mapping provider. */\n  path?: string\n  /** Caption placement; `split` puts the label above and value below the dial. */\n  labelPosition?: KnobLabelPosition\n  /** Caption/readout width in pixels, independent of the knob diameter. */\n  captionWidth?: number\n  /** Fit captions within readable bounds before falling back to ellipsis. */\n  labelCompressible?: boolean\n  /** Condensed caption treatment; the knob geometry stays unchanged. */\n  labelNarrow?: boolean\n  /**\n   * Swap the caption for the live value while hovering or editing; defaults to\n   * true for dial variants, false for text. The readout reserves space for both\n   * strings, so swapping does not move neighbours. Set false to keep both\n   * label and value visible.\n   */\n  showLabelOnHover?: boolean\n  /** Reveal a substituted value on hover/edit, or only while editing. */\n  valueReveal?: \"hover-or-edit\" | \"edit\"\n  /** Text-only layout, label, value and unit overrides; merged after defaults. */\n  textStyles?: KnobTextStyles\n  /** Unit appended by the default formatter, e.g. \"Hz\". */\n  unit?: string\n  /** Smaller unit typography; text defaults to 0.4em, dials leave it unsplit. */\n  unitSize?: CSSProperties[\"fontSize\"]\n  /** Custom conversion or semantic label; common numeric units retain shared precision. */\n  format?: ValueFormatter\n  /** Preset readout format. Ignored when `format` is given. */\n  valueDisplay?: KnobValueDisplay\n  /** Explicit readout text; recognized numeric units use the shared presentation policy. */\n  display?: string\n  /** Hide the numeric readout under the knob. Text variants always show it. */\n  hideValue?: boolean\n  /** Keep the accessible label without drawing a caption. */\n  hideLabel?: boolean\n  /** Replace only the artwork, retaining all shared control behavior. */\n  renderDesign?: (state: KnobDesignState) => ReactNode\n  /**\n   * Cursor shown over the knob. Defaults to `ns-resize`; `grab` changes to\n   * `grabbing` during a pointer gesture. Pass `default` or `auto` to keep the\n   * browser's ordinary cursor.\n   */\n  cursor?: CSSProperties[\"cursor\"]\n\n  /**\n   * Visual preset — the knob's own set, including its sizes and the two panel\n   * skins. Prefer `size` for layout sizing, and `skin` when all you want is\n   * the finish. An explicit variant wins over an inherited skin.\n   */\n  variant?: KnobVariant\n  /**\n   * Finish, shared with every other control in the kit. Omitted, the knob\n   * takes whatever a surrounding `ControlSkinProvider` declares.\n   */\n  skin?: ControlSkin\n  /**\n   * Semantic size preset, or an explicit diameter in pixels. `m` is the\n   * current/default knob size. Numeric sizes remain available for fine-tuned\n   * instrument layouts.\n   */\n  size?: KnobSize | number\n  /** Optional KnobMan template identity; interaction remains owned by this knob. */\n  visual?: KnobmanVisualSource\n  /** Resolution and fitting policy passed to the host's KnobMan renderer. */\n  visualOptions?: KnobmanVisualOptions\n  /** Total arc sweep in degrees. 270 is the classic hardware throw. */\n  rotationRange?: number\n  /** Where the sweep begins, in degrees from twelve o'clock. */\n  rotationOffset?: number\n  /** @deprecated Use `rotationRange`. Kept so existing panels keep working. */\n  sweep?: number\n\n  /**\n   * Light the arc out from the centre rather than from the minimum — for\n   * pan, detune, and any other parameter whose neutral position is the middle.\n   */\n  bipolar?: boolean\n  /** Response curve for both the gesture and the visual. */\n  curve?: KnobCurve\n\n  /** When the conic value wedge is drawn behind the knob. */\n  pizza?: KnobPizzaMode\n  /** Colour of the wedge and the lit indicator. Defaults to the accent token. */\n  activeColor?: string\n  /** Optional utility class for the lit arc and pointer. */\n  accentClass?: string\n  /** Persistent skin glow, used by instrument-style panels. */\n  glow?: boolean\n  /**\n   * Multiplier on the unlit track stroke. The active arc keeps its variant\n   * width, so values below 1 make the track visually quieter without changing\n   * the selected value's emphasis. Omit to use the variant preset.\n   */\n  trackScale?: number\n  /** Ring width in pixels, independent of diameter; panel defaults to 4px. */\n  arcWidth?: number\n  /**\n   * Terminal geometry for both arc strokes. Panel defaults to `round`;\n   * other variants use `square`, which is flat and\n   * extends by half a stroke width; `butt` ends flush at the path endpoint,\n   * while `round` preserves the older rounded treatment.\n   */\n  arcCap?: KnobArcCap\n  /**\n   * Draw the unlit arc track behind the value arc. Turn it off for a knob that\n   * shows only its lit arc (or only the pizza wedge) against the panel.\n   */\n  showTrack?: boolean\n  /**\n   * How the current position is marked on the control: a radial line, a classic\n   * potentiometer dot near the rim, or nothing. Defaults to the variant's\n   * treatment — a line for most, a dot for flat and stepped, and no marker for\n   * the arc-only ring.\n   */\n  indicator?: \"line\" | \"dot\" | \"none\"\n  /** Engraved skirt marks. Omit for the variant default; 0 hides them. */\n  tickCount?: number\n  /** Sparse printed scale around the dial; positions share its parameter taper. */\n  scaleLabels?: readonly KnobScaleLabel[]\n  /** Draw the centre detent tick (bipolar knobs show one regardless). */\n  showCenter?: boolean\n\n  /**\n   * \"vertical\" (default) tracks up/down drag. \"horizontal\" tracks left/right,\n   * \"xy\" takes both, and \"circular\" turns the knob to follow the pointer's\n   * angle around its centre.\n   */\n  interaction?: \"vertical\" | \"horizontal\" | \"xy\" | \"circular\"\n  /**\n   * Pixels of drag needed to traverse the whole range. When omitted, the\n   * default is 126px at the 56px knob size and scales down for smaller knobs.\n   * Stepped ranges cap travel at 8px per interval.\n   */\n  dragDistance?: number\n  /** Normalised units per wheel notch. 0 disables the wheel. */\n  wheelSensitivity?: number\n  /**\n   * Degrees from the cross axis inside which a single-axis drag is ignored.\n   * The default (20) refuses only drags running within 20° of the wrong axis,\n   * so an ordinary diagonal still turns the knob; 0 accepts any drag with a\n   * component along the axis.\n   */\n  axisTolerance?: number\n  /** Put the knob in the tab order. Default true. */\n  includeIntoTabOrder?: boolean\n\n  /**\n   * MIDI CC mapping. Present: the knob is a drop target for CC chips, wears its\n   * assignment as a badge and pulses on incoming traffic. Absent: nothing\n   * changes, not even the markup.\n   */\n  midi?: ControlMidiProps\n\n  /** Decorative activity signal, independent of MIDI mapping. A changed finite\n   * number (value, revision or event counter) highlights briefly; true pulses\n   * once. A numeric initial value stays quiet. Never changes the parameter. */\n  activity?: boolean | number\n  /** Highlight any visible value change, including automation and MIDI host\n   * updates. Initial mount stays quiet; an explicit activity signal wins. */\n  activityOnChange?: boolean\n\n  disabled?: boolean\n  className?: string\n  style?: CSSProperties\n  /** Accessible name when no visible `label` is given. */\n  \"aria-label\"?: string\n}\n\n/**\n * What a variant is, beyond its size: the cap treatment, the skirt ticks, how\n * thick the arc runs and which marker it defaults to.\n *\n * Keeping the looks as *data* rather than as branches in the render is what\n * makes a new variant a five-line table entry — the SVG below draws whatever\n * the table asks for, and every variant keeps the same gesture, ARIA contract\n * and theming.\n */\ninterface KnobVariantSpec extends SkinTraits {\n  size: number\n  labelSize: number\n  valueSize: number\n  wedgeInset: number\n  /** Number of skirt ticks around the sweep. The arc pulls in to make room. */\n  ticks?: number\n  /** Multiplier on the unlit track stroke. */\n  trackScale?: number\n  /** Multiplier on the lit value stroke; defaults to trackScale when omitted. */\n  valueScale?: number\n  /** Marker this variant wants when the caller hasn't asked for one. */\n  indicator?: \"line\" | \"dot\" | \"none\"\n  /** Whether the variant draws the unlit arc unless the caller overrides it. */\n  showTrack?: boolean\n  /** Whether the current value is repeated as a lit arc. */\n  showValueArc?: boolean\n  /** Whether interaction can reveal the conic value wedge. */\n  showWedge?: boolean\n}\n\n/** Dimensions plus a shared skin — the shape every variant entry is built from. */\nfunction knobSpec(\n  skin: SkinTraits,\n  dimensions: Omit<KnobVariantSpec, keyof SkinTraits>\n): KnobVariantSpec {\n  return { ...skin, ...dimensions }\n}\n\n/** Diameter, readout size, wedge thickness and looks per variant. */\nconst VARIANTS: Record<KnobVariant, KnobVariantSpec> = {\n  text: knobSpec(CONTROL_SKINS.default, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 32,\n    wedgeInset: 0,\n    showWedge: false,\n  }),\n  panel: knobSpec(\n    { ...CONTROL_SKINS.flat, ink: token.text },\n    {\n      size: 56,\n      labelSize: 10,\n      valueSize: 11,\n      wedgeInset: 0,\n      showWedge: false,\n    }\n  ),\n  default: knobSpec(CONTROL_SKINS.default, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 6,\n    trackScale: 0.72,\n    valueScale: 1,\n  }),\n  small: knobSpec(CONTROL_SKINS.default, {\n    size: 40,\n    labelSize: 9,\n    valueSize: 10,\n    wedgeInset: 4,\n    trackScale: 0.72,\n    valueScale: 1,\n  }),\n  mini: knobSpec(CONTROL_SKINS.default, {\n    size: 28,\n    labelSize: 8,\n    valueSize: 9,\n    wedgeInset: 3,\n    trackScale: 0.72,\n    valueScale: 1,\n  }),\n  large: knobSpec(CONTROL_SKINS.default, {\n    size: 76,\n    labelSize: 11,\n    valueSize: 12,\n    wedgeInset: 8,\n    trackScale: 0.72,\n    valueScale: 1,\n  }),\n  // A quiet, compact software control: one matte surface and one position dot.\n  // There is deliberately no skirt, value arc or interaction glow.\n  minimal: knobSpec(CONTROL_SKINS.flat, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 0,\n    trackScale: 0.45,\n    indicator: \"dot\",\n    showTrack: false,\n    showValueArc: false,\n    showWedge: false,\n  }),\n  detailed: knobSpec(CONTROL_SKINS.default, {\n    size: 64,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 7,\n    trackScale: 0.72,\n    valueScale: 1,\n  }),\n  // Modern software look: matte cap, no moulded highlight, a thin arc and the\n  // dot marker that reads better against a flat face than a full radial line.\n  flat: knobSpec(CONTROL_SKINS.flat, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 5,\n    trackScale: 0.72,\n    valueScale: 0.72,\n    indicator: \"dot\",\n  }),\n  // Just the arc — for dense panels where a cap per parameter would be visual\n  // noise. The track is a hairline under the thicker active value arc; a line\n  // or dot can still be requested explicitly through the indicator prop.\n  ring: knobSpec(CONTROL_SKINS.minimal, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 5,\n    trackScale: 0.42,\n    valueScale: 0.72,\n    indicator: \"none\",\n  }),\n  // Cream-skirted hardware pot: eleven ticks and a dark pointer, the way a\n  // sixties console knob is engraved.\n  vintage: knobSpec(CONTROL_SKINS.vintage, {\n    size: 60,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 4,\n    ticks: 11,\n    trackScale: 0.45,\n    valueScale: 0.6,\n    indicator: \"line\",\n  }),\n  // Detented feel: the skirt ticks light up as the value passes them.\n  stepped: knobSpec(CONTROL_SKINS.default, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 5,\n    ticks: 9,\n    trackScale: 0.72,\n    valueScale: 1,\n    indicator: \"dot\",\n  }),\n  // Engraved hardware dial with fixed top-left lighting. Only the ivory index\n  // turns: the bevel and cap retain their screen-space lighting at every value.\n  studio: knobSpec(\n    { ...CONTROL_SKINS.default, ink: token.text },\n    {\n      size: 112,\n      labelSize: 12,\n      valueSize: 20,\n      wedgeInset: 0,\n      ticks: 31,\n      indicator: \"line\",\n      showTrack: false,\n      showValueArc: false,\n      showWedge: false,\n    }\n  ),\n  // Always-lit arc, for a dark panel that should look powered up at rest.\n  neon: knobSpec(CONTROL_SKINS.neon, {\n    size: 56,\n    labelSize: 10,\n    valueSize: 11,\n    wedgeInset: 6,\n    trackScale: 0.85,\n    valueScale: 0.85,\n  }),\n  // The two panel skins draw their own body entirely; they take the capless\n  // trait so the shared arc and wedge stay out of their way.\n  instrument: knobSpec(CONTROL_SKINS.minimal, {\n    size: 64,\n    labelSize: 9,\n    valueSize: 11,\n    wedgeInset: 0,\n  }),\n  molten: knobSpec(CONTROL_SKINS.minimal, {\n    size: 64,\n    labelSize: 9,\n    valueSize: 11,\n    wedgeInset: 0,\n  }),\n}\n\n/**\n * Diameter, label/readout sizes and wedge inset for the public size presets.\n * The scale keeps the small controls legible while giving XL enough visual\n * weight for a hero instrument parameter.\n */\nconst SIZE_PRESETS: Record<\n  KnobSize,\n  Pick<KnobVariantSpec, \"size\" | \"labelSize\" | \"valueSize\" | \"wedgeInset\">\n> = {\n  xs: { size: 28, labelSize: 8, valueSize: 9, wedgeInset: 3 },\n  s: { size: 40, labelSize: 9, valueSize: 10, wedgeInset: 4 },\n  m: { size: 56, labelSize: 10, valueSize: 11, wedgeInset: 6 },\n  l: { size: 76, labelSize: 11, valueSize: 12, wedgeInset: 8 },\n  xl: { size: 96, labelSize: 12, valueSize: 13, wedgeInset: 10 },\n}\n\n/** The preset readouts. `ms` takes seconds in, the way an envelope stores it. */\nfunction presetFormatter(\n  display: KnobValueDisplay,\n  min: number,\n  max: number\n): ValueFormatter {\n  switch (display) {\n    case \"percent\": {\n      const formatPercent = percent()\n      return (value) => formatPercent(normalizeValue01(value, min, max))\n    }\n    case \"db\":\n      return defaultFormat(0, \"dB\")\n    case \"hz\":\n      return defaultFormat(0, \"Hz\")\n    case \"ms\":\n      return defaultFormat(0, \"s\")\n    default:\n      return (value) => `${value}`\n  }\n}\n\nexport function Knob({\n  title,\n  value,\n  defaultValue = 0,\n  onChange,\n  onChangeEnd,\n  onInteraction,\n  min = 0,\n  max = 1,\n  step = 0,\n  label,\n  path,\n  labelPosition = \"bottom\",\n  captionWidth,\n  labelCompressible = true,\n  labelNarrow = false,\n  showLabelOnHover: requestedShowLabelOnHover,\n  valueReveal,\n  textStyles,\n  unit = \"\",\n  unitSize,\n  format,\n  valueDisplay,\n  display,\n  hideValue = false,\n  hideLabel = false,\n  renderDesign,\n  cursor = \"ns-resize\",\n  variant,\n  skin,\n  size,\n  visual,\n  visualOptions,\n  rotationRange,\n  rotationOffset = -135,\n  sweep,\n  bipolar = false,\n  curve = \"linear\",\n  pizza = \"interaction\",\n  activeColor,\n  accentClass,\n  glow = false,\n  trackScale: trackScaleOverride,\n  arcWidth,\n  arcCap: requestedArcCap,\n  showTrack,\n  showCenter = false,\n  indicator,\n  tickCount: requestedTickCount,\n  scaleLabels,\n  interaction: requestedInteraction = \"vertical\",\n  dragDistance,\n  wheelSensitivity,\n  axisTolerance,\n  includeIntoTabOrder = true,\n  midi,\n  activity,\n  activityOnChange = false,\n  disabled = false,\n  className,\n  style,\n  \"aria-label\": ariaLabel,\n}: KnobProps) {\n  const range = { min, max, step }\n  const [dragging, setDragging] = useState(false)\n  const [wheeling, setWheeling] = useState(false)\n  const [keyboardEditing, setKeyboardEditing] = useState(false)\n  const [current, commit, valueInteraction] = useControlledValue(\n    value,\n    defaultValue,\n    onChange,\n    range\n  )\n  const incomingActivity = activity ?? (activityOnChange ? current : undefined)\n  const activityPulse = useCcActivityPulse(\n    disabled ||\n      (typeof incomingActivity === \"number\" &&\n        !Number.isFinite(incomingActivity))\n      ? undefined\n      : incomingActivity\n  )\n  const labelId = useId()\n  const knobId = useId()\n\n  // An explicit variant wins; otherwise the knob wears its own `skin`, then\n  // whatever the surrounding panel declared, then the kit default.\n  const inheritedSkin = useControlSkin(skin)\n  const resolvedVariant: KnobVariant = variant ?? inheritedSkin\n  const panel = resolvedVariant === \"panel\"\n  const arcCap = requestedArcCap ?? (panel ? \"round\" : \"square\")\n  const textOnly = resolvedVariant === \"text\"\n  const showLabelOnHover = requestedShowLabelOnHover ?? !textOnly\n  // A text surface has no circular geometry; keep its adjustment vertical.\n  const interaction =\n    textOnly && requestedInteraction === \"circular\"\n      ? \"vertical\"\n      : requestedInteraction\n  const variantSpec = VARIANTS[resolvedVariant] ?? VARIANTS.default\n  const presetSpec = typeof size === \"string\" ? SIZE_PRESETS[size] : undefined\n  // `variant` carries the looks, `size` the layout scale, and the two compose:\n  // a semantic size replaces the variant's dimensions and leaves its cap,\n  // ticks and marker exactly as the variant asked for them.\n  const spec: KnobVariantSpec = presetSpec\n    ? { ...variantSpec, ...presetSpec }\n    : variantSpec\n  const diameter = typeof size === \"number\" ? size : spec.size\n  const labelSize =\n    resolvedVariant === \"studio\"\n      ? Math.min(12, Math.max(9, diameter * 0.1))\n      : spec.labelSize\n  const valueSize =\n    resolvedVariant === \"studio\"\n      ? Math.min(20, Math.max(14, diameter * 0.17))\n      : spec.valueSize\n  const effectiveDragDistance = resolveKnobDragDistance(\n    diameter,\n    dragDistance,\n    step,\n    min,\n    max\n  )\n  const effectiveWheelSensitivity = resolveKnobWheelSensitivity(\n    wheelSensitivity,\n    step > 0 ? step : undefined,\n    min,\n    max\n  )\n  // `sweep` is the old name for the same thing; honour it when present.\n  const sweepDeg = rotationRange ?? sweep ?? 270\n\n  const svgRef = useRef<SVGSVGElement | null>(null)\n  const knobmanVisualRef = useRef<HTMLSpanElement | null>(null)\n  const latest = useRef(current)\n  latest.current = current\n\n  const { mapTo01, mapFrom01 } = useMemo(\n    () => controlCurveMapping(curve),\n    [curve]\n  )\n\n  const formatter = useMemo<ValueFormatter>(() => {\n    const source =\n      display !== undefined\n        ? () => display\n        : (format ??\n          (valueDisplay\n            ? presetFormatter(valueDisplay, min, max)\n            : defaultFormat(step > 0 ? step : 0.01, unit)))\n    return (value) =>\n      formatControlValueLabel(source(value), unit, step > 0 ? step : 0.01)\n  }, [display, format, valueDisplay, min, max, step, unit])\n\n  const { notify } = useFirstInteraction(onInteraction)\n\n  const handleValueChange = useCallback(\n    (next: number) => commit(next),\n    [commit]\n  )\n\n  const { onKeyDown } = useKnobKeyboardControls({\n    valueRaw: current,\n    valueMin: min,\n    valueMax: max,\n    step: step > 0 ? step : undefined,\n    onValueRawChange: handleValueChange,\n    disabled,\n  })\n\n  const handleDoubleClick = () => {\n    if (disabled) return\n    const reset = sanitize(defaultValue, min, max, step)\n    commit(reset)\n    onChangeEnd?.(reset)\n  }\n\n  /**\n   * Circular interaction is absolute, not incremental: the cap follows the\n   * pointer's angle exactly. It bypasses the headless drag maths for that\n   * reason, but still routes through the same commit path.\n   *\n   * `circularAngleToValue01` owns the awkward parts — the knob's own sweep\n   * offset, the gap at the bottom it must not flip across, and the few pixels\n   * around the spindle where an angle means nothing.\n   */\n  const handleCircularPointerDown = (event: PointerEvent<HTMLElement>) => {\n    if (disabled || interaction !== \"circular\" || event.button !== 0) return\n    valueInteraction.begin()\n    const applyAngle = (clientX: number, clientY: number) => {\n      const rect = (\n        knobmanVisualRef.current ?? svgRef.current\n      )?.getBoundingClientRect()\n      if (!rect) return\n      const next01 = circularAngleToValue01({\n        dx: clientX - (rect.left + rect.width / 2),\n        dy: clientY - (rect.top + rect.height / 2),\n        rotationOffset,\n        sweep: sweepDeg,\n        previous01: normalizeValue01(latest.current, min, max, mapTo01),\n      })\n      if (next01 === null) return\n      commit(mapFrom01(next01, min, max))\n    }\n\n    applyAngle(event.clientX, event.clientY)\n    setDragging(true)\n\n    const move = (moveEvent: globalThis.PointerEvent) =>\n      applyAngle(moveEvent.clientX, moveEvent.clientY)\n    const up = () => {\n      setDragging(false)\n      window.removeEventListener(\"pointermove\", move)\n      window.removeEventListener(\"pointerup\", up)\n      onChangeEnd?.(latest.current)\n      valueInteraction.end()\n    }\n    window.addEventListener(\"pointermove\", move)\n    window.addEventListener(\"pointerup\", up)\n  }\n\n  const t = normalizeValue01(current, min, max, mapTo01)\n  const half = diameter / 2\n  const requestedWidth = arcWidth ?? (panel ? 4 : undefined)\n  const fixedStrokeWidth =\n    requestedWidth !== undefined && Number.isFinite(requestedWidth)\n      ? Math.min(Math.max(1, requestedWidth), diameter / 6)\n      : undefined\n  const baseStrokeWidth = fixedStrokeWidth ?? Math.max(3, diameter * 0.09)\n  const strokeWidth =\n    fixedStrokeWidth ??\n    baseStrokeWidth * (spec.valueScale ?? spec.trackScale ?? 1)\n  const trackStrokeWidth =\n    baseStrokeWidth *\n    (trackScaleOverride ??\n      (fixedStrokeWidth !== undefined\n        ? 1\n        : (spec.trackScale ?? spec.valueScale ?? 1)))\n  const trackStrokeStyle =\n    trackScaleOverride === undefined\n      ? `var(--uikit-knob-track-width, ${trackStrokeWidth}px)`\n      : `${trackStrokeWidth}px`\n  // A ticked variant pulls the arc in and spends the reclaimed outer ring on\n  // its skirt, so ticks never paint outside the knob's own box and crowd a\n  // neighbouring control.\n  const requestedTicks = requestedTickCount ?? spec.ticks ?? 0\n  const tickCount = Number.isFinite(requestedTicks)\n    ? Math.min(101, Math.max(0, Math.floor(requestedTicks)))\n    : 0\n  const tickRoom = tickCount ? Math.max(3, diameter * 0.1) : 0\n  const radius = half - strokeWidth / 2 - 1 - tickRoom\n  const startAngle = rotationOffset\n  const endAngle = rotationOffset + sweepDeg\n  const valueAngle = rotationOffset + t * sweepDeg\n  // Bipolar knobs light out from the sweep's midpoint.\n  const centreAngle = rotationOffset + sweepDeg / 2\n\n  const trackPath = describeArc(half, half, radius, startAngle, endAngle)\n  const valuePath = bipolar\n    ? describeArc(\n        half,\n        half,\n        radius,\n        Math.min(centreAngle, valueAngle),\n        Math.max(centreAngle, valueAngle)\n      )\n    : describeArc(half, half, radius, startAngle, valueAngle)\n\n  const studio = resolvedVariant === \"studio\"\n  const capRadius = studio\n    ? diameter * 0.36\n    : panel\n      ? diameter * 0.34\n      : radius - strokeWidth * 0.9\n  // The line spans two fifths of the cap radius and ends just inside its rim.\n  const pointerOuterRadius = capRadius - 1.5\n  const pointerOuter = polarToCartesian(\n    half,\n    half,\n    pointerOuterRadius,\n    valueAngle\n  )\n  const pointerInner = polarToCartesian(\n    half,\n    half,\n    pointerOuterRadius - capRadius * 0.4,\n    valueAngle\n  )\n\n  const capStyle: CapTreatment = spec.cap\n  // An explicit `indicator` always wins; otherwise the variant chooses.\n  const marker = indicator ?? spec.indicator ?? \"line\"\n  const trackVisible = showTrack ?? spec.showTrack ?? true\n\n  // Dot indicator: scaled to the knob and inset far enough from the rim that it\n  // never clips the cap outline, at any variant size.\n  const dotRadius = Math.max(1.5, diameter * 0.055)\n  const dotCentre = polarToCartesian(\n    half,\n    half,\n    capRadius - dotRadius - 2,\n    valueAngle\n  )\n  const gradientId = `uikit-knob-${knobId.replace(/[^a-zA-Z0-9]/g, \"\")}`\n\n  const lit = activeColor ?? token.accent\n  // `--uikit-knob-pointer` recolours the indicator without changing the arc.\n  const markerInk = studio\n    ? token.studioPointer\n    : `var(--uikit-knob-pointer, ${spec.ink ?? lit})`\n  const active = dragging || wheeling || keyboardEditing\n  const knobmanVisualRenderer = useKnobmanVisualRenderer()\n  const wedgeVisible = pizza === \"always\" || (pizza === \"interaction\" && active)\n\n  // The pizza wedge: a conic gradient rotated to the sweep's start, with the\n  // coloured slice running exactly as far as the value. Rendered behind the\n  // SVG so the arc and cap sit on top of it.\n  const wedge = spec.showWedge !== false &&\n    pizza !== \"never\" &&\n    capStyle !== \"none\" && (\n      <span\n        aria-hidden\n        style={{\n          position: \"absolute\",\n          inset: -spec.wedgeInset,\n          borderRadius: \"50%\",\n          background: `conic-gradient(from ${startAngle}deg, ${lit} 0deg, ${lit} ${t * sweepDeg}deg, transparent ${t * sweepDeg}deg)`,\n          opacity: wedgeVisible ? 0.28 : 0,\n          transition: \"opacity 0.2s\",\n          pointerEvents: \"none\",\n        }}\n      />\n    )\n\n  const customSkin =\n    resolvedVariant === \"instrument\" || resolvedVariant === \"molten\"\n  const customArcRadius = tickCount > 1 ? 26 : 29\n  const customCircumference = 2 * Math.PI * customArcRadius\n  const customArcLength = customCircumference * 0.75\n  const customCap = diameter * 0.72\n  const customStrokeWidth =\n    fixedStrokeWidth === undefined ? 2.5 : (fixedStrokeWidth * 64) / diameter\n  const customTrackStrokeWidth =\n    customStrokeWidth *\n    (trackScaleOverride ?? (fixedStrokeWidth === undefined ? 0.8 : 1))\n  const customTrackStrokeStyle =\n    trackScaleOverride === undefined\n      ? `var(--uikit-knob-track-width, ${customTrackStrokeWidth}px)`\n      : `${customTrackStrokeWidth}px`\n  // Forty percent of the cap radius, still anchored at its outer edge.\n  const customPointer = customCap * 0.2\n\n  const customKnobBody = customSkin ? (\n    <span\n      style={{\n        position: \"relative\",\n        display: \"inline-flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n      }}\n    >\n      <svg\n        ref={svgRef}\n        viewBox=\"0 0 64 64\"\n        width={diameter}\n        height={diameter}\n        aria-hidden\n        style={{ display: \"block\", overflow: \"visible\", position: \"relative\" }}\n      >\n        {evenFractions(tickCount).map((fraction, index) => {\n          const angle = rotationOffset + fraction * sweepDeg\n          const inner = polarToCartesian(32, 32, 30, angle)\n          const outer = polarToCartesian(32, 32, 32, angle)\n          return (\n            <line\n              key={index}\n              data-uikit-knob-tick=\"instrument\"\n              x1={inner.x}\n              y1={inner.y}\n              x2={outer.x}\n              y2={outer.y}\n              stroke=\"var(--knob-tick, var(--uikit-text-muted, #94a3b8))\"\n              strokeWidth=\"0.65\"\n              opacity=\"0.7\"\n            />\n          )\n        })}\n        <circle\n          cx=\"32\"\n          cy=\"32\"\n          r={customArcRadius}\n          fill=\"none\"\n          strokeWidth={customTrackStrokeWidth}\n          strokeLinecap={arcCap}\n          style={{ strokeWidth: customTrackStrokeStyle }}\n          stroke={\n            resolvedVariant === \"instrument\"\n              ? \"var(--knob-track, rgb(255 255 255 / 0.09))\"\n              : \"rgb(255 255 255 / 0.09)\"\n          }\n          strokeDasharray={`${customArcLength} ${customCircumference}`}\n          transform=\"rotate(135 32 32)\"\n        />\n        <circle\n          cx=\"32\"\n          cy=\"32\"\n          r={customArcRadius}\n          fill=\"none\"\n          strokeWidth={customStrokeWidth}\n          strokeLinecap={arcCap}\n          stroke=\"currentColor\"\n          className={accentClass}\n          strokeDasharray={`${customArcLength * t} ${customCircumference}`}\n          transform=\"rotate(135 32 32)\"\n          style={\n            resolvedVariant === \"instrument\"\n              ? {\n                  stroke: activeColor ?? \"var(--knob-arc, currentColor)\",\n                  strokeWidth: `var(--uikit-knob-arc-width, ${customStrokeWidth}px)`,\n                }\n              : {\n                  filter: \"drop-shadow(0 0 3px currentColor)\",\n                  strokeWidth: `var(--uikit-knob-arc-width, ${customStrokeWidth}px)`,\n                }\n          }\n        />\n      </svg>\n      <div\n        data-uikit-knob-overlay\n        style={{\n          position: \"absolute\",\n          inset: 0,\n          display: \"grid\",\n          placeItems: \"center\",\n          pointerEvents: \"none\",\n        }}\n      >\n        <div\n          data-uikit-knob-cap\n          style={{\n            position: \"relative\",\n            width: customCap,\n            height: customCap,\n            borderRadius: \"50%\",\n            border: \"1px solid\",\n            background:\n              resolvedVariant === \"instrument\"\n                ? \"var(--knob-cap, radial-gradient(circle at 38% 28%, #36464c 0%, #172329 44%, #091015 100%))\"\n                : \"var(--uikit-knob-cap, radial-gradient(circle at 38% 28%, #3a3835 0%, #1c1b19 45%, #0d0c0b 100%))\",\n            borderColor:\n              resolvedVariant === \"instrument\"\n                ? \"var(--knob-cap-border, rgb(207 250 254 / 0.1))\"\n                : \"var(--uikit-knob-cap-border, rgb(255 255 255 / 0.07))\",\n            boxShadow:\n              resolvedVariant === \"instrument\"\n                ? glow\n                  ? \"inset 0 1px 1px rgb(255 255 255/0.08), 0 3px 9px rgb(0 0 0/0.75), 0 0 10px 1px rgb(103 232 249/0.18)\"\n                  : \"inset 0 1px 1px rgb(255 255 255/0.07), 0 3px 9px rgb(0 0 0/0.75)\"\n                : glow\n                  ? \"inset 0 1px 1px rgb(255 255 255/0.07), 0 3px 8px rgb(0 0 0/0.7), 0 0 12px 1px rgb(234 88 12/0.28)\"\n                  : \"var(--uikit-knob-shadow, inset 0 1px 1px rgb(255 255 255/0.06), 0 3px 8px rgb(0 0 0/0.7))\",\n          }}\n        >\n          <div\n            data-uikit-knob-pointer\n            style={{\n              position: \"absolute\",\n              inset: 0,\n              transform: `rotate(${valueAngle}deg)`,\n            }}\n          >\n            <div\n              className={accentClass}\n              style={{\n                position: \"absolute\",\n                top: \"4%\",\n                left: \"50%\",\n                transform: \"translateX(-50%)\",\n                width: 2,\n                height: customPointer,\n                borderRadius: 9999,\n                backgroundColor:\n                  resolvedVariant === \"instrument\"\n                    ? `var(--knob-pointer, ${activeColor ?? \"currentColor\"})`\n                    : \"var(--uikit-knob-pointer, currentColor)\",\n              }}\n            />\n          </div>\n        </div>\n      </div>\n    </span>\n  ) : null\n\n  const stockKnobBody = customSkin ? (\n    customKnobBody\n  ) : (\n    <span\n      style={{\n        position: \"relative\",\n        display: \"inline-flex\",\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        margin: scaleLabels?.length ? \"12px 14px\" : undefined,\n      }}\n    >\n      {wedge}\n      <svg\n        ref={svgRef}\n        width={diameter}\n        height={diameter}\n        viewBox={`0 0 ${diameter} ${diameter}`}\n        aria-hidden\n        style={{ display: \"block\", overflow: \"visible\", position: \"relative\" }}\n      >\n        <defs>\n          <linearGradient id={gradientId} x1=\"0\" y1=\"1\" x2=\"1\" y2=\"0\">\n            <stop offset=\"0%\" stopColor={lit} />\n            <stop offset=\"100%\" stopColor={activeColor ?? token.accent2} />\n          </linearGradient>\n          <radialGradient id={`${gradientId}-cap`} cx=\"0.5\" cy=\"0.32\" r=\"0.75\">\n            <stop offset=\"0%\" stopColor=\"rgba(255,255,255,0.16)\" />\n            <stop offset=\"60%\" stopColor=\"rgba(255,255,255,0.02)\" />\n            <stop offset=\"100%\" stopColor=\"rgba(0,0,0,0.22)\" />\n          </radialGradient>\n          {studio && (\n            <>\n              <linearGradient\n                id={`${gradientId}-bevel`}\n                x1=\"0\"\n                y1=\"0\"\n                x2=\"0.8\"\n                y2=\"1\"\n              >\n                <stop offset=\"0%\" stopColor={token.studioBevelLight} />\n                <stop offset=\"24%\" stopColor=\"var(--uikit-cap, #484a47)\" />\n                <stop offset=\"65%\" stopColor={token.studioCapShadow} />\n                <stop offset=\"100%\" stopColor={token.studioBevelDark} />\n              </linearGradient>\n              <linearGradient\n                id={`${gradientId}-face`}\n                x1=\"0\"\n                y1=\"0\"\n                x2=\"0.7\"\n                y2=\"1\"\n              >\n                <stop offset=\"0%\" stopColor=\"var(--uikit-cap, #363735)\" />\n                <stop offset=\"100%\" stopColor={token.studioCapShadow} />\n              </linearGradient>\n            </>\n          )}\n          {capStyle === \"vintage\" && (\n            // SVG cannot fill from a CSS gradient, so the shared cream is\n            // rebuilt here as a paint server from the same stops.\n            <radialGradient\n              id={`${gradientId}-vintage`}\n              cx=\"0.42\"\n              cy=\"0.3\"\n              r=\"0.8\"\n            >\n              {VINTAGE_FACE_STOPS.map((stop) => (\n                <stop\n                  key={stop.offset}\n                  offset={stop.offset}\n                  stopColor={stop.color}\n                />\n              ))}\n            </radialGradient>\n          )}\n        </defs>\n\n        {/* Unlit track */}\n        {trackVisible && (\n          <path\n            d={trackPath}\n            fill=\"none\"\n            stroke={token.track}\n            strokeWidth={trackStrokeWidth}\n            strokeLinecap={arcCap}\n            style={{\n              strokeWidth: trackStrokeStyle,\n            }}\n          />\n        )}\n\n        {/* Lit value arc */}\n        {spec.showValueArc !== false && valuePath && (\n          <path\n            d={valuePath}\n            fill=\"none\"\n            stroke={\n              accentClass\n                ? \"currentColor\"\n                : spec.solidFill\n                  ? lit\n                  : `url(#${gradientId})`\n            }\n            className={accentClass}\n            strokeWidth={strokeWidth}\n            strokeLinecap={arcCap}\n            style={{\n              strokeWidth: `var(--uikit-knob-arc-width, ${strokeWidth}px)`,\n              filter: (() => {\n                const glow = panel\n                  ? undefined\n                  : litGlow(lit, strokeWidth, active, spec.glowAtRest)\n                return glow ? `drop-shadow(${glow})` : undefined\n              })(),\n            }}\n          />\n        )}\n\n        {/* Skirt ticks. They light up to the value, so a detented control\n            reads its position from the engraving as well as from the arc. */}\n        {evenFractions(tickCount).map((frac, i) => {\n          const angle = rotationOffset + frac * sweepDeg\n          const outer = polarToCartesian(half, half, half - 1, angle)\n          const inner = polarToCartesian(\n            half,\n            half,\n            half - 1 - tickRoom * (studio ? (i % 5 === 0 ? 0.65 : 0.32) : 0.72),\n            angle\n          )\n          // Bipolar knobs fill out from the middle, exactly as the arc does.\n          const passed = isMarkLit(frac, t, bipolar)\n          return (\n            <line\n              key={i}\n              data-uikit-knob-tick={\n                studio ? (i % 5 === 0 ? \"major\" : \"minor\") : undefined\n              }\n              x1={inner.x}\n              y1={inner.y}\n              x2={outer.x}\n              y2={outer.y}\n              stroke={studio ? token.text : passed ? lit : token.border}\n              strokeWidth={\n                studio\n                  ? i % 5 === 0\n                    ? 1.5\n                    : 0.8\n                  : Math.max(1, diameter * 0.02)\n              }\n              strokeLinecap={studio ? \"butt\" : \"round\"}\n              opacity={\n                studio ? (i % 5 === 0 ? 0.9 : 0.55) : passed ? 0.95 : 0.6\n              }\n            />\n          )\n        })}\n\n        {scaleLabels?.map(({ value: scaleValue, label: scaleLabel }, index) => {\n          const angle =\n            rotationOffset +\n            normalizeValue01(scaleValue, min, max, mapTo01) * sweepDeg\n          const position = polarToCartesian(half, half, half + 9, angle)\n          return (\n            <text\n              key={index}\n              data-uikit-knob-scale={scaleValue}\n              x={position.x}\n              y={position.y}\n              textAnchor=\"middle\"\n              dominantBaseline=\"central\"\n              fill={token.textMuted}\n              fontFamily={token.font}\n              fontSize={Math.min(10, Math.max(8, diameter * 0.08))}\n            >\n              {scaleLabel}\n            </text>\n          )\n        })}\n\n        {/* Centre detent tick */}\n        {(bipolar || showCenter) && (\n          <line\n            x1={\n              polarToCartesian(\n                half,\n                half,\n                radius + strokeWidth * 0.1,\n                centreAngle\n              ).x\n            }\n            y1={\n              polarToCartesian(\n                half,\n                half,\n                radius + strokeWidth * 0.1,\n                centreAngle\n              ).y\n            }\n            x2={\n              polarToCartesian(\n                half,\n                half,\n                radius - strokeWidth * 0.9,\n                centreAngle\n              ).x\n            }\n            y2={\n              polarToCartesian(\n                half,\n                half,\n                radius - strokeWidth * 0.9,\n                centreAngle\n              ).y\n            }\n            stroke={token.border}\n            strokeWidth={1.5}\n            strokeLinecap=\"round\"\n          />\n        )}\n\n        {/* Cap. \"flat\" skips the moulded highlight, \"vintage\" swaps the dark\n            plastic for a cream skirt, \"none\" leaves the arc on its own. */}\n        {studio ? (\n          <g data-uikit-knob-cap=\"studio\">\n            <circle\n              cx={half}\n              cy={half + diameter * 0.015}\n              r={capRadius + diameter * 0.03}\n              fill=\"#080908\"\n              style={{\n                filter: `drop-shadow(0 ${diameter * 0.025}px ${diameter * 0.025}px rgb(0 0 0 / .6))`,\n              }}\n            />\n            <circle\n              cx={half}\n              cy={half}\n              r={capRadius + diameter * 0.02}\n              fill={`url(#${gradientId}-bevel)`}\n              stroke=\"#0e0f0e\"\n              strokeWidth={1}\n            />\n            <circle\n              cx={half}\n              cy={half}\n              r={capRadius}\n              fill={`url(#${gradientId}-face)`}\n              stroke=\"#101210\"\n              strokeWidth={diameter * 0.012}\n            />\n          </g>\n        ) : (\n          capStyle !== \"none\" && (\n            <>\n              <circle\n                cx={half}\n                cy={half}\n                r={capRadius}\n                fill={\n                  capStyle === \"vintage\"\n                    ? `url(#${gradientId}-vintage)`\n                    : token.cap\n                }\n              />\n              {capStyle === \"moulded\" && (\n                <circle\n                  cx={half}\n                  cy={half}\n                  r={capRadius}\n                  fill={`url(#${gradientId}-cap)`}\n                />\n              )}\n            </>\n          )\n        )}\n        {!studio && capStyle !== \"none\" && (\n          <circle\n            cx={half}\n            cy={half}\n            r={capRadius}\n            fill=\"none\"\n            stroke={token.border}\n            strokeWidth={1}\n          />\n        )}\n\n        {/* Position marker: optional on the capless arc-only treatment. */}\n        {marker === \"dot\" ? (\n          <circle\n            data-uikit-knob-indicator=\"dot\"\n            cx={dotCentre.x}\n            cy={dotCentre.y}\n            r={dotRadius}\n            fill={markerInk}\n          />\n        ) : marker === \"line\" ? (\n          <line\n            data-uikit-knob-indicator=\"line\"\n            x1={pointerInner.x}\n            y1={pointerInner.y}\n            x2={pointerOuter.x}\n            y2={pointerOuter.y}\n            stroke={\n              panel ? markerInk : accentClass ? \"currentColor\" : markerInk\n            }\n            className={panel ? undefined : accentClass}\n            strokeWidth={panel ? 2.5 : Math.max(2, diameter * 0.045)}\n            strokeLinecap={studio || panel ? \"butt\" : \"round\"}\n          />\n        ) : null}\n      </svg>\n    </span>\n  )\n\n  const renderedKnobmanVisual =\n    !textOnly && visual && knobmanVisualRenderer\n      ? knobmanVisualRenderer({\n          source: visual,\n          value01: t,\n          cssWidth: diameter,\n          cssHeight: diameter,\n          options: visualOptions ?? {},\n          active,\n          disabled,\n        })\n      : null\n\n  const readout = formatter(current)\n  const renderedReadout = (\n    <FormattedControlValue\n      formatted={readout}\n      fit\n      unitSize={unitSize ?? (textOnly ? \"0.4em\" : undefined)}\n      unitStyle={textOnly ? textStyles?.unit : undefined}\n    />\n  )\n  const textWidth =\n    captionWidth !== undefined && Number.isFinite(captionWidth)\n      ? Math.max(1, captionWidth)\n      : diameter * 2\n  const textHorizontal = labelPosition === \"left\" || labelPosition === \"right\"\n  const textLabelFirst = labelPosition === \"top\" || labelPosition === \"left\"\n  const textSlot = (first: boolean) =>\n    showLabelOnHover || !label\n      ? \"1 / 1\"\n      : textHorizontal\n        ? `1 / ${first ? 1 : 2}`\n        : `${first ? 1 : 2} / 1`\n  const textValueStyle: CSSProperties = {\n    fontFamily:\n      \"var(--uikit-text-knob-font, var(--uikit-font-mono, monospace))\",\n    fontSize: `var(--uikit-text-knob-size, ${diameter * 0.57}px)`,\n    fontVariantNumeric: \"tabular-nums\",\n    lineHeight: 1.1,\n    color:\n      activeColor ??\n      \"var(--uikit-text-knob-color, var(--uikit-text, currentColor))\",\n    ...textStyles?.value,\n  }\n  const knobBody = renderDesign ? (\n    <span\n      aria-hidden\n      style={{\n        display: \"inline-flex\",\n        width: diameter,\n        height: diameter,\n        position: \"relative\",\n      }}\n    >\n      {renderDesign({\n        value01: t,\n        size: diameter,\n        active,\n        activity: activityPulse,\n        disabled,\n        formattedValue: readout,\n      })}\n    </span>\n  ) : textOnly ? (\n    <KnobHeadlessReadout\n      label={label}\n      labelFor={knobId}\n      labelCompressible={labelCompressible}\n      labelNarrow={labelNarrow}\n      showLabelOnHover={showLabelOnHover}\n      valueReveal={valueReveal}\n      value={<span data-uikit-knob-text>{renderedReadout}</span>}\n      style={{\n        width: textWidth,\n        gap: showLabelOnHover || !label ? 0 : textHorizontal ? 10 : 6,\n        gridTemplateColumns:\n          textHorizontal && label && !showLabelOnHover\n            ? textLabelFirst\n              ? \"auto minmax(0, 1fr)\"\n              : \"minmax(0, 1fr) auto\"\n            : \"minmax(0, 1fr)\",\n        gridTemplateRows:\n          textHorizontal || showLabelOnHover || !label ? \"auto\" : \"auto auto\",\n        ...textStyles?.layout,\n      }}\n      labelStyle={{\n        ...textValueStyle,\n        fontSize: showLabelOnHover ? textValueStyle.fontSize : labelSize,\n        color:\n          \"var(--uikit-text-knob-label, var(--uikit-text-muted, currentColor))\",\n        textTransform: \"uppercase\",\n        letterSpacing: \"0.08em\",\n        gridArea: textSlot(textLabelFirst),\n        ...textStyles?.label,\n      }}\n      outputStyle={{\n        ...textValueStyle,\n        gridArea: textSlot(!textLabelFirst),\n        ...textStyles?.value,\n      }}\n    />\n  ) : renderedKnobmanVisual === null ? (\n    stockKnobBody\n  ) : (\n    <span\n      ref={knobmanVisualRef}\n      data-uikit-knobman-visual\n      data-uikit-knobman-template={visual?.templateName}\n      data-uikit-knobman-setting-id={visual?.settingId}\n      aria-hidden\n      style={{\n        position: \"relative\",\n        display: \"inline-flex\",\n        width: diameter,\n        height: diameter,\n        alignItems: \"center\",\n        justifyContent: \"center\",\n        overflow: \"hidden\",\n      }}\n    >\n      {renderedKnobmanVisual}\n    </span>\n  )\n\n  // The shared headless slot overlays both texts inside the knob's fixed-width\n  // readout, so long labels and values cannot widen a knob column.\n  // Without a caption there is nothing to swap, so keep the numeric value\n  // visible as before.\n  const splitCaption = labelPosition === \"split\"\n  const showReadout =\n    !textOnly && !hideValue && (!label || hideLabel || splitCaption)\n  const skinLabelColor =\n    resolvedVariant === \"instrument\"\n      ? \"var(--knob-label, #94a3b8)\"\n      : resolvedVariant === \"molten\"\n        ? \"#d6d3d1\"\n        : token.textMuted\n  const skinValueColor =\n    resolvedVariant === \"instrument\"\n      ? \"var(--knob-value, #cffafe)\"\n      : resolvedVariant === \"molten\"\n        ? \"#fdba74\"\n        : token.text\n  const horizontal = labelPosition === \"left\" || labelPosition === \"right\"\n  const captionTextWidth =\n    captionWidth !== undefined && Number.isFinite(captionWidth)\n      ? Math.max(1, captionWidth)\n      : textOnly\n        ? diameter * 2\n        : diameter\n\n  const renderBody = (\n    control: ReactNode,\n    badge: ReactNode,\n    pulse: boolean | undefined\n  ): ReactNode => {\n    if (textOnly)\n      return (\n        <>\n          {label && (\n            <span\n              id={labelId}\n              style={{\n                position: \"absolute\",\n                width: 1,\n                height: 1,\n                overflow: \"hidden\",\n                clipPath: \"inset(50%)\",\n                whiteSpace: \"nowrap\",\n              }}\n            >\n              {label}\n            </span>\n          )}\n          {control}\n          {pulse !== undefined && <KnobActivityRing active={pulse} />}\n          {badge && (\n            <span\n              style={{ position: \"absolute\", top: -5, right: -7, zIndex: 1 }}\n            >\n              {badge}\n            </span>\n          )}\n        </>\n      )\n    const dial = (\n      <span\n        data-uikit-knob-dial\n        style={{\n          position: \"relative\",\n          display: \"inline-flex\",\n          flexShrink: 0,\n          lineHeight: 0,\n        }}\n      >\n        {control}\n        {pulse !== undefined && <KnobActivityRing active={pulse} circular />}\n      </span>\n    )\n    if ((!label || hideLabel) && !showReadout)\n      return (\n        <>\n          {dial}\n          {badge && (\n            <span\n              style={{ position: \"absolute\", top: -5, right: -7, zIndex: 1 }}\n            >\n              {badge}\n            </span>\n          )}\n        </>\n      )\n\n    const caption =\n      label && !hideLabel ? (\n        <KnobHeadlessReadout\n          label={label}\n          labelCompressible={labelCompressible}\n          labelNarrow={labelNarrow}\n          value={renderedReadout}\n          labelFor={knobId}\n          showLabelOnHover={showLabelOnHover}\n          valueReveal={valueReveal}\n          hideValue={hideValue || splitCaption}\n          trailing={badge}\n          style={{ width: captionTextWidth }}\n          labelStyle={{\n            fontFamily: \"var(--uikit-knob-label-font, inherit)\",\n            fontSize: labelSize,\n            fontWeight: \"var(--uikit-knob-label-weight, 600)\" as CSSProperties[\"fontWeight\"],\n            letterSpacing: \"var(--uikit-knob-label-tracking, 0.08em)\",\n            textTransform: \"var(--uikit-knob-label-case, uppercase)\" as CSSProperties[\"textTransform\"],\n            color: `var(--uikit-knob-label, ${skinLabelColor})`,\n            lineHeight: `${(splitCaption ? labelSize : Math.max(labelSize, valueSize)) * 1.2}px`,\n            textAlign:\n              labelPosition === \"left\"\n                ? \"right\"\n                : labelPosition === \"right\"\n                  ? \"left\"\n                  : \"center\",\n            cursor: disabled ? \"not-allowed\" : \"pointer\",\n            ...textStyles?.label,\n          }}\n          outputStyle={{\n            fontFamily: `var(--uikit-knob-value-font, ${token.fontMono})`,\n            fontSize: valueSize,\n            lineHeight: `${Math.max(labelSize, valueSize) * 1.2}px`,\n            fontVariantNumeric: \"tabular-nums\",\n            color: `var(--uikit-knob-value, ${skinValueColor})`,\n            ...textStyles?.value,\n          }}\n        />\n      ) : null\n\n    return (\n      <div\n        style={{\n          display: \"inline-flex\",\n          flexDirection: horizontal ? \"row\" : \"column\",\n          alignItems: \"center\",\n          justifyContent: \"center\",\n          gap: horizontal ? 10 : 6,\n          ...(horizontal\n            ? {}\n            : {\n                width: Math.max(\n                  diameter + (scaleLabels?.length ? 28 : 0),\n                  captionTextWidth\n                ),\n              }),\n          boxSizing: \"border-box\",\n          fontFamily: token.font,\n        }}\n      >\n        {label && (\n          <span\n            id={labelId}\n            style={{\n              position: \"absolute\",\n              width: 1,\n              height: 1,\n              padding: 0,\n              margin: -1,\n              overflow: \"hidden\",\n              clip: \"rect(0, 0, 0, 0)\",\n              whiteSpace: \"nowrap\",\n              border: 0,\n            }}\n          >\n            {label}\n          </span>\n        )}\n        {label &&\n          (labelPosition === \"top\" ||\n            labelPosition === \"left\" ||\n            splitCaption) &&\n          caption}\n\n        {dial}\n\n        {showReadout && (\n          <output\n            htmlFor={knobId}\n            style={{\n              display: \"block\",\n              boxSizing: \"border-box\",\n              width: captionTextWidth,\n              maxWidth: \"100%\",\n              minWidth: 0,\n              overflow: \"hidden\",\n              textOverflow: \"ellipsis\",\n              fontFamily: `var(--uikit-knob-value-font, ${token.fontMono})`,\n              fontSize: valueSize,\n              lineHeight: `${valueSize * 1.2}px`,\n              fontVariantNumeric: \"tabular-nums\",\n              color: `var(--uikit-knob-value, ${skinValueColor})`,\n              whiteSpace: \"nowrap\",\n              textAlign: \"center\",\n              ...textStyles?.value,\n            }}\n          >\n            {renderedReadout}\n          </output>\n        )}\n\n        {label &&\n          (labelPosition === \"bottom\" || labelPosition === \"right\") &&\n          caption}\n      </div>\n    )\n  }\n\n  const renderFrame: KnobHeadlessProps[\"renderFrame\"] = ({ control, chrome }) =>\n    renderBody(\n      control,\n      chrome.owns ? chrome.badge : null,\n      activity !== undefined || activityOnChange || (chrome.cc && chrome.owns)\n        ? !disabled &&\n            (activityPulse || (chrome.cc && chrome.owns ? chrome.pulse : false))\n        : undefined\n    )\n  const hasCcIdentity = path !== undefined || midi !== undefined\n  const knob = (\n    <KnobHeadless\n      title={title}\n      id={knobId}\n      className={`uikit-control${className ? ` ${className}` : \"\"}`}\n      valueRaw={current}\n      valueMin={min}\n      valueMax={max}\n      // Convert \"pixels across the range\" into normalised units per pixel.\n      dragSensitivity={1 / effectiveDragDistance}\n      valueRawRoundFn={(raw) =>\n        step > 0 ? sanitize(raw, min, max, step) : raw\n      }\n      valueRawDisplayFn={formatter}\n      onValueRawChange={handleValueChange}\n      mapTo01={mapTo01}\n      mapFrom01={mapFrom01}\n      step={step > 0 ? step : undefined}\n      axis={\n        interaction === \"horizontal\" ? \"x\" : interaction === \"xy\" ? \"xy\" : \"y\"\n      }\n      // Circular drives the value from the pointer's angle in this component;\n      // the headless linear drag would write over it on every move.\n      dragEnabled={interaction !== \"circular\"}\n      editing={interaction === \"circular\" && dragging}\n      {...(axisTolerance === undefined ? {} : { axisTolerance })}\n      includeIntoTabOrder={includeIntoTabOrder && !disabled}\n      wheelSensitivity={effectiveWheelSensitivity}\n      cursor={cursor}\n      disabled={disabled}\n      {...(hasCcIdentity\n        ? {\n            ...(path ? { path } : {}),\n            ...(midi ? { midi } : {}),\n            ccLabel: label ?? ariaLabel ?? path ?? \"Knob\",\n            ccRange: { min, max, ...(unit ? { unit } : {}) },\n            ccPresentation: {\n              label: label ?? ariaLabel ?? path ?? \"Knob\",\n              value: readout,\n              ...(path ? { path } : {}),\n              range: `${min}–${max}${unit ? ` ${unit}` : \"\"}`,\n            },\n            renderCcBadge: false as const,\n          }\n        : {})}\n      {...(hasCcIdentity ||\n      activity !== undefined ||\n      activityOnChange ||\n      !hideLabel ||\n      !hideValue\n        ? { renderFrame }\n        : {})}\n      aria-label={\n        label && !hideLabel ? undefined : (ariaLabel ?? label ?? \"Knob\")\n      }\n      aria-labelledby={label && !hideLabel ? labelId : undefined}\n      onInteraction={notify}\n      onWheelingChange={(next) => {\n        setWheeling(next)\n        if (next) valueInteraction.begin()\n        else {\n          valueInteraction.end()\n          onChangeEnd?.(latest.current)\n        }\n      }}\n      onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {\n        if (\n          [\n            \"ArrowUp\",\n            \"ArrowDown\",\n            \"ArrowLeft\",\n            \"ArrowRight\",\n            \"PageUp\",\n            \"PageDown\",\n            \"Home\",\n            \"End\",\n          ].includes(event.key)\n        )\n          setKeyboardEditing(true)\n        onKeyDown(event)\n      }}\n      onKeyUp={() => {\n        if (keyboardEditing) onChangeEnd?.(latest.current)\n        setKeyboardEditing(false)\n      }}\n      onBlur={() => {\n        if (keyboardEditing) onChangeEnd?.(latest.current)\n        setKeyboardEditing(false)\n      }}\n      onDoubleClick={handleDoubleClick}\n      onPointerDown={(event) => {\n        if (event.button !== 0) return\n        if (interaction === \"circular\") handleCircularPointerDown(event)\n        else {\n          valueInteraction.begin()\n          setDragging(true)\n        }\n      }}\n      onPointerUp={() => {\n        if (interaction === \"circular\") return\n        setDragging(false)\n        if (dragging) onChangeEnd?.(latest.current)\n        valueInteraction.end()\n      }}\n      onPointerCancel={() => {\n        if (interaction === \"circular\") return\n        setDragging(false)\n        if (dragging) onChangeEnd?.(latest.current)\n        valueInteraction.end()\n      }}\n      style={{\n        display: \"inline-flex\",\n        ...(textOnly ? { justifyContent: \"center\", minHeight: 32 } : {}),\n        cursor: disabled\n          ? \"not-allowed\"\n          : dragging && cursor === \"grab\"\n            ? \"grabbing\"\n            : cursor,\n        outline: \"none\",\n        ...style,\n      }}\n    >\n      {knobBody}\n    </KnobHeadless>\n  )\n\n  return knob\n}\n\n/** Uses the existing mapping activity chrome for host events as well. The\n * scoped media rule also works when a knob is installed without face styles. */\nfunction KnobActivityRing({\n  active,\n  circular = false,\n}: {\n  active: boolean\n  circular?: boolean\n}) {\n  return (\n    <>\n      <style>\n        {\n          \"@media(prefers-reduced-motion:reduce){[data-uikit-knob-activity]{transition:none!important}}\"\n        }\n      </style>\n      <span\n        aria-hidden\n        data-uikit-knob-activity={active ? \"active\" : \"idle\"}\n        style={{\n          ...ccActivityRingStyle(active),\n          ...(circular ? { borderRadius: \"50%\" } : {}),\n        }}\n      />\n    </>\n  )\n}\n",
      "type": "registry:component",
      "target": "@ui/knob.tsx"
    }
  ]
}
