{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "CometDial-JS-CSS",
	"title": "CometDial",
	"description": "Tick-ring dial you flick by angle: the reading launches on a spring and a velocity-driven comet streaks behind the lit head, trailing the direction of travel and vanishing at rest.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "CometDial.css",
			"target": "@components/CometDial.css",
			"content": ".comet-dial {\n  --cd-accent: #f5f5f5;\n  --cd-ink: #fdfdfd;\n  --cd-size: 250px;\n  --cd-figure: 40px;\n\n  position: relative;\n  width: var(--cd-size);\n  height: var(--cd-size);\n  color: var(--cd-ink);\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.comet-dial__ring {\n  position: absolute;\n  inset: 0;\n  display: block;\n  width: 100%;\n  height: 100%;\n  border-radius: 50%;\n  outline: none;\n  cursor: grab;\n  touch-action: none;\n}\n\n.comet-dial[data-dragging] .comet-dial__ring {\n  cursor: grabbing;\n}\n\n.comet-dial__track,\n.comet-dial__lit,\n.comet-dial__comet path {\n  fill: none;\n  stroke-linecap: round;\n}\n\n.comet-dial__track {\n  stroke: var(--cd-ink);\n  opacity: 0.24;\n}\n\n.comet-dial__lit {\n  stroke: var(--cd-accent);\n  opacity: 0.7;\n}\n\n.comet-dial__comet path {\n  stroke: var(--cd-accent);\n}\n\n.comet-dial__head {\n  fill: var(--cd-accent);\n}\n\n.comet-dial__readout {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  pointer-events: none;\n}\n\n.comet-dial__value {\n  display: inline-flex;\n  align-items: baseline;\n  gap: 0.06em;\n  font-size: var(--cd-figure);\n  font-weight: 500;\n  line-height: 1;\n  letter-spacing: -0.04em;\n  font-variant-numeric: tabular-nums;\n}\n\n.comet-dial__unit {\n  font-size: 0.5em;\n  opacity: 0.5;\n}\n\n.comet-dial[data-disabled] {\n  opacity: 0.55;\n}\n\n.comet-dial[data-disabled] .comet-dial__ring {\n  cursor: default;\n  pointer-events: none;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "CometDial.jsx",
			"content": "'use client';\n\nimport { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';\nimport { animate, useMotionValue, useReducedMotion } from 'motion/react';\n\nimport './CometDial.css';\n\nconst R = 80;\nconst DEAD = 44;\nconst K = 12;\nconst V_FULL = 4;\nconst V_FLICK = 2;\nconst TAU_FOLLOW = 0.06;\nconst TAU_V = 0.05;\nconst DECEL = 0.99;\nconst DRAG_PX = 10;\nconst STALE_MS = 80;\n\nconst clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));\nconst velocityOf = hist => {\n  if (hist.length < 2) return 0;\n  const a = hist[0];\n  const b = hist[hist.length - 1];\n  return ((b[1] - a[1]) / Math.max(1, b[0] - a[0])) * 1000;\n};\nconst decimalsOf = step => {\n  const s = String(step);\n  const i = s.indexOf('.');\n  return i === -1 ? 0 : s.length - i - 1;\n};\nconst pointAt = deg => {\n  const a = (deg * Math.PI) / 180;\n  return [100 + Math.cos(a) * R, 100 + Math.sin(a) * R];\n};\nconst arcPath = (a0, a1) => {\n  const [x0, y0] = pointAt(a0);\n  const [x1, y1] = pointAt(a1);\n  return `M ${x0.toFixed(3)} ${y0.toFixed(3)} A ${R} ${R} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(3)} ${y1.toFixed(3)}`;\n};\n\nexport default function CometDial({\n  value,\n  defaultValue = 62,\n  min = 0,\n  max = 100,\n  step = 1,\n  unit = '%',\n  label = 'Level',\n  accent = '#f5f5f5',\n  ink = '#fdfdfd',\n  size = 250,\n  sweep = 320,\n  thickness = 5,\n  speed = 25,\n  tapBounce = 0.2,\n  flickBounce = 0.1,\n  momentum = 1,\n  cometReach = 180,\n  cometWidth = 12,\n  disabled = false,\n  onChange,\n  onChangeEnd,\n  className = ''\n}) {\n  const reduce = useReducedMotion();\n  const [dragging, setDragging] = useState(false);\n  const svgRef = useRef(null);\n  const litRef = useRef(null);\n  const headRef = useRef(null);\n  const comet = useRef([]);\n  const figure = useRef(null);\n  const target = useRef(clamp(value ?? defaultValue, min, max));\n  const grip = useRef(null);\n  const unbind = useRef(null);\n  const loop = useRef({ raf: 0, last: 0, v: 0, rPrev: null, text: '', valueText: '' });\n  const live = useRef({});\n  const reading = useMotionValue(target.current);\n\n  const range = Math.max(1e-9, max - min);\n  const gap = 360 - sweep;\n  const start = 90 + gap / 2;\n  const end = start + sweep;\n  const decimals = decimalsOf(step);\n  const k = 200 + (clamp(speed, 0, 100) / 100) * 700;\n  const crit = 2 * Math.sqrt(k);\n  const snap = v => (step > 0 ? clamp(Math.round((v - min) / step) * step + min, min, max) : clamp(v, min, max));\n\n  const commit = (v, finished, detail) => {\n    const s = snap(v);\n    if (s !== target.current) {\n      target.current = s;\n      onChange?.(s);\n    }\n    if (finished) onChangeEnd?.(s, detail ?? { velocity: 0, bounce: tapBounce });\n  };\n\n  const paintRef = useRef(null);\n  const tick = useCallback(now => paintRef.current?.(now), []);\n  const wake = () => {\n    const L = loop.current;\n    if (L.raf) return;\n    L.last = performance.now();\n    L.rPrev = null;\n    L.raf = requestAnimationFrame(tick);\n  };\n  const launch = (to, bounce, velocity = reading.getVelocity()) => {\n    reading.stop();\n    if (reduce) {\n      reading.jump(to);\n      wake();\n      return;\n    }\n    animate(reading, to, {\n      type: 'spring',\n      stiffness: k,\n      damping: crit * (1 - clamp(bounce, 0, 0.9)),\n      mass: 1,\n      velocity\n    });\n    wake();\n  };\n\n  const paint = now => {\n    const L = loop.current;\n    const svg = svgRef.current;\n    if (!svg) {\n      L.raf = 0;\n      return;\n    }\n    const dt = clamp((now - L.last) / 1000, 0, 0.05);\n    L.last = now;\n    const g = grip.current;\n    if (g && g.at != null) {\n      if (reduce) reading.jump(g.at);\n      else reading.set(reading.get() + (g.at - reading.get()) * (1 - Math.exp(-dt / TAU_FOLLOW)));\n    }\n    const r = reading.get();\n    const vRaw = L.rPrev == null || dt === 0 || reduce ? 0 : (r - L.rPrev) / dt / range;\n    L.rPrev = r;\n    L.v += (vRaw - L.v) * (1 - Math.exp(-dt / TAU_V));\n    const v = L.v;\n    const s = clamp(Math.abs(v) / V_FULL, 0, 1);\n    const dir = Math.sign(v) || 1;\n    const f = clamp((r - min) / range, 0, 1);\n    const ang = start + f * sweep;\n    if (litRef.current) litRef.current.setAttribute('d', f > 0.0005 ? arcPath(start, ang) : '');\n    if (headRef.current) {\n      const [cx, cy] = pointAt(ang);\n      headRef.current.setAttribute('cx', cx.toFixed(3));\n      headRef.current.setAttribute('cy', cy.toFixed(3));\n    }\n    const seg = (cometReach * s) / K;\n    for (let j = 0; j < K; j++) {\n      const el = comet.current[j];\n      if (!el) continue;\n      let a0 = dir > 0 ? ang - (j + 1) * seg : ang + j * seg;\n      let a1 = dir > 0 ? ang - j * seg : ang + (j + 1) * seg;\n      a0 = clamp(a0, start, end);\n      a1 = clamp(a1, start, end);\n      if (seg < 0.01 || a1 - a0 < 0.01) {\n        if (el.style.opacity !== '0') el.style.opacity = '0';\n        continue;\n      }\n      const w = 1 - j / K;\n      el.setAttribute('d', arcPath(a0, a1));\n      el.setAttribute('stroke-width', (thickness + cometWidth * w * s).toFixed(2));\n      el.style.opacity = (w * s).toFixed(3);\n    }\n    const shown = clamp(r, min, max).toFixed(decimals);\n    if (shown !== L.text) {\n      L.text = shown;\n      if (figure.current) figure.current.textContent = shown;\n      svg.setAttribute('aria-valuenow', shown);\n    }\n    const valueText = `${target.current}${unit}`;\n    if (valueText !== L.valueText) {\n      L.valueText = valueText;\n      svg.setAttribute('aria-valuetext', valueText);\n    }\n    const moving = !!g || reading.isAnimating() || Math.abs(v) > 0.002;\n    if (!moving) {\n      for (const el of comet.current) if (el && el.style.opacity !== '0') el.style.opacity = '0';\n    }\n    L.raf = moving ? requestAnimationFrame(tick) : 0;\n  };\n  paintRef.current = paint;\n\n  const localPoint = (cx, cy) => {\n    const b = svgRef.current.getBoundingClientRect();\n    return [((cx - b.left) / b.width) * 200 - 100, ((cy - b.top) / b.height) * 200 - 100];\n  };\n  const angleAt = (cx, cy) => {\n    const [x, y] = localPoint(cx, cy);\n    let rel = ((Math.atan2(y, x) * 180) / Math.PI - start + 720) % 360;\n    const g = grip.current;\n    if (rel > sweep) rel = g && g.side ? (g.side === 'hi' ? sweep : 0) : rel < sweep + gap / 2 ? sweep : 0;\n    else if (g) g.side = rel > sweep / 2 ? 'hi' : 'lo';\n    return min + (rel / sweep) * range;\n  };\n\n  const move = e => {\n    const g = grip.current;\n    if (!g || g.id !== e.pointerId) return;\n    if (!g.moved) {\n      if (Math.hypot(e.clientX - g.x0, e.clientY - g.y0) < DRAG_PX) return;\n      g.moved = true;\n      reading.stop();\n    }\n    g.at = angleAt(e.clientX, e.clientY);\n    g.hist.push([performance.now(), g.at]);\n    if (g.hist.length > 4) g.hist.shift();\n    commit(g.at, false);\n    wake();\n  };\n  const up = e => {\n    const g = grip.current;\n    if (!g || g.id !== e.pointerId) return;\n    grip.current = null;\n    unbind.current?.();\n    unbind.current = null;\n    setDragging(false);\n    try {\n      svgRef.current?.releasePointerCapture(e.pointerId);\n    } catch {}\n    if (!g.moved) {\n      commit(target.current, true);\n      return;\n    }\n    const stale = performance.now() - g.hist[g.hist.length - 1][0] > STALE_MS;\n    const v = e.type === 'pointercancel' || stale ? 0 : velocityOf(g.hist);\n    const bounce = tapBounce + (flickBounce - tapBounce) * clamp(Math.abs(v) / range / V_FLICK, 0, 1);\n    const to = snap((g.at ?? target.current) + (v / 1000) * (DECEL / (1 - DECEL)) * momentum);\n    commit(to, true, { velocity: v, bounce });\n    launch(to, bounce, v);\n  };\n  live.current = { move, up };\n\n  const down = e => {\n    if (disabled || grip.current || e.button > 0) return;\n    const [x, y] = localPoint(e.clientX, e.clientY);\n    if (Math.hypot(x, y) < DEAD) return;\n    e.preventDefault();\n    const svg = svgRef.current;\n    try {\n      svg.setPointerCapture(e.pointerId);\n    } catch {}\n    svg.focus({ preventScroll: true });\n    grip.current = { id: e.pointerId, at: null, hist: [], side: null, moved: false, x0: e.clientX, y0: e.clientY };\n    const at = angleAt(e.clientX, e.clientY);\n    grip.current.hist.push([performance.now(), at]);\n    setDragging(true);\n    commit(at, false);\n    launch(snap(at), tapBounce);\n    const onMove = ev => {\n      if (ev.isTrusted) live.current.move(ev);\n    };\n    const onUp = ev => {\n      if (ev.isTrusted) live.current.up(ev);\n    };\n    window.addEventListener('pointermove', onMove);\n    window.addEventListener('pointerup', onUp);\n    window.addEventListener('pointercancel', onUp);\n    unbind.current = () => {\n      window.removeEventListener('pointermove', onMove);\n      window.removeEventListener('pointerup', onUp);\n      window.removeEventListener('pointercancel', onUp);\n    };\n  };\n\n  const key = e => {\n    if (disabled) return;\n    const t = target.current;\n    const big = e.shiftKey ? 10 : 1;\n    let to;\n    switch (e.key) {\n      case 'ArrowRight':\n      case 'ArrowUp':\n        to = t + step * big;\n        break;\n      case 'ArrowLeft':\n      case 'ArrowDown':\n        to = t - step * big;\n        break;\n      case 'PageUp':\n        to = t + step * 10;\n        break;\n      case 'PageDown':\n        to = t - step * 10;\n        break;\n      case 'Home':\n        to = min;\n        break;\n      case 'End':\n        to = max;\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n    to = snap(to);\n    reading.stop();\n    reading.jump(to);\n    commit(to, true);\n    wake();\n  };\n\n  useLayoutEffect(() => {\n    const L = loop.current;\n    L.text = '';\n    L.valueText = '';\n    paintRef.current?.(performance.now());\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [min, max, step, sweep, thickness, cometReach, cometWidth, unit]);\n  useEffect(() => {\n    if (value === undefined || grip.current) return;\n    const s = snap(value);\n    if (s === target.current) return;\n    target.current = s;\n    launch(s, tapBounce);\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [value]);\n  useEffect(\n    () => () => {\n      cancelAnimationFrame(loop.current.raf);\n      unbind.current?.();\n      reading.stop();\n    },\n    [reading]\n  );\n\n  return (\n    <div\n      className={`comet-dial${className ? ` ${className}` : ''}`}\n      data-dragging={dragging ? '' : undefined}\n      data-disabled={disabled ? '' : undefined}\n      style={{\n        '--cd-accent': accent,\n        '--cd-ink': ink,\n        '--cd-size': `${size}px`,\n        '--cd-figure': `${Math.round(size * 0.16)}px`\n      }}\n    >\n      <svg\n        ref={svgRef}\n        className=\"comet-dial__ring\"\n        viewBox=\"0 0 200 200\"\n        role=\"slider\"\n        tabIndex={disabled ? -1 : 0}\n        aria-label={label}\n        aria-valuemin={min}\n        aria-valuemax={max}\n        aria-valuenow={target.current}\n        aria-valuetext={`${target.current}${unit}`}\n        aria-disabled={disabled || undefined}\n        onPointerDown={down}\n        onKeyDown={key}\n      >\n        <path className=\"comet-dial__track\" d={arcPath(start, end)} strokeWidth={thickness} />\n        <path ref={litRef} className=\"comet-dial__lit\" strokeWidth={thickness} />\n        <g className=\"comet-dial__comet\">\n          {Array.from({ length: K }, (_, j) => (\n            <path\n              key={j}\n              ref={el => {\n                comet.current[j] = el;\n              }}\n              style={{ opacity: 0 }}\n            />\n          ))}\n        </g>\n        <circle ref={headRef} className=\"comet-dial__head\" r={thickness * 1.8} />\n      </svg>\n      <div className=\"comet-dial__readout\" aria-hidden=\"true\">\n        <span className=\"comet-dial__value\">\n          <span ref={figure} className=\"comet-dial__figure\" />\n          {unit ? <span className=\"comet-dial__unit\">{unit}</span> : null}\n        </span>\n      </div>\n    </div>\n  );\n}\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"motion@^12.23.12"
	]
}