{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dial",
  "title": "Dial",
  "description": "A number you turn to. The arc sets a few on a half circle and rolls to your choice; the tuner is a long scale on the rim of a wheel you turn by hand, by scroll or by key, and your number reads above it in italic. Dynamics sets the number at the size of its loudness, from niente to fff; the tumbler sets a whole number figure by figure, like the wheels of a lock.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/dial.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\ntype DialValue = string | number\ntype DialOption = DialValue | { value: DialValue; label?: string }\n\ntype DialProps = Omit<React.ComponentProps<\"fieldset\">, \"defaultValue\" | \"onChange\"> & {\n  /** arc: a few choices on a half circle, no script. tuner: a long scale on the rim of a wheel you turn. dynamics: the number's type size is its loudness, from niente to fff. tumbler: set figure by figure, the one you turn held in parentheses. */\n  variant?: \"arc\" | \"tuner\" | \"dynamics\" | \"tumbler\"\n  /** The form field's name. */\n  name?: string\n  /** The question, as a small label over the dial. It also names the range. */\n  legend: React.ReactNode\n  /** The choices, in order. The arc takes up to nine; the tuner any number. */\n  options?: DialOption[]\n  /** Tuner without options, and dynamics: the scale runs from min to max by step. Tumbler: whole numbers from min to max. */\n  min?: number\n  max?: number\n  step?: number\n  /** Tuner: a numeral every this many steps, with a half-length tick halfway between. */\n  major?: number\n  value?: DialValue\n  defaultValue?: DialValue\n  onValueChange?: (value: DialValue) => void\n  /** Small words for the number, e.g. \"weeks\": under the arc, after the tuner's reading. */\n  unit?: string\n  /** Pins a state for documentation (\"hover\", \"focus\"); set on the root. */\n  \"data-force\"?: string\n}\n\nconst valueOf = (o: DialOption) => (typeof o === \"object\" ? o.value : o)\nconst labelOf = (o: DialOption) => (typeof o === \"object\" ? (o.label ?? String(o.value)) : String(o))\n\n/**\n * A number you turn to. The arc is a radio on a half circle, with no script: the chosen\n * number swells into italic and the dot travels. The tuner is a native range drawn as the\n * rim of a wheel you turn, for more numbers than fit in view.\n */\nfunction Dial({ variant = \"arc\", ...props }: DialProps) {\n  if (variant === \"dynamics\") return <Dynamics {...props} />\n  if (variant === \"tumbler\") return <Tumbler {...props} />\n  return variant === \"arc\" ? <Arc {...props} /> : <Turning variant={variant} {...props} />\n}\n\nfunction Arc({ name, legend, options = [], value, defaultValue, onValueChange, unit, className, style, ...props }: Omit<DialProps, \"variant\">) {\n  const generated = React.useId()\n  return (\n    <fieldset data-slot=\"dial\" data-variant=\"arc\" className={cn(\"ot-dial\", className)} style={{ \"--n\": options.length, ...style } as React.CSSProperties} {...props}>\n      <legend className=\"ot-label\">{legend}</legend>\n      <div data-slot=\"dial-face\" className=\"ot-dial-face\">\n        {options.map((option, i) => {\n          const v = valueOf(option)\n          const text = labelOf(option)\n          const mine = (x: DialValue | undefined) => x !== undefined && String(x) === String(v)\n          return (\n            <label key={String(v)} data-slot=\"dial-item\" data-text={text} style={{ \"--i\": i } as React.CSSProperties}>\n              <input\n                type=\"radio\"\n                name={name ?? generated}\n                value={String(v)}\n                aria-label={unit ? `${text} ${unit}` : undefined}\n                {...(value === undefined ? { defaultChecked: mine(defaultValue) } : { checked: mine(value) })}\n                onChange={() => onValueChange?.(v)}\n              />\n              <span>{text}</span>\n            </label>\n          )\n        })}\n        <span className=\"ot-dial-arc\" aria-hidden=\"true\" />\n        <span className=\"ot-dial-dot\" aria-hidden=\"true\" />\n        {unit ? (\n          <span className=\"ot-dial-unit\" aria-hidden=\"true\">\n            {unit}\n          </span>\n        ) : null}\n      </div>\n    </fieldset>\n  )\n}\n\ntype Kind = \"minor\" | \"half\" | \"major\"\n\n/** The numbers a turning dial runs through, by index: from the options, or from min to max by step. */\nfunction scaleOf(options: DialOption[] | undefined, min: number, max: number, step: number, major: number) {\n  if (options) {\n    const labels = options.map(labelOf)\n    return {\n      numeric: false,\n      count: options.length,\n      valueAt: (i: number) => valueOf(options[i]),\n      label: (i: number) => labels[i] ?? \"\",\n      mark: (i: number) => labels[i] ?? \"\",\n      indexOf: (v: DialValue) => Math.max(0, options.findIndex((o) => String(valueOf(o)) === String(v))),\n      kind: (i: number): Kind => (i % major === 0 ? \"major\" : major % 2 === 0 && i % (major / 2) === 0 ? \"half\" : \"minor\"),\n    }\n  }\n  const digits = (String(step).split(\".\")[1] ?? \"\").length\n  const count = Math.max(1, Math.floor((max - min) / step + 1e-9) + 1)\n  const at = (i: number) => Number((min + i * step).toFixed(digits))\n  // Marks fall on round numbers (88, 89 on a 87.5 to 108 scale), not on min plus a count.\n  const on = (i: number, every: number) => Math.abs(at(i) / every - Math.round(at(i) / every)) < 1e-6\n  return {\n    numeric: true,\n    count,\n    valueAt: at,\n    label: (i: number) => (i >= 0 && i < count ? at(i).toFixed(digits) : \"\"),\n    mark: (i: number) => String(at(i)),\n    indexOf: (v: DialValue) => Math.min(count - 1, Math.max(0, Math.round((Number(v) - min) / step))),\n    kind: (i: number): Kind => (on(i, major * step) ? \"major\" : major % 2 === 0 && on(i, (major / 2) * step) ? \"half\" : \"minor\"),\n  }\n}\n\ntype Live = {\n  wheel: HTMLElement | null\n  drum: HTMLElement | null\n  input: HTMLInputElement | null\n  last: number\n  label: (i: number) => string\n  commit: (i: number) => void\n  recentre: (i: number) => void\n}\n\n// ponytail: hand-tuned; the feel lives in these four numbers.\nconst SPRING = [121, 13.6] // ω 11, ζ 0.62: it lands with one small swing past (about 8%), the house spiccato\nconst CATCH = [676, 52] // ω 26, critically damped: the wheel catches up with a scroll without swinging\nconst COAST = 0.32 // seconds for a flick's speed to fall to a third\nconst STRETCH = 64 // px the rim gives under the hand past either end\n\n/**\n * The wheel's physics, outside React. One position, in steps, that a hand, a scroll or a key\n * moves; each frame it's written to the face as --pos and CSS places every mark from it.\n */\nfunction createTurn(start: number) {\n  let live: Live = { wheel: null, drum: null, input: null, last: 0, label: String, commit: () => {}, recentre: () => {} }\n  let pos = start\n  let vel = 0\n  let target = start\n  let scrolled = start\n  let mode: \"rest\" | \"held\" | \"coast\" | \"spring\" | \"wheel\" = \"rest\"\n  let follow = false // a hand or a scroll is turning it, so the value follows the wheel\n  let committed = start\n  let centre = start\n  let raf = 0\n  let then = 0\n  let timer = 0\n  let pitch = 24\n  let k = 1\n  let dir = 1\n  let still = false\n  let grip: { x: number; from: number; moved: boolean; halted: boolean; trail: [number, number][] } | null = null\n\n  const clamp = (i: number) => Math.min(live.last, Math.max(0, i))\n\n  // Read at the start of each gesture, so a resize, a direction or a motion setting is always current.\n  const measure = () => {\n    const wheel = live.wheel\n    if (!wheel) return\n    const cs = getComputedStyle(wheel)\n    pitch = parseFloat(cs.getPropertyValue(\"--ot-dial-pitch\")) || 24\n    k = parseFloat(cs.getPropertyValue(\"--k\")) || 1\n    dir = cs.direction === \"rtl\" ? -1 : 1\n    still = matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n  }\n\n  const paint = () => {\n    const { wheel, drum, last, label } = live\n    wheel?.parentElement?.style.setProperty(\"--pos\", pos.toFixed(4))\n    const near = clamp(Math.round(pos))\n    if (near !== centre) {\n      centre = near\n      live.recentre(near)\n    }\n    if (follow && near !== committed) {\n      committed = near\n      live.commit(near)\n    }\n    if (drum) {\n      // The drum turns with the wheel like a counter: between two numbers, both show.\n      const p = Math.min(last, Math.max(0, pos))\n      const i = still ? Math.round(p) : Math.floor(p)\n      drum.dataset.a = label(i)\n      drum.dataset.b = label(i + 1)\n      drum.style.setProperty(\"--f\", still ? \"0\" : (p - i).toFixed(4))\n    }\n  }\n\n  const frame = (now: number) => {\n    const dt = Math.min(0.05, (now - then) / 1000)\n    then = now\n    const n = Math.max(1, Math.ceil(dt / 0.008))\n    for (let j = 0; j < n; j++) {\n      const h = dt / n\n      if (mode === \"coast\") {\n        vel *= Math.exp(-h / COAST)\n        pos += vel * h\n        // Slow enough, or past an end: catch the step it's heading for.\n        if (pos < 0 || pos > live.last || Math.abs(vel) * pitch < 90) {\n          mode = \"spring\"\n          target = clamp(Math.round(pos + vel / 11))\n        }\n      } else {\n        const [stiff, damp] = mode === \"wheel\" ? CATCH : SPRING\n        vel += (-stiff * (pos - (mode === \"wheel\" ? scrolled : target)) - damp * vel) * h\n        pos += vel * h\n      }\n    }\n    if (mode === \"spring\" && Math.abs(pos - target) < 0.001 && Math.abs(vel) < 0.01) {\n      pos = target\n      vel = 0\n      mode = \"rest\"\n    }\n    paint()\n    raf = mode === \"rest\" || mode === \"held\" ? 0 : requestAnimationFrame(frame)\n  }\n\n  const run = () => {\n    if (raf) return\n    then = performance.now()\n    raf = requestAnimationFrame(frame)\n  }\n\n  const land = (i: number) => {\n    target = clamp(i)\n    if (still) {\n      pos = target\n      vel = 0\n      mode = \"rest\"\n      paint()\n      return\n    }\n    mode = \"spring\"\n    run()\n  }\n\n  /** A key or a tap: the value is set now, and the wheel turns to it. */\n  const set = (i: number) => {\n    measure()\n    follow = false\n    const to = clamp(i)\n    if (to !== committed) {\n      committed = to\n      live.commit(to)\n    }\n    land(to)\n  }\n\n  const held = (el: Element, on: boolean) => el.closest(\"fieldset\")?.toggleAttribute(\"data-held\", on)\n\n  return {\n    /** React hands over the current elements, range and callbacks after every render. */\n    sync(next: Live) {\n      live = next\n    },\n    committed: () => committed,\n    set,\n    /** The value changed from outside: the wheel turns to it. */\n    to(i: number) {\n      // While a hand or a scroll turns it, the wheel leads and React's renders trail it: ignore their echo.\n      if (follow && mode !== \"rest\") return\n      measure()\n      follow = false\n      committed = i\n      land(i)\n    },\n    down(e: React.PointerEvent<HTMLElement>) {\n      const input = live.input\n      if (e.button !== 0 || !input || input.matches(\":disabled\")) return\n      e.preventDefault() // keeps the focus given below, and stops text selection\n      input.focus({ preventScroll: true })\n      measure()\n      e.currentTarget.setPointerCapture(e.pointerId)\n      const halted = mode !== \"rest\" && Math.abs(vel) > 1 // still settling counts as still\n      cancelAnimationFrame(raf)\n      raf = 0\n      clearTimeout(timer)\n      mode = \"held\"\n      follow = true\n      vel = 0\n      grip = { x: e.clientX, from: pos, moved: false, halted, trail: [[e.timeStamp, e.clientX]] }\n      held(e.currentTarget, true)\n    },\n    move(e: React.PointerEvent<HTMLElement>) {\n      if (!grip) return\n      const dx = e.clientX - grip.x\n      if (!grip.moved && Math.abs(dx) < 4) return\n      grip.moved = true\n      const last = live.last\n      const raw = grip.from - (dx * dir) / pitch\n      const over = raw < 0 ? raw : raw > last ? raw - last : 0\n      const give = STRETCH / pitch\n      // Past an end the rim stretches, less the further you pull.\n      pos = over ? (over < 0 ? 0 : last) + Math.sign(over) * give * (1 - 1 / ((Math.abs(over) * 0.55) / give + 1)) : raw\n      grip.trail.push([e.timeStamp, e.clientX])\n      while (grip.trail.length > 2 && e.timeStamp - grip.trail[0][0] > 100) grip.trail.shift()\n      paint()\n    },\n    up(e: React.PointerEvent<HTMLElement>) {\n      if (!grip) return\n      const g = grip\n      grip = null\n      held(e.currentTarget, false)\n      if (!g.moved) {\n        // A tap turns the wheel to the mark under it; a tap on a moving wheel only stops it.\n        const wheel = live.wheel\n        if (e.type !== \"pointerup\" || g.halted || !wheel) return land(Math.round(pos))\n        const r = wheel.getBoundingClientRect()\n        const x = Math.max(-1, Math.min(1, (e.clientX - r.left - r.width / 2) / (pitch * k)))\n        return set(Math.round(pos + Math.asin(x) * k * dir))\n      }\n      const [t0, x0] = g.trail[0]\n      const [t1, x1] = g.trail[g.trail.length - 1]\n      vel = e.timeStamp - t1 < 60 && t1 > t0 ? (-((x1 - x0) / (t1 - t0)) * 1000 * dir) / pitch : 0\n      if (still) return land(Math.round(pos))\n      mode = \"coast\"\n      run()\n    },\n    wheel(e: WheelEvent) {\n      const { input, last } = live\n      // A smooth scroller on the page (Lenis) leaves a wheel alone that carries this mark, as the scrollbar's host does.\n      const claim = (on: boolean) => (e.currentTarget as Element).toggleAttribute(\"data-lenis-prevent-wheel\", on)\n      // A sideways swipe always turns it; the plain wheel only once it has focus, so the page still scrolls past it.\n      const sideways = Math.abs(e.deltaX) > Math.abs(e.deltaY)\n      measure()\n      const d = (sideways ? e.deltaX * dir : e.deltaY) * (e.deltaMode === 0 ? 1 / pitch : e.deltaMode === 1 ? 1 : 10)\n      const from = mode === \"wheel\" ? scrolled : mode === \"spring\" ? target : pos // a scroll adds to where it is heading\n      const ours = input && !input.matches(\":disabled\") && (sideways || document.activeElement === input)\n      // At an end, the page scrolls on.\n      if (!ours || (from <= 0 && d < 0) || (from >= last && d > 0)) {\n        claim(false)\n        return\n      }\n      claim(true)\n      e.preventDefault()\n      follow = true\n      scrolled = clamp(from + d)\n      clearTimeout(timer)\n      timer = window.setTimeout(() => land(Math.round(scrolled)), 140)\n      if (still) {\n        pos = Math.round(scrolled)\n        paint()\n        return\n      }\n      mode = \"wheel\"\n      run()\n    },\n    stop() {\n      cancelAnimationFrame(raf)\n      clearTimeout(timer)\n    },\n  }\n}\n\nfunction Turning({\n  variant,\n  name,\n  legend,\n  options,\n  min = 0,\n  max = 100,\n  step = 1,\n  major = 10,\n  value,\n  defaultValue,\n  onValueChange,\n  unit,\n  className,\n  ...props\n}: Omit<DialProps, \"variant\"> & { variant: \"tuner\" }) {\n  const scale = React.useMemo(() => scaleOf(options, min, max, step, major), [options, min, max, step, major])\n  const last = scale.count - 1\n  const [own, setOwn] = React.useState(() => (defaultValue === undefined ? 0 : scale.indexOf(defaultValue)))\n  const index = value === undefined ? own : scale.indexOf(value)\n  const [start] = React.useState(index)\n  const [base, setBase] = React.useState(index)\n  const legendId = React.useId()\n  const wheel = React.useRef<HTMLSpanElement>(null)\n  const drum = React.useRef<HTMLSpanElement>(null)\n  const input = React.useRef<HTMLInputElement>(null)\n  const [turn] = React.useState(() => createTurn(start))\n\n  React.useLayoutEffect(() => {\n    turn.sync({\n      wheel: wheel.current,\n      drum: drum.current,\n      input: input.current,\n      last,\n      label: scale.label,\n      commit: (i) => {\n        if (value === undefined) setOwn(i)\n        onValueChange?.(scale.valueAt(i))\n      },\n      recentre: setBase,\n    })\n  }, [turn, last, scale, value, onValueChange])\n\n  // The wheel listener can't be passive: a turn claims the scroll.\n  React.useEffect(() => {\n    const face = wheel.current?.parentElement\n    const onWheel = (e: WheelEvent) => turn.wheel(e)\n    face?.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => {\n      face?.removeEventListener(\"wheel\", onWheel)\n      turn.stop()\n    }\n  }, [turn])\n\n  React.useEffect(() => {\n    if (index !== turn.committed()) turn.to(index)\n  }, [turn, index])\n\n  // Only the marks near the top exist; the rest of the scale is made as the wheel brings it round.\n  // ponytail: a fixed reach covers the half turn in view while the stylesheet's --k stays under 21.\n  const reach = 34\n  const marks: number[] = []\n  for (let i = Math.max(0, base - reach); i <= Math.min(last, base + reach); i++) marks.push(i)\n  const chars = scale.numeric ? Math.max(scale.label(0).length, scale.label(last).length) : Math.max(...Array.from({ length: scale.count }, (_, i) => scale.label(i).length))\n  const jump = major\n\n  return (\n    <fieldset data-slot=\"dial\" data-variant={variant} className={cn(\"ot-dial\", className)} {...props}>\n      <legend id={legendId} className=\"ot-label\">\n        {legend}\n      </legend>\n      <div\n        data-slot=\"dial-face\"\n        className=\"ot-dial-face\"\n        aria-hidden=\"true\"\n        style={{ \"--pos\": start, \"--chars\": chars } as React.CSSProperties}\n        onPointerDown={turn.down}\n        onPointerMove={turn.move}\n        onPointerUp={turn.up}\n        onPointerCancel={turn.up}\n      >\n        <span className=\"ot-dial-read\">\n          <span ref={drum} className=\"ot-dial-drum\" data-a={scale.label(start)} data-b={scale.label(start + 1)} />\n          {unit ? <span className=\"ot-dial-unit\">{unit}</span> : null}\n        </span>\n        <span ref={wheel} className=\"ot-dial-scale\">\n          {marks.map((i) => {\n            const kind = scale.kind(i)\n            return (\n              <span key={i} className=\"ot-dial-tick\" data-kind={kind} style={{ \"--v\": i } as React.CSSProperties}>\n                {kind === \"major\" ? <span>{scale.mark(i)}</span> : null}\n              </span>\n            )\n          })}\n          <span className=\"ot-dial-index\" />\n        </span>\n      </div>\n      <input\n        ref={input}\n        type=\"range\"\n        className=\"ot-sr\"\n        name={scale.numeric ? name : undefined}\n        min={scale.numeric ? min : 0}\n        max={scale.numeric ? Number(scale.valueAt(last)) : last}\n        step={scale.numeric ? step : 1}\n        value={scale.numeric ? Number(scale.valueAt(index)) : index}\n        aria-labelledby={legendId}\n        aria-valuetext={unit ? `${scale.label(index)} ${unit}` : scale.label(index)}\n        onChange={(e) => turn.set(scale.numeric ? scale.indexOf(e.target.value) : Number(e.target.value))}\n        onKeyDown={(e) => {\n          // A native range jumps a tenth of its length; a dial jumps a major mark.\n          if (e.key !== \"PageUp\" && e.key !== \"PageDown\") return\n          e.preventDefault()\n          turn.set(turn.committed() + (e.key === \"PageUp\" ? jump : -jump))\n        }}\n      />\n      {!scale.numeric && name ? <input type=\"hidden\" name={name} value={String(scale.valueAt(index))} /> : null}\n    </fieldset>\n  )\n}\n\nconst EXHALE = \"cubic-bezier(.16,1,.3,1)\" // --ot-exhale\nconst still = () => matchMedia(\"(prefers-reduced-motion: reduce)\").matches\nconst clampTo = (lo: number, hi: number, n: number) => Math.min(hi, Math.max(lo, n))\n\n// The score's dynamics, quietest first. The type scale is named after them already (--ot-pp … --ot-fff), so the dial\n// sets its number at the size its loudness would be printed.\nconst DYNAMICS = [\n  [\"ppp\", \"pianississimo\"],\n  [\"pp\", \"pianissimo\"],\n  [\"p\", \"piano\"],\n  [\"mp\", \"mezzo piano\"],\n  [\"mf\", \"mezzo forte\"],\n  [\"f\", \"forte\"],\n  [\"ff\", \"fortissimo\"],\n  [\"fff\", \"fortississimo\"],\n] as const\nconst dynamicOf = (u: number) => (u <= 0 ? ([\"niente\", \"niente\"] as const) : DYNAMICS[Math.min(7, Math.ceil(u * 8) - 1)])\n\n/**\n * Dynamics: a native range under a number whose size is its loudness. Drag it (right or up is louder), scroll it\n * once it has focus, or use the keys; the marking under it names the dynamic.\n */\nfunction Dynamics({ name, legend, min = 0, max = 100, step = 1, value, defaultValue, onValueChange, unit, className, style, ...props }: Omit<DialProps, \"variant\">) {\n  const [own, setOwn] = React.useState(() => Number(defaultValue ?? min))\n  const snap = (n: number) => clampTo(min, max, Number((Math.round((n - min) / step) * step + min).toFixed(6)))\n  const v = snap(Number(value ?? own))\n  const u = max > min ? (v - min) / (max - min) : 0\n  const [mark, word] = dynamicOf(u)\n  const legendId = React.useId()\n  const input = React.useRef<HTMLInputElement>(null)\n  const face = React.useRef<HTMLDivElement>(null)\n  const grip = React.useRef<{ x: number; y: number; from: number } | null>(null)\n  const live = React.useRef<{ v: number; set: (n: number) => void }>({ v, set: () => {} })\n\n  const set = (n: number) => {\n    const next = snap(n)\n    if (next === v) return\n    if (value === undefined) setOwn(next)\n    onValueChange?.(next)\n  }\n  React.useLayoutEffect(() => {\n    live.current = { v, set }\n  })\n\n  // The wheel turns it only once it has focus, so the page still scrolls past; at either end it hands the scroll back.\n  React.useEffect(() => {\n    const el = face.current\n    const onWheel = (e: WheelEvent) => {\n      const { v: now, set: to } = live.current\n      const d = Math.sign(Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : -e.deltaY)\n      if (!d || document.activeElement !== input.current || input.current?.disabled || (d < 0 && now <= min) || (d > 0 && now >= max)) return\n      e.preventDefault()\n      to(now + d * step)\n    }\n    el?.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => el?.removeEventListener(\"wheel\", onWheel)\n  }, [min, max, step])\n\n  // The whole range is a short drag: about 260px, right or up for louder.\n  const travel = 260 / Math.max(1, (max - min) / step)\n  return (\n    <fieldset data-slot=\"dial\" data-variant=\"dynamics\" className={cn(\"ot-dial\", className)} style={{ \"--u\": u.toFixed(4), \"--chars\": Math.max(String(min).length, String(max).length), ...style } as React.CSSProperties} {...props}>\n      <legend id={legendId} className=\"ot-label\">\n        {legend}\n      </legend>\n      <div\n        ref={face}\n        data-slot=\"dial-face\"\n        className=\"ot-dial-face\"\n        aria-hidden=\"true\"\n        onPointerDown={(e) => {\n          if (e.button !== 0 || !input.current || input.current.disabled) return\n          e.preventDefault()\n          input.current.focus({ preventScroll: true })\n          e.currentTarget.setPointerCapture(e.pointerId)\n          grip.current = { x: e.clientX, y: e.clientY, from: v }\n          e.currentTarget.closest(\"fieldset\")?.setAttribute(\"data-held\", \"\")\n        }}\n        onPointerMove={(e) => {\n          const g = grip.current\n          if (!g) return\n          const dx = (e.clientX - g.x) * (getComputedStyle(e.currentTarget).direction === \"rtl\" ? -1 : 1)\n          set(g.from + Math.round((dx - (e.clientY - g.y)) / travel) * step)\n        }}\n        onPointerUp={(e) => {\n          grip.current = null\n          e.currentTarget.closest(\"fieldset\")?.removeAttribute(\"data-held\")\n        }}\n        onPointerCancel={(e) => {\n          grip.current = null\n          e.currentTarget.closest(\"fieldset\")?.removeAttribute(\"data-held\")\n        }}\n      >\n        <span className=\"ot-dial-loud\">\n          <span className=\"ot-dial-numeral\">{v}</span>\n          {unit ? <span className=\"ot-dial-unit\">{unit}</span> : null}\n        </span>\n        <span className=\"ot-dial-marking\" data-mark={mark}>\n          {mark}\n        </span>\n      </div>\n      <input\n        ref={input}\n        type=\"range\"\n        className=\"ot-sr\"\n        name={name}\n        min={min}\n        max={max}\n        step={step}\n        value={v}\n        aria-labelledby={legendId}\n        aria-valuetext={`${v}${unit ? ` ${unit}` : \"\"}, ${word}`}\n        onChange={(e) => set(Number(e.target.value))}\n      />\n    </fieldset>\n  )\n}\n\n/**\n * Tumbler: a whole number set figure by figure, like the wheels of a lock. The figure you turn stands in\n * parentheses, as \"20(25)\" holds the part that changes. Underneath, a native number input.\n */\nfunction Tumbler({ name, legend, min = 0, max = 100, value, defaultValue, onValueChange, unit, className, ...props }: Omit<DialProps, \"variant\">) {\n  const lo = Math.max(0, Math.ceil(min)) // ponytail: whole numbers from zero up; negatives and decimals wait for a need\n  const hi = Math.max(lo, Math.floor(max))\n  const n = String(hi).length\n  const [own, setOwn] = React.useState(() => Number(defaultValue ?? lo))\n  const v = clampTo(lo, hi, Math.round(Number(value ?? own)))\n  const [at, setAt] = React.useState(n - 1) // which figure the parentheses hold, from the left\n  const text = String(v).padStart(n, \"0\")\n  const lead = n - String(v).length\n  const legendId = React.useId()\n  const hintId = React.useId()\n  const input = React.useRef<HTMLInputElement>(null)\n  const face = React.useRef<HTMLDivElement>(null)\n  const figs = React.useRef<(HTMLSpanElement | null)[]>([])\n  const was = React.useRef(text)\n  const grip = React.useRef<{ y: number; from: number; k: number } | null>(null)\n  const live = React.useRef<{ v: number; at: number; set: (n: number) => void }>({ v, at, set: () => {} })\n  const place = (k: number) => 10 ** (n - 1 - k)\n\n  const set = (next: number) => {\n    const to = clampTo(lo, hi, Math.round(next))\n    if (to === v) return\n    if (value === undefined) setOwn(to)\n    onValueChange?.(to)\n  }\n  React.useLayoutEffect(() => {\n    live.current = { v, at, set }\n  })\n\n  // A changed figure turns in from below when the number grew, from above when it fell: the units first, the carry a\n  // step behind, as a counter's wheels do.\n  React.useLayoutEffect(() => {\n    const before = was.current\n    was.current = text\n    if (before === text || still()) return\n    const dir = Number(text) > Number(before) ? 1 : -1\n    let order = 0\n    for (let k = n - 1; k >= 0; k--) {\n      if (before[k] === text[k]) continue\n      figs.current[k]?.animate([{ translate: `0 ${dir * 0.45}em`, opacity: 0 }, { translate: \"0 0\", opacity: 1 }], { duration: 320, easing: EXHALE, delay: order++ * 36, fill: \"backwards\" }) // --ot-moderato, --ot-arpeggio\n    }\n  }, [text, n])\n\n  React.useEffect(() => {\n    const el = face.current\n    const onWheel = (e: WheelEvent) => {\n      const { v: now, at: k, set: to } = live.current\n      const d = -Math.sign(e.deltaY)\n      if (!d || document.activeElement !== input.current || input.current?.disabled || (d < 0 && now <= lo) || (d > 0 && now >= hi)) return\n      e.preventDefault()\n      to(now + d * 10 ** (n - 1 - k))\n    }\n    el?.addEventListener(\"wheel\", onWheel, { passive: false })\n    return () => el?.removeEventListener(\"wheel\", onWheel)\n  }, [lo, hi, n])\n\n  const keys = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const k = e.key\n    let handled = true\n    if (k === \"ArrowUp\" || k === \"ArrowDown\") set(v + (k === \"ArrowUp\" ? 1 : -1) * place(at))\n    else if (k === \"ArrowLeft\" || k === \"ArrowRight\") setAt(clampTo(0, n - 1, at + (k === \"ArrowLeft\" ? -1 : 1)))\n    else if (k === \"Home\" || k === \"End\") set(k === \"Home\" ? lo : hi)\n    else if (/^[0-9]$/.test(k)) {\n      // Typing sets the held figure and moves on to the next, as a code is entered.\n      set(Number(text.slice(0, at) + k + text.slice(at + 1)))\n      setAt(Math.min(n - 1, at + 1))\n    } else handled = false\n    if (handled) e.preventDefault()\n  }\n\n  return (\n    <fieldset data-slot=\"dial\" data-variant=\"tumbler\" className={cn(\"ot-dial\", className)} {...props}>\n      <legend id={legendId} className=\"ot-label\">\n        {legend}\n      </legend>\n      <div\n        ref={face}\n        data-slot=\"dial-face\"\n        className=\"ot-dial-face\"\n        aria-hidden=\"true\"\n        dir=\"ltr\"\n        onPointerDown={(e) => {\n          const fig = (e.target as Element).closest<HTMLElement>(\"[data-k]\")\n          if (e.button !== 0 || !input.current || input.current.disabled) return\n          e.preventDefault()\n          input.current.focus({ preventScroll: true })\n          const k = fig ? Number(fig.dataset.k) : at\n          setAt(k)\n          e.currentTarget.setPointerCapture(e.pointerId)\n          grip.current = { y: e.clientY, from: v, k }\n          e.currentTarget.closest(\"fieldset\")?.setAttribute(\"data-held\", \"\")\n        }}\n        onPointerMove={(e) => {\n          const g = grip.current\n          if (!g) return\n          set(g.from + Math.trunc((g.y - e.clientY) / 24) * place(g.k)) // a figure a finger's width up or down\n        }}\n        onPointerUp={(e) => {\n          grip.current = null\n          e.currentTarget.closest(\"fieldset\")?.removeAttribute(\"data-held\")\n        }}\n        onPointerCancel={(e) => {\n          grip.current = null\n          e.currentTarget.closest(\"fieldset\")?.removeAttribute(\"data-held\")\n        }}\n      >\n        <span className=\"ot-dial-figures\" style={{ \"--at\": at } as React.CSSProperties}>\n          {[...text].map((f, k) => (\n            <span key={k} ref={(el) => void (figs.current[k] = el)} data-k={k} data-lead={k < lead && k < n - 1 ? \"\" : undefined} className=\"ot-dial-figure\">\n              {f}\n            </span>\n          ))}\n          <span className=\"ot-dial-cursor\" />\n        </span>\n        {unit ? <span className=\"ot-dial-unit\">{unit}</span> : null}\n      </div>\n      <input\n        ref={input}\n        type=\"number\"\n        role=\"spinbutton\"\n        className=\"ot-sr\"\n        name={name}\n        min={lo}\n        max={hi}\n        step={1}\n        value={v}\n        inputMode=\"none\"\n        aria-labelledby={legendId}\n        aria-describedby={hintId}\n        aria-valuetext={unit ? `${v} ${unit}` : String(v)}\n        onChange={(e) => e.target.value !== \"\" && set(Number(e.target.value))}\n        onKeyDown={keys}\n      />\n      <span id={hintId} className=\"ot-sr\">\n        Up and down turn the figure in parentheses; left and right choose another.\n      </span>\n    </fieldset>\n  )\n}\n\nexport { Dial, type DialProps, type DialOption, type DialValue }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/dial.css",
      "content": "@layer components {\n  /* ── ot-dial ── a radio on an arc. The whole dial rolls to what you choose:\n     --sel is animated, and size, colour and italic all follow its distance. */\n  @property --sel { syntax: \"<number>\"; inherits: true; initial-value: 0; }\n  .ot-dial { --r: min(13rem, 32vw); border: 0; margin: 0; padding: 0; min-width: 0; }\n  .ot-dial > legend { padding: 0; margin-bottom: var(--ot-space-5); }\n  .ot-dial-face {\n    position: relative; width: calc(2 * var(--r) + 3em); height: calc(var(--r) + 3.5em);\n    transition: --sel var(--ot-andante) var(--ot-spiccato);\n  }\n  .ot-dial > .ot-dial-face { font-size: var(--ot-mp); }\n  .ot-dial:has(label:nth-of-type(1) > input:checked) .ot-dial-face { --sel: 0; }\n  .ot-dial:has(label:nth-of-type(2) > input:checked) .ot-dial-face { --sel: 1; }\n  .ot-dial:has(label:nth-of-type(3) > input:checked) .ot-dial-face { --sel: 2; }\n  .ot-dial:has(label:nth-of-type(4) > input:checked) .ot-dial-face { --sel: 3; }\n  .ot-dial:has(label:nth-of-type(5) > input:checked) .ot-dial-face { --sel: 4; }\n  .ot-dial:has(label:nth-of-type(6) > input:checked) .ot-dial-face { --sel: 5; }\n  .ot-dial:has(label:nth-of-type(7) > input:checked) .ot-dial-face { --sel: 6; }\n  .ot-dial:has(label:nth-of-type(8) > input:checked) .ot-dial-face { --sel: 7; }\n  .ot-dial:has(label:nth-of-type(9) > input:checked) .ot-dial-face { --sel: 8; }\n  .ot-dial > .ot-dial-face > label {\n    --a: calc(170deg - 160deg * var(--i) / (var(--n) - 1));\n    --d: max(var(--i) - var(--sel), var(--sel) - var(--i));\n    --near: max(0, 1 - var(--d));\n    position: absolute; left: calc(50% + cos(var(--a)) * var(--r)); top: calc(1.75em + var(--r) - sin(var(--a)) * var(--r));\n    translate: -50% -50%; scale: calc(1 + 1.4 * var(--near));\n    display: grid; place-items: center; cursor: pointer; font-variant-numeric: lining-nums;\n    color: color-mix(in srgb, var(--ot-ink) calc(100% - min(var(--d), 3) * 33%), var(--ot-pencil));\n    -webkit-tap-highlight-color: transparent;\n  }\n  .ot-dial label > input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; pointer-events: none; }\n  .ot-dial label > span, .ot-dial label::after { grid-area: 1 / 1; }\n  .ot-dial label > span { opacity: calc(1 - var(--near)); }\n  .ot-dial label::after {\n    content: attr(data-text) / \"\"; opacity: var(--near);\n    font-family: var(--ot-expression); font-style: italic; font-size: calc(var(--ot-expression-scale) * 1em); line-height: 1;\n  }\n  .ot-dial label:hover { color: var(--ot-ink); }\n  .ot-dial label:has(input:focus-visible) { outline: var(--ot-stroke) solid var(--ot-accent); outline-offset: 4px; border-radius: 50%; }\n  .ot-dial-arc, .ot-dial-dot { position: absolute; pointer-events: none; }\n  .ot-dial-arc {\n    --ri: calc(var(--r) - 2.1em);\n    left: calc(50% - var(--ri)); top: calc(1.75em + var(--r) - var(--ri));\n    width: calc(2 * var(--ri)); height: calc(2 * var(--ri)); border-radius: 50%;\n    box-shadow: inset 0 0 0 var(--ot-hairline) var(--ot-rule-strong); clip-path: inset(0 0 50% 0);\n  }\n  .ot-dial-dot {\n    --a: calc(170deg - 160deg * var(--sel) / (var(--n) - 1));\n    --ri: calc(var(--r) - 2.1em);\n    left: calc(50% + cos(var(--a)) * var(--ri)); top: calc(1.75em + var(--r) - sin(var(--a)) * var(--ri));\n    width: var(--ot-dot); height: var(--ot-dot); translate: -50% -50%; border-radius: 50%; background: var(--ot-accent);\n  }\n  .ot-dial:not(:has(input:checked)) .ot-dial-dot { opacity: 0; }\n  .ot-dial-unit { position: absolute; left: 50%; bottom: 0.4em; translate: -50% 0; font-size: var(--ot-pp); color: var(--ot-pencil); }\n\n  /* Right to left the first choice starts on the right, so arrow keys and the arc agree. */\n  .ot-dial:is([dir=\"rtl\"], [dir=\"rtl\"] *) > .ot-dial-face > label { left: calc(50% - cos(var(--a)) * var(--r)); }\n  .ot-dial:is([dir=\"rtl\"], [dir=\"rtl\"] *) .ot-dial-dot { left: calc(50% - cos(var(--a)) * var(--ri)); }\n\n  /* ── the tuner ── a long scale on the rim of a wheel, seen edge on. Script turns --pos (in steps) and\n     every mark sits at the angle (v - pos) / k, so marks spread under the index and crowd and fade as the\n     rim turns away. Your number reads above in italic, on a drum that turns with the wheel. */\n  .ot-dial[data-variant=\"tuner\"] { --ot-dial-pitch: 13px; --k: 15; --R: calc(var(--k) * var(--ot-dial-pitch)); }\n  @media (max-width: 520px) { .ot-dial[data-variant=\"tuner\"] { --ot-dial-pitch: 10px; } }\n  .ot-dial[data-variant=\"tuner\"] > .ot-dial-face {\n    display: grid; justify-items: center; gap: var(--ot-space-5); width: calc(2 * var(--R)); max-width: 100%; height: auto;\n    cursor: grab; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n  }\n  .ot-dial[data-held] > .ot-dial-face { cursor: grabbing; }\n  .ot-dial-read { display: flex; align-items: baseline; gap: 0.3em; transition: translate var(--ot-andante) var(--ot-spiccato); }\n  .ot-dial[data-held] .ot-dial-read { translate: 0 -5px; transition-duration: var(--ot-moderato); }\n  .ot-dial-drum {\n    display: inline-block; overflow: clip; height: 1.1em; min-width: calc(var(--chars) * 1ch); text-align: end;\n    font-family: var(--ot-expression); font-style: italic; font-size: calc(var(--ot-expression-scale) * var(--ot-f)); line-height: 1.1;\n    color: var(--ot-ink); font-variant-numeric: tabular-nums lining-nums;\n  }\n  .ot-dial-drum::before, .ot-dial-drum::after { display: block; translate: 0 calc(var(--f, 0) * -1.1em); }\n  .ot-dial-drum::before { content: attr(data-a); }\n  .ot-dial-drum::after { content: attr(data-b) / \"\"; }\n  .ot-dial[data-variant=\"tuner\"] .ot-dial-unit { position: static; translate: none; font-size: var(--ot-p); color: var(--ot-graphite); }\n  .ot-dial-scale {\n    position: relative; width: 100%; height: calc(16px + var(--ot-space-2) + 1.6 * var(--ot-pp)); overflow-x: clip;\n  }\n  /* The rim, a hairline that turns away at both ends. */\n  .ot-dial-scale::before {\n    content: \"\"; position: absolute; inset: 0 0 auto; height: var(--ot-hairline); background: var(--ot-rule-strong);\n    mask-image: linear-gradient(90deg, transparent, #000 12% 88%, transparent);\n  }\n  .ot-dial-tick {\n    --t: calc((var(--v) - var(--pos)) / var(--k) * 1rad);\n    --c: max(0, cos(var(--t)));\n    position: absolute; top: 0; left: calc(50% + sin(var(--t)) * var(--R));\n    width: var(--ot-hairline); height: 6px; background: var(--ot-rule-strong); opacity: calc(var(--c) * var(--c));\n  }\n  .ot-dial-tick[data-kind=\"half\"] { height: 10px; background: var(--ot-pencil); }\n  .ot-dial-tick[data-kind=\"major\"] { height: 16px; background: var(--ot-graphite); }\n  .ot-dial-tick > span {\n    position: absolute; left: 50%; top: calc(16px + var(--ot-space-2)); translate: -50% 0; scale: var(--c) 1;\n    --near: max(0, 1 - max(var(--v) - var(--pos), var(--pos) - var(--v)) / 5);\n    font-size: var(--ot-pp); line-height: 1.6; font-variant-numeric: tabular-nums lining-nums; white-space: nowrap;\n    color: color-mix(in srgb, var(--ot-ink) calc(var(--near) * 100%), var(--ot-graphite)); /* inks as it passes the index */\n  }\n  /* The index: an ink hairline standing through the rim, the accent dot on top (the slider's hand). */\n  .ot-dial-index { position: absolute; left: 50%; top: -12px; width: var(--ot-stroke); height: calc(12px + 16px + 2px); translate: -50% 0; background: var(--ot-ink); }\n  .ot-dial-index::before { content: \"\"; position: absolute; left: 50%; top: 0; width: var(--ot-dot); height: var(--ot-dot); translate: -50% -130%; border-radius: 50%; background: var(--ot-accent); }\n  .ot-dial:not([data-variant=\"arc\"])[data-force~=\"focus\"] { outline: 0; }\n  .ot-dial:not([data-variant=\"arc\"]):has(> input:focus-visible) > .ot-dial-face,\n  .ot-dial:not([data-variant=\"arc\"])[data-force~=\"focus\"] > .ot-dial-face { outline: var(--ot-stroke) solid var(--ot-accent); outline-offset: 12px; }\n  .ot-dial:has(input:disabled) { opacity: 0.45; }\n  .ot-dial:has(input:disabled) > .ot-dial-face, .ot-dial:has(input:disabled) label { cursor: not-allowed; }\n  .ot-dial:is([dir=\"rtl\"], [dir=\"rtl\"] *) .ot-dial-tick { left: calc(50% - sin(var(--t)) * var(--R)); }\n\n  /* ── dynamics ── the number's type size is its loudness. --u (0 to 1) is set from the value, and the numeral is set\n     between the p and fff steps on a curve that rises the way loudness does (little change at the quiet end, most at the\n     loud), so it grows up from its baseline as you turn it up. The room for the loudest is always kept, so nothing moves\n     around it. Under it, in the bold italic and the accent, the marking a score would print there: niente at the\n     bottom, then ppp to fff. Dragging follows the hand at once; a key or a scroll swells it to the next size with spiccato. */\n  .ot-dial[data-variant=\"dynamics\"] { --u: 0; --e: calc((pow(9, var(--u)) - 1) / 8); }\n  .ot-dial[data-variant=\"dynamics\"] > .ot-dial-face {\n    display: grid; justify-items: start; gap: var(--ot-space-3); width: max-content; max-width: 100%; height: auto;\n    cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n  }\n  .ot-dial[data-variant=\"dynamics\"][data-held] > .ot-dial-face { cursor: grabbing; }\n  .ot-dial-loud {\n    display: flex; align-items: baseline; gap: 0.3em; overflow: visible;\n    /* the loudest numeral's height and width, kept */\n    height: calc(var(--ot-expression-scale) * var(--ot-fff) * 0.9); min-width: calc(var(--chars) * 0.62 * var(--ot-expression-scale) * var(--ot-fff));\n    align-content: end; flex-wrap: wrap;\n  }\n  .ot-dial-numeral {\n    font-family: var(--ot-expression); font-style: italic; font-weight: 400; line-height: 0.9; color: var(--ot-ink);\n    font-size: calc(var(--ot-expression-scale) * (var(--ot-p) + (var(--ot-fff) - var(--ot-p)) * var(--e)));\n    font-variant-numeric: lining-nums tabular-nums; letter-spacing: -0.02em;\n    transition: font-size var(--ot-moderato) var(--ot-spiccato);\n  }\n  .ot-dial[data-held] .ot-dial-numeral { transition: none; }\n  .ot-dial[data-variant=\"dynamics\"] .ot-dial-unit { position: static; translate: none; font-size: var(--ot-p); color: var(--ot-graphite); }\n  .ot-dial-marking {\n    font-family: var(--ot-expression); font-style: italic; font-weight: 600; letter-spacing: 0.02em; line-height: 1;\n    font-size: calc(var(--ot-expression-scale) * var(--ot-mp)); color: var(--ot-accent);\n  }\n  .ot-dial-marking[data-mark=\"niente\"] { font-weight: 400; color: var(--ot-pencil); }\n\n  /* ── tumbler ── a whole number set figure by figure, like a lock's wheels. The figures stand large in the italic at\n     even steps (tabular, 1ch apart plus --g), leading zeros in pencil; the one you turn is held in a pair of thin\n     parentheses in the accent, as \"20(25)\" holds the part that changes. Left and right (or a tap) step the pair to\n     another figure and it lands with spiccato; up and down turn that figure, which turns in from below as the number\n     grows and from above as it falls. */\n  .ot-dial[data-variant=\"tumbler\"] > .ot-dial-face {\n    display: inline-flex; align-items: baseline; gap: var(--ot-space-4); width: auto; height: auto; padding-inline: 0.5em;\n    cursor: ns-resize; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent;\n  }\n  .ot-dial-figures {\n    --g: 0.42em; position: relative; display: inline-flex; gap: var(--g); padding-inline: 0.18em;\n    font-family: var(--ot-expression); font-style: italic; font-weight: 400; line-height: 1.1; color: var(--ot-ink);\n    /* ponytail: a phone narrows the wheels by the viewport, enough for four figures; a query on the dial's own width if a\n       tumbler sits in a narrow column on a wide screen. */\n    font-size: min(calc(var(--ot-expression-scale) * var(--ot-ff)), 16vw); font-variant-numeric: lining-nums tabular-nums;\n  }\n  .ot-dial-figure { display: inline-block; width: 1ch; text-align: center; transition: color var(--ot-allegro); }\n  .ot-dial-figure[data-lead] { color: var(--ot-rule-strong); }\n  .ot-dial-cursor {\n    position: absolute; inset-block: 0; left: 0.18em; width: 1ch; pointer-events: none;\n    translate: calc(var(--at) * (1ch + var(--g))) 0; transition: translate var(--ot-moderato) var(--ot-spiccato);\n  }\n  .ot-dial-cursor::before, .ot-dial-cursor::after {\n    position: absolute; top: 50%; translate: 0 -52%;\n    font-family: var(--ot-voice); font-style: normal; font-weight: 200; font-size: 0.92em; line-height: 1; color: var(--ot-accent);\n  }\n  .ot-dial-cursor::before { content: \"(\" / \"\"; right: calc(100% + 0.03em); }\n  .ot-dial-cursor::after { content: \")\" / \"\"; left: calc(100% + 0.03em); }\n  .ot-dial[data-variant=\"tumbler\"] .ot-dial-unit { position: static; translate: none; font-size: var(--ot-p); color: var(--ot-graphite); }\n  .ot-dial[data-variant=\"tumbler\"][data-held] .ot-dial-cursor::before { translate: -0.06em -52%; }\n  .ot-dial[data-variant=\"tumbler\"][data-held] .ot-dial-cursor::after { translate: 0.06em -52%; }\n\n  @media (forced-colors: active) {\n    .ot-dial-cursor::before, .ot-dial-cursor::after, .ot-dial-marking { forced-color-adjust: none; color: Highlight; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/dial.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/dial.css\"": {}
  },
  "meta": {
    "movement": "VI",
    "underneath": "hook",
    "contract": "ot-dial"
  },
  "type": "registry:ui"
}
