{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "escapement",
  "type": "registry:ui",
  "title": "Escapement",
  "description": "Ratcheting numeric stepper — digits roll with an overshoot-click spring settle, a brass pawl flicks on every step, and hold-to-repeat accelerates like a wound mechanism. Proper spinbutton semantics with arrow keys, Home/End, and live value announcement.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/utility/escapement/escapement.tsx",
      "type": "registry:ui",
      "target": "components/crucible/escapement.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface EscapementProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onChange\" | \"defaultValue\"> {\n  /** Controlled value. */\n  value?: number;\n  /** Initial value when uncontrolled. @default 0 (clamped into range) */\n  defaultValue?: number;\n  /** Fires with the next value on every step, controlled or not. */\n  onChange?: (value: number) => void;\n  /** Lower bound. @default 0 */\n  min?: number;\n  /** Upper bound. @default 100 */\n  max?: number;\n  /** Amount per step. @default 1 */\n  step?: number;\n  /** Accent for the pawl and focus ring. @default \"#d4a95c\" (brass) */\n  accentColor?: string;\n  /** Accessible name for the spinbutton. @default \"Value\" */\n  label?: string;\n}\n\nfunction DigitColumn({\n  char,\n  direction,\n  reducedMotion,\n}: {\n  char: string;\n  direction: number;\n  reducedMotion: boolean;\n}) {\n  return (\n    <span className=\"relative inline-block h-[1.3em] w-[0.62em] overflow-hidden\">\n      <AnimatePresence initial={false} custom={direction}>\n        <motion.span\n          key={char}\n          custom={direction}\n          variants={{\n            enter: (d: number) => ({ y: d > 0 ? \"105%\" : \"-105%\", opacity: 0.3 }),\n            center: { y: \"0%\", opacity: 1 },\n            exit: (d: number) => ({ y: d > 0 ? \"-105%\" : \"105%\", opacity: 0 }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={\n            reducedMotion\n              ? { duration: 0 }\n              : { type: \"spring\", stiffness: 620, damping: 24, mass: 0.6 }\n          }\n          className=\"absolute inset-0 flex items-center justify-center\"\n        >\n          {char}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/**\n * Escapement — a numeric stepper with a mechanical ratchet feel. Each step\n * rolls the digit column with an overshoot-click settle (new digits spring in\n * from the direction of travel and catch), while a small brass pawl beneath the\n * dial flicks and re-seats like a gear tooth engaging. Hold either button and\n * the cadence accelerates like a wound mechanism. Neutral capsule, one brass\n * accent.\n *\n * Accessibility: proper `role=\"spinbutton\"` semantics with Arrow keys,\n * PageUp/PageDown, and Home/End on the focusable value; the +/- buttons stay\n * out of the tab order per the APG pattern; value changes are announced via a\n * polite live region. Honors prefers-reduced-motion: digits swap instantly\n * with no springs or pawl flicks.\n */\nexport function Escapement({\n  value,\n  defaultValue,\n  onChange,\n  min = 0,\n  max = 100,\n  step = 1,\n  accentColor = \"#d4a95c\",\n  label = \"Value\",\n  className,\n  style,\n  ...props\n}: EscapementProps) {\n  const reducedMotion = useReducedMotion();\n\n  const clamp = React.useCallback(\n    (v: number) => Math.min(max, Math.max(min, v)),\n    [min, max]\n  );\n\n  const isControlled = value !== undefined;\n  const [internalValue, setInternalValue] = React.useState(() =>\n    clamp(defaultValue ?? 0)\n  );\n  const current = clamp(isControlled ? value : internalValue);\n\n  // Direction of the last change — drives the roll direction and pawl flick.\n  const prevRef = React.useRef(current);\n  const direction = current >= prevRef.current ? 1 : -1;\n  React.useEffect(() => {\n    prevRef.current = current;\n  }, [current]);\n\n  const [hasStepped, setHasStepped] = React.useState(false);\n\n  const valueRef = React.useRef(current);\n  valueRef.current = current;\n\n  const commit = React.useCallback(\n    (next: number) => {\n      setHasStepped(true);\n      if (!isControlled) setInternalValue(next);\n      onChange?.(next);\n    },\n    [isControlled, onChange]\n  );\n\n  /** Steps by `d` steps. Returns false when pinned at a bound. */\n  const stepBy = React.useCallback(\n    (d: number) => {\n      // Round to the step's precision so fractional steps (0.1) never surface\n      // float noise like 0.30000000000000004 in the digit columns.\n      const decimals = (String(step).split(\".\")[1] ?? \"\").length;\n      const next = clamp(Number((valueRef.current + d * step).toFixed(decimals)));\n      if (next === valueRef.current) return false;\n      commit(next);\n      return true;\n    },\n    [clamp, step, commit]\n  );\n\n  // Hold-to-repeat with accelerating cadence.\n  const holdTimerRef = React.useRef(0);\n  const stopHold = React.useCallback(() => {\n    window.clearTimeout(holdTimerRef.current);\n  }, []);\n  React.useEffect(() => stopHold, [stopHold]);\n\n  const startHold = React.useCallback(\n    (d: number) => {\n      stopHold();\n      if (!stepBy(d)) return;\n      let delay = 340;\n      const run = () => {\n        if (!stepBy(d)) return;\n        delay = Math.max(60, delay * 0.82);\n        holdTimerRef.current = window.setTimeout(run, delay);\n      };\n      holdTimerRef.current = window.setTimeout(run, delay);\n    },\n    [stepBy, stopHold]\n  );\n\n  const handleSpinKeyDown = React.useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      let handled = true;\n      if (event.key === \"ArrowUp\" || event.key === \"ArrowRight\") stepBy(1);\n      else if (event.key === \"ArrowDown\" || event.key === \"ArrowLeft\") stepBy(-1);\n      else if (event.key === \"PageUp\") stepBy(10);\n      else if (event.key === \"PageDown\") stepBy(-10);\n      else if (event.key === \"Home\") {\n        if (valueRef.current !== min) commit(min);\n      } else if (event.key === \"End\") {\n        if (valueRef.current !== max) commit(max);\n      } else handled = false;\n      if (handled) event.preventDefault();\n    },\n    [stepBy, commit, min, max]\n  );\n\n  const buttonClass = cn(\n    \"flex size-9 items-center justify-center rounded-full text-neutral-300 transition-colors\",\n    \"hover:bg-white/5 hover:text-white active:bg-white/10\",\n    \"disabled:cursor-not-allowed disabled:opacity-30 disabled:hover:bg-transparent\",\n    \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--esc-accent)\"\n  );\n\n  const holdButtonProps = (d: number) => ({\n    tabIndex: -1,\n    disabled: d > 0 ? current >= max : current <= min,\n    onPointerDown: (event: React.PointerEvent) => {\n      if (event.pointerType === \"mouse\" && event.button !== 0) return;\n      startHold(d);\n    },\n    onPointerUp: stopHold,\n    onPointerLeave: stopHold,\n    onPointerCancel: stopHold,\n    onKeyDown: (event: React.KeyboardEvent) => {\n      if (event.key === \"Enter\" || event.key === \" \") {\n        event.preventDefault();\n        stepBy(d);\n      }\n    },\n  });\n\n  const chars = String(current).split(\"\");\n\n  return (\n    <div\n      data-crucible=\"escapement\"\n      className={cn(\"inline-flex flex-col items-center gap-1.5\", className)}\n      style={{ [\"--esc-accent\" as string]: accentColor, ...style }}\n      {...props}\n    >\n      <div className=\"inline-flex items-center gap-0.5 rounded-full border border-white/10 bg-neutral-900 p-1.5 shadow-[inset_0_1px_0_rgba(255,255,255,0.08),0_14px_30px_-16px_rgba(0,0,0,0.8)]\">\n        <button\n          type=\"button\"\n          aria-label=\"Decrease\"\n          className={buttonClass}\n          {...holdButtonProps(-1)}\n        >\n          <svg\n            aria-hidden\n            viewBox=\"0 0 24 24\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n          >\n            <path d=\"M5 12h14\" />\n          </svg>\n        </button>\n\n        <div\n          role=\"spinbutton\"\n          aria-valuenow={current}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuetext={String(current)}\n          aria-label={label}\n          tabIndex={0}\n          onKeyDown={handleSpinKeyDown}\n          className=\"relative flex min-w-[3.5ch] items-center justify-center rounded-full px-2 py-1 text-2xl font-semibold tabular-nums text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--esc-accent)\"\n        >\n          {chars.map((char, i) => (\n            <DigitColumn\n              // Keyed by distance from the units place so existing columns\n              // persist (and roll) when the digit count grows, odometer-style.\n              key={chars.length - i}\n              char={char}\n              direction={direction}\n              reducedMotion={reducedMotion}\n            />\n          ))}\n        </div>\n\n        <button\n          type=\"button\"\n          aria-label=\"Increase\"\n          className={buttonClass}\n          {...holdButtonProps(1)}\n        >\n          <svg\n            aria-hidden\n            viewBox=\"0 0 24 24\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2\"\n            strokeLinecap=\"round\"\n          >\n            <path d=\"M12 5v14M5 12h14\" />\n          </svg>\n        </button>\n      </div>\n\n      {/* The pawl — flicks and re-seats on every step, like a gear tooth engaging. */}\n      <motion.svg\n        key={current}\n        aria-hidden\n        viewBox=\"0 0 12 8\"\n        className=\"h-2 w-3 text-(--esc-accent)\"\n        initial={\n          hasStepped && !reducedMotion\n            ? { rotate: direction * 26, y: 1.5 }\n            : false\n        }\n        animate={{ rotate: 0, y: 0 }}\n        transition={\n          reducedMotion\n            ? { duration: 0 }\n            : { type: \"spring\", stiffness: 700, damping: 18, mass: 0.5 }\n        }\n      >\n        <path d=\"M6 0 11.5 8H0.5L6 0Z\" fill=\"currentColor\" />\n      </motion.svg>\n\n      {/* Announces value changes made via the +/- buttons (focus stays off the spinbutton). */}\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {current}\n      </span>\n    </div>\n  );\n}\n"
    }
  ]
}