{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ui-dial",
  "title": "Rotary dial",
  "description": "Rotary dial that follows the pointer around a 270° arc, flashing a tick and rolling the value at each detent, then springs onto the step.",
  "registryDependencies": [
    "https://elements.lumesec.ai/r/lumesec-theme.json",
    "https://elements.lumesec.ai/r/lumesec-color.json",
    "https://elements.lumesec.ai/r/lumesec-math.json",
    "https://elements.lumesec.ai/r/lumesec-motion.json",
    "https://elements.lumesec.ai/r/lumesec-roll-text.json",
    "https://elements.lumesec.ai/r/lumesec-use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/components/lumesec/ui-dial.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { resolveColor } from \"@/lib/lumesec/color\";\nimport { clamp, rgba } from \"@/lib/lumesec/math\";\nimport { prefersReducedMotion, springEuler, useAnimationLoop } from \"@/lib/lumesec/motion\";\nimport { RollText } from \"@/lib/lumesec/roll-text\";\nimport { useControllableState } from \"@/lib/lumesec/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface UiDialProps extends Omit<React.ComponentProps<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Controlled value, snapped to `step` and clamped to `min` and `max`. Use with `onValueChange`. */\n  value?: number;\n  /** Initial value when uncontrolled. */\n  defaultValue?: number;\n  /** Lowest value. */\n  min?: number;\n  /** Highest value. A `max` below `min` is treated as `min`. */\n  max?: number;\n  /** Detent size, greater than 0 (anything else falls back to 1). Its decimal places set the display precision. */\n  step?: number;\n  /** Caption under the dial and the slider's accessible name. */\n  label?: string;\n  /** Called on every detent change from drag, wheel or keys. */\n  onValueChange?: (value: number) => void;\n  /** Called with each wheel or key step, and when a drag ends, even if the value did not change. */\n  onValueCommit?: (value: number) => void;\n}\n\ninterface Tick {\n  /** Angle from straight up, -135° to 135°. */\n  degrees: number;\n  major: boolean;\n  x1: number;\n  y1: number;\n  x2: number;\n  y2: number;\n}\n\n/** Detents per key press. */\nconst KEY_STEPS = new Map<string, number>([\n  [\"ArrowUp\", 1],\n  [\"ArrowRight\", 1],\n  [\"ArrowDown\", -1],\n  [\"ArrowLeft\", -1],\n  [\"PageUp\", 10],\n  [\"PageDown\", -10],\n]);\n\n/** Above this many ticks the arc gets evenly spaced ticks instead of one per detent. */\nconst MAX_TICKS = 91;\n\n/**\n * Rotary dial that follows the pointer around a 270° arc, flashing a tick and rolling the value at each\n * detent, then springs onto the step.\n *\n * @example\n * <UiDial defaultValue={0.7} min={0} max={1} step={0.1} label=\"Temperature\" />\n */\nexport function UiDial({\n  value: valueProp,\n  defaultValue = 0.7,\n  min = 0,\n  max: maxProp = 1,\n  step: stepProp = 0.1,\n  label = \"Temperature\",\n  onValueChange,\n  onValueCommit,\n  className,\n  ...props\n}: UiDialProps) {\n  // an inverted range or a step of 0 would divide by zero in the angle and snapping math\n  const max = Math.max(min, maxProp);\n  const step = stepProp > 0 ? stepProp : 1;\n  const decimals = decimalPlaces(step);\n\n  const [rawValue, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const value = snapToStep(rawValue, min, max, step);\n\n  // derived state: the value rolls up when it grows, down when it shrinks\n  const [shown, setShown] = React.useState({ value, direction: 1 });\n  if (shown.value !== value) setShown({ value, direction: value > shown.value ? 1 : -1 });\n\n  const [dragging, setDragging] = React.useState(false);\n  const [initialAngle] = React.useState(() => angleFor(value, min, max));\n  const dialRef = React.useRef<HTMLDivElement>(null);\n  const knobRef = React.useRef<HTMLDivElement>(null);\n  const tickRefs = React.useRef<(SVGLineElement | null)[]>([]);\n\n  // knob physics: the angle (`position`, in degrees) springs to `target` unless a drag holds it\n  const knob = React.useRef({ position: initialAngle, velocity: 0, target: initialAngle, dragging: false });\n  const current = React.useRef(value);\n  const latest = React.useRef({ min, max, step, onValueCommit });\n  React.useEffect(() => {\n    current.current = value;\n    latest.current = { min, max, step, onValueCommit };\n  });\n\n  const ticks = React.useMemo(() => buildTicks(min, max, step), [min, max, step]);\n\n  const loop = useAnimationLoop(dialRef, (_time, delta) => {\n    const state = knob.current;\n    const dt = Math.min(0.04, delta);\n    if (!state.dragging) {\n      if (prefersReducedMotion()) {\n        state.position = state.target;\n        state.velocity = 0;\n      } else {\n        for (let i = 0; i < 2; i++) springEuler(state, state.target, dt / 2, 420, 0.55);\n      }\n    }\n    if (knobRef.current) knobRef.current.style.transform = `rotate(${state.position}deg)`;\n    ticks.forEach((tick, i) => {\n      const line = tickRefs.current[i];\n      const lit = tick.degrees <= state.position + 0.6;\n      if (line && line.hasAttribute(\"data-lit\") !== lit) line.toggleAttribute(\"data-lit\", lit);\n    });\n    return state.dragging || Math.abs(state.target - state.position) > 0.02 || Math.abs(state.velocity) > 0.05;\n  });\n\n  // every new value flashes its detent tick and nudges the knob; outside a drag the knob springs to it\n  const previousValue = React.useRef(value);\n  React.useEffect(() => {\n    if (previousValue.current === value) return;\n    previousValue.current = value;\n    const angle = angleFor(value, min, max);\n    if (!prefersReducedMotion()) {\n      const line = tickRefs.current[nearestMajorTick(ticks, angle)];\n      if (line) {\n        line.animate(\n          [{ stroke: rgba(resolveColor(line, \"--lumesec-glint\")), strokeWidth: \"4\" }, { strokeWidth: \"2.2\" }],\n          {\n            duration: 450,\n            easing: \"ease-out\",\n          },\n        );\n      }\n      knobRef.current?.animate([{ scale: \"0.975\" }, { scale: \"1\" }], { duration: 160, easing: \"ease-out\" });\n    }\n    if (!knob.current.dragging) knob.current.target = angle;\n    loop.kick();\n  }, [value, min, max, ticks, loop]);\n\n  // a new range re-aims the knob without the flash\n  React.useEffect(() => {\n    knob.current.target = angleFor(current.current, min, max);\n    loop.kick();\n  }, [min, max, loop]);\n\n  /** Apply a value from user input. Drags report changes only; the commit comes when the drag ends. */\n  const update = React.useCallback(\n    (raw: number, fromDrag = false) => {\n      const { min: low, max: high, step: size, onValueCommit: commit } = latest.current;\n      const next = snapToStep(raw, low, high, size);\n      if (next === current.current) return;\n      current.current = next;\n      setValue(next);\n      if (!fromDrag) commit?.(next);\n    },\n    [setValue],\n  );\n\n  // the wheel must not scroll the page, which needs a non-passive listener\n  React.useEffect(() => {\n    const dial = dialRef.current;\n    if (!dial) return;\n    const onWheel = (event: WheelEvent) => {\n      // a sideways scroll (deltaY 0) is not a turn and leaves the page alone\n      if (event.deltaY === 0) return;\n      event.preventDefault();\n      update(current.current + (event.deltaY < 0 ? 1 : -1) * latest.current.step);\n    };\n    dial.addEventListener(\"wheel\", onWheel, { passive: false });\n    return () => dial.removeEventListener(\"wheel\", onWheel);\n  }, [update]);\n\n  const track = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    const dx = event.clientX - (rect.left + rect.width / 2);\n    const dy = event.clientY - (rect.top + rect.height / 2);\n    const state = knob.current;\n    let angle = (Math.atan2(dx, -dy) * 180) / Math.PI;\n    // dead zone at the bottom: stick to the nearer end\n    if (angle > 135 || angle < -135) angle = state.position > 0 ? 135 : -135;\n    state.position = angle;\n    state.velocity = 0;\n    update(min + ((angle + 135) / 270) * (max - min), true);\n    loop.kick();\n  };\n\n  const endDrag = () => {\n    const state = knob.current;\n    if (!state.dragging) return;\n    state.dragging = false;\n    setDragging(false);\n    state.target = angleFor(current.current, min, max);\n    loop.kick();\n    latest.current.onValueCommit?.(current.current);\n  };\n\n  const format = (v: number) => v.toFixed(decimals);\n\n  return (\n    <div\n      data-slot=\"ui-dial\"\n      className={cn(\"inline-grid justify-items-center gap-1.5 leading-normal\", className)}\n      {...props}\n    >\n      <div\n        ref={dialRef}\n        role=\"slider\"\n        tabIndex={0}\n        aria-valuenow={value}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-label={label}\n        data-dragging={dragging ? \"\" : undefined}\n        className=\"relative size-[148px] cursor-grab touch-none rounded-full outline-none select-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-lumesec focus-visible:outline-solid data-[dragging]:cursor-grabbing\"\n        onPointerDown={(event) => {\n          if (event.button !== 0) return;\n          event.currentTarget.setPointerCapture(event.pointerId);\n          event.currentTarget.focus({ preventScroll: true });\n          knob.current.dragging = true;\n          setDragging(true);\n          track(event);\n        }}\n        onPointerMove={(event) => {\n          if (knob.current.dragging) track(event);\n        }}\n        onPointerUp={endDrag}\n        onPointerCancel={endDrag}\n        onKeyDown={(event) => {\n          const next = keyTarget(event.key, current.current, min, max, step);\n          if (next === null) return;\n          event.preventDefault();\n          update(next);\n        }}\n      >\n        <svg viewBox=\"0 0 148 148\" aria-hidden=\"true\" className=\"absolute inset-0 size-full overflow-visible\">\n          {ticks.map((tick, i) => (\n            <line\n              key={i}\n              ref={(node) => {\n                tickRefs.current[i] = node;\n              }}\n              x1={tick.x1}\n              y1={tick.y1}\n              x2={tick.x2}\n              y2={tick.y2}\n              // the render loop owns `data-lit` after the first paint\n              data-lit={tick.degrees <= initialAngle + 0.6 ? \"\" : undefined}\n              className={cn(\n                \"stroke-foreground/22 [stroke-width:1.5] [stroke-linecap:round] [transition:stroke_.18s_ease] data-[lit]:stroke-lumesec\",\n                tick.major &&\n                  \"[stroke-width:2.2] data-[lit]:[filter:drop-shadow(0_0_3px_color-mix(in_srgb,var(--lumesec)_70%,transparent))]\",\n              )}\n            />\n          ))}\n        </svg>\n        <div\n          ref={knobRef}\n          className=\"absolute top-1/2 left-1/2 -mt-12 -ml-12 size-24 rounded-full bg-[radial-gradient(circle_at_35%_28%,color-mix(in_srgb,var(--card)_70%,#fff_30%),var(--card)_60%,var(--muted))] shadow-[0_10px_24px_-10px_rgba(0,0,0,.45),0_2px_4px_rgba(0,0,0,.15),inset_0_0_0_1px_var(--border)]\"\n          style={{ transform: `rotate(${initialAngle}deg)` }}\n        >\n          <span className=\"absolute top-[9px] left-1/2 -ml-0.5 h-[13px] w-1 rounded-[2px] bg-lumesec shadow-[0_0_8px_color-mix(in_srgb,var(--lumesec)_60%,transparent)]\" />\n        </div>\n        <div className=\"pointer-events-none absolute inset-0 grid place-items-center text-[22px] font-semibold text-foreground tabular-nums\">\n          <RollText text={format(value)} direction={shown.direction} />\n        </div>\n      </div>\n      <div\n        aria-hidden=\"true\"\n        className=\"-mt-[18px] flex w-[120px] justify-between font-mono text-[11px] text-muted-foreground\"\n      >\n        <span>{format(min)}</span>\n        <span>{format(max)}</span>\n      </div>\n      <span className=\"text-[12.5px] text-muted-foreground\">{label}</span>\n    </div>\n  );\n}\n\n/** Number of decimal places in a step such as 0.1, 0.05 or 1e-7. */\nfunction decimalPlaces(step: number) {\n  const [mantissa, exponent = \"0\"] = String(step).split(\"e\");\n  const fractionDigits = mantissa.split(\".\")[1]?.length ?? 0;\n  return clamp(fractionDigits - Number(exponent), 0, 20);\n}\n\n/**\n * Round an SVG coordinate to 3 decimals. Server and browser trig can differ in the last digits, and the\n * ticks render on the server, so unrounded values would not hydrate.\n */\nconst coord = (value: number) => Math.round(value * 1000) / 1000;\n\n/** Knob angle for a value: -135° at `min` to 135° at `max`. */\nfunction angleFor(value: number, min: number, max: number) {\n  return max > min ? -135 + (270 * (value - min)) / (max - min) : -135;\n}\n\n/** The value a key moves to from `value` (snapped later), or null for keys the dial ignores. */\nfunction keyTarget(key: string, value: number, min: number, max: number, step: number) {\n  if (key === \"Home\") return min;\n  if (key === \"End\") return max;\n  const detents = KEY_STEPS.get(key);\n  return detents === undefined ? null : value + detents * step;\n}\n\n/** Snap `raw` to the nearest detent, counted from `min`, and clamp it to the range. */\nfunction snapToStep(raw: number, min: number, max: number, step: number) {\n  const snapped = Math.round((raw - min) / step) * step + min;\n  // toFixed drops float noise such as 0.30000000000000004\n  return +clamp(snapped, min, max).toFixed(decimalPlaces(step));\n}\n\n/**\n * Tick geometry: with 16 or fewer detents each step gets a major tick plus two minor ones; with more, one tick\n * per detent, capped at `MAX_TICKS` evenly spaced ticks for very fine steps.\n */\nfunction buildTicks(min: number, max: number, step: number): Tick[] {\n  const detents = Math.round((max - min) / step) + 1;\n  const perDetent = detents > 16 ? 1 : 3;\n  const count = Math.min(MAX_TICKS, (detents - 1) * perDetent + 1);\n  return Array.from({ length: count }, (_, j) => {\n    const degrees = count > 1 ? -135 + (270 * j) / (count - 1) : -135;\n    const radians = (degrees * Math.PI) / 180;\n    const major = j % perDetent === 0;\n    const outer = 72;\n    const inner = major ? 62 : 66;\n    return {\n      degrees,\n      major,\n      x1: coord(74 + Math.sin(radians) * outer),\n      y1: coord(74 - Math.cos(radians) * outer),\n      x2: coord(74 + Math.sin(radians) * inner),\n      y2: coord(74 - Math.cos(radians) * inner),\n    };\n  });\n}\n\n/** Index of the major tick closest to `degrees`: the detent tick that flashes. */\nfunction nearestMajorTick(ticks: readonly Tick[], degrees: number) {\n  let nearest = -1;\n  ticks.forEach((tick, i) => {\n    if (tick.major && (nearest < 0 || Math.abs(tick.degrees - degrees) < Math.abs(ticks[nearest].degrees - degrees)))\n      nearest = i;\n  });\n  return nearest;\n}\n",
      "type": "registry:component",
      "target": "@components/lumesec/ui-dial.tsx"
    }
  ],
  "meta": {
    "tag": "ui-dial",
    "collection": "interaction",
    "react": "UiDial",
    "kind": "react"
  },
  "docs": "Rotary dial docs: https://elements.lumesec.ai/components/interaction/dial",
  "categories": [
    "interaction",
    "controls"
  ],
  "type": "registry:component"
}