{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ratchet",
  "type": "registry:ui",
  "title": "Ratchet",
  "description": "Hold-to-confirm button for destructive actions: pressing and holding (pointer or Space/Enter) advances a 24-tooth SVG ring tooth by tooth with a click-scale, releasing early ratchets it back down faster, and completion flashes the ring, morphs the icon into a check and fires onConfirm. Real button, aria-describedby hold hint, polite live announcement, stepped-opacity fill under reduced motion.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/buttons/ratchet/ratchet.tsx",
      "type": "registry:ui",
      "target": "components/crucible/ratchet.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion, useInView } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\nexport type RatchetTone = \"danger\" | \"neutral\";\n\nexport interface RatchetHandle {\n  /** Return to the idle state immediately (clears a latched confirmation). */\n  reset(): void;\n}\n\ntype OwnedHandlers =\n  | \"children\"\n  | \"onClick\"\n  | \"onPointerDown\"\n  | \"onPointerUp\"\n  | \"onPointerMove\"\n  | \"onPointerCancel\"\n  | \"onLostPointerCapture\"\n  | \"onKeyDown\"\n  | \"onKeyUp\"\n  | \"onBlur\"\n  | \"onContextMenu\";\n\nexport interface RatchetProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, OwnedHandlers> {\n  /** Idle label, shown to the right of the toothed ring. @default \"Hold to confirm\" */\n  children?: React.ReactNode;\n  /** Label shown (and politely announced) after the hold completes. @default \"Confirmed\" */\n  confirmedLabel?: React.ReactNode;\n  /** Milliseconds of continuous holding needed to fill all 24 teeth. @default 1200 */\n  holdMs?: number;\n  /** Fires once when the ring completes. Never fires on an early release. */\n  onConfirm?: () => void;\n  /** Stay in the confirmed state instead of resetting after 1.6s (call `ref.reset()` to clear). @default false */\n  latch?: boolean;\n  /** Accent family: `danger` is rust, `neutral` is graphite. @default \"danger\" */\n  tone?: RatchetTone;\n  /** Override the tone's accent color (any CSS color). */\n  accentColor?: string;\n  /** Icon inside the well. @default a trash glyph (danger) or an up-arrow (neutral) */\n  icon?: React.ReactNode;\n  /** Really disable the button. @default false */\n  disabled?: boolean;\n  /** Imperative handle: `{ reset() }`. */\n  ref?: React.Ref<RatchetHandle>;\n}\n\nconst TEETH = 24;\nconst DRAIN_SPEED = 2.6; // release ratchets down this many times faster than it went up\nconst CONFIRM_DWELL = 1600;\nconst TONE_ACCENT: Record<RatchetTone, string> = {\n  danger: \"#e2683f\", // rust\n  neutral: \"#d4d4d8\", // graphite (lit zinc on dark)\n};\n\n// One tooth, drawn at 12 o'clock in a 100×100 box: a trapezoid wider at its root.\nconst TOOTH_PATH = (() => {\n  const cx = 50;\n  const cy = 50;\n  const pt = (r: number, deg: number) => {\n    const a = ((deg - 90) * Math.PI) / 180;\n    return `${(cx + r * Math.cos(a)).toFixed(3)} ${(cy + r * Math.sin(a)).toFixed(3)}`;\n  };\n  const rRoot = 35;\n  const rTip = 48.5;\n  return `M${pt(rRoot, -5.4)} L${pt(rTip, -3.6)} L${pt(rTip, 3.6)} L${pt(rRoot, 5.4)} Z`;\n})();\n\nfunction TrashGlyph() {\n  return (\n    <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"none\" aria-hidden>\n      <path\n        d=\"M4.5 6.5h15M9.5 6.5V4.75c0-.41.34-.75.75-.75h3.5c.41 0 .75.34.75.75V6.5M6.5 6.5l.85 12.1c.06.8.72 1.4 1.5 1.4h6.3c.78 0 1.44-.6 1.5-1.4l.85-12.1M10.25 10.5v6M13.75 10.5v6\"\n        stroke=\"currentColor\"\n        strokeWidth={1.6}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction ArrowGlyph() {\n  return (\n    <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"none\" aria-hidden>\n      <path\n        d=\"M12 19V5.5M6.5 11 12 5.5 17.5 11\"\n        stroke=\"currentColor\"\n        strokeWidth={1.7}\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\n/**\n * Ratchet — a hold-to-confirm button for destructive or irreversible actions.\n * A toothed ring (24 teeth) wraps a circular icon well; pressing and holding\n * (pointer, or Space/Enter held — key auto-repeat ignored) advances the ring\n * tooth by tooth, each tooth landing with a tiny click-scale. Let go early\n * and it ratchets back down, faster than it went up. On completion the ring\n * flashes once, the icon morphs into a check, `onConfirm` fires and the label\n * swaps to `confirmedLabel` for 1.6s (or stays, with `latch`). A real\n * `<button>` with an `aria-describedby` hold hint and a polite completion\n * announcement; under reduced motion the fill advances in stepped opacity\n * with no scale.\n */\nexport function Ratchet({\n  children = \"Hold to confirm\",\n  confirmedLabel = \"Confirmed\",\n  holdMs = 1200,\n  onConfirm,\n  latch = false,\n  tone = \"danger\",\n  accentColor,\n  icon,\n  disabled = false,\n  className,\n  style,\n  ref,\n  type = \"button\",\n  ...rest\n}: RatchetProps) {\n  const reduced = useReducedMotion();\n  const accent = accentColor ?? TONE_ACCENT[tone];\n  const uid = React.useId();\n  const hintId = `${uid}-hint`;\n  const glowId = `${uid.replace(/:/g, \"\")}-glow`;\n\n  const btnRef = React.useRef<HTMLButtonElement>(null);\n  const inView = useInView(btnRef, { once: true, amount: 0.4 });\n\n  const [lit, setLit] = React.useState(0);\n  const [holding, setHolding] = React.useState(false);\n  const [confirmed, setConfirmed] = React.useState(false);\n  const [flashKey, setFlashKey] = React.useState(0);\n\n  const progressRef = React.useRef(0); // in teeth units, 0..TEETH\n  const holdingRef = React.useRef(false);\n  const confirmedRef = React.useRef(false);\n  const rafRef = React.useRef(0);\n  const lastRef = React.useRef(0);\n  const timerRef = React.useRef(0);\n\n  const holdMsRef = React.useRef(holdMs);\n  const onConfirmRef = React.useRef(onConfirm);\n  const latchRef = React.useRef(latch);\n  const reducedRef = React.useRef(reduced);\n  React.useEffect(() => {\n    holdMsRef.current = holdMs;\n    onConfirmRef.current = onConfirm;\n    latchRef.current = latch;\n    reducedRef.current = reduced;\n  });\n\n  const stopLoop = React.useCallback(() => {\n    cancelAnimationFrame(rafRef.current);\n    rafRef.current = 0;\n  }, []);\n\n  const loopRef = React.useRef<(t: number) => void>(() => {});\n\n  const ensureLoop = React.useCallback(() => {\n    if (rafRef.current) return;\n    lastRef.current = performance.now();\n    rafRef.current = requestAnimationFrame((t) => loopRef.current(t));\n  }, []);\n\n  const complete = React.useCallback(() => {\n    holdingRef.current = false;\n    confirmedRef.current = true;\n    setHolding(false);\n    setConfirmed(true);\n    setFlashKey((k) => k + 1);\n    onConfirmRef.current?.();\n    window.clearTimeout(timerRef.current);\n    if (!latchRef.current) {\n      timerRef.current = window.setTimeout(() => {\n        confirmedRef.current = false;\n        setConfirmed(false);\n        // Ratchet the full ring back down so the reset reads as mechanical too.\n        if (reducedRef.current) {\n          progressRef.current = 0;\n          setLit(0);\n        } else ensureLoop();\n      }, CONFIRM_DWELL);\n    }\n  }, [ensureLoop]);\n\n  React.useEffect(() => {\n    loopRef.current = (t: number) => {\n      // Clamp dt so a backgrounded tab can't complete a hold in one frame.\n      const dt = Math.min(50, t - lastRef.current);\n      lastRef.current = t;\n      const rate = TEETH / Math.max(1, holdMsRef.current); // teeth per ms\n      let p = progressRef.current;\n      if (holdingRef.current) p = Math.min(TEETH, p + dt * rate);\n      else if (!confirmedRef.current) p = Math.max(0, p - dt * rate * DRAIN_SPEED);\n      progressRef.current = p;\n      const n = Math.floor(p + 1e-6);\n      setLit(n);\n      if (holdingRef.current && p >= TEETH) {\n        rafRef.current = 0;\n        complete();\n        return;\n      }\n      if (!holdingRef.current && (p <= 0 || confirmedRef.current)) {\n        rafRef.current = 0;\n        return;\n      }\n      rafRef.current = requestAnimationFrame((tt) => loopRef.current(tt));\n    };\n  }, [complete]);\n\n  React.useEffect(\n    () => () => {\n      stopLoop();\n      window.clearTimeout(timerRef.current);\n    },\n    [stopLoop]\n  );\n\n  const startHold = React.useCallback(() => {\n    if (disabled || confirmedRef.current || holdingRef.current) return;\n    holdingRef.current = true;\n    setHolding(true);\n    ensureLoop();\n  }, [disabled, ensureLoop]);\n\n  const cancelHold = React.useCallback(() => {\n    if (!holdingRef.current) return;\n    holdingRef.current = false;\n    setHolding(false);\n    if (reducedRef.current) {\n      stopLoop();\n      progressRef.current = 0;\n      setLit(0);\n    } else ensureLoop();\n  }, [ensureLoop, stopLoop]);\n\n  const reset = React.useCallback(() => {\n    stopLoop();\n    window.clearTimeout(timerRef.current);\n    holdingRef.current = false;\n    confirmedRef.current = false;\n    progressRef.current = 0;\n    setHolding(false);\n    setConfirmed(false);\n    setLit(0);\n  }, [stopLoop]);\n\n  React.useImperativeHandle(ref, () => ({ reset }), [reset]);\n\n  React.useEffect(() => {\n    if (disabled) cancelHold();\n  }, [disabled, cancelHold]);\n\n  // Reduced motion: the fill advances in four stepped-opacity quarters.\n  const shownLit = reduced ? Math.floor(lit / (TEETH / 4)) * (TEETH / 4) : lit;\n  const fill = confirmed ? 1 : shownLit / TEETH;\n  const introShown = reduced || inView;\n\n  const isKey = (k: string) => k === \" \" || k === \"Enter\";\n\n  return (\n    <>\n      <button\n        ref={btnRef}\n        type={type}\n        data-crucible=\"ratchet\"\n        data-tone={tone}\n        data-holding={holding || undefined}\n        data-confirmed={confirmed || undefined}\n        disabled={disabled}\n        aria-describedby={hintId}\n        aria-disabled={confirmed || undefined}\n        className={cn(\n          \"group/ratchet relative isolate inline-flex h-14 select-none items-center gap-3 rounded-full\",\n          \"border border-neutral-800 bg-neutral-900 py-1 pl-1 pr-6 text-sm font-medium text-neutral-100\",\n          \"touch-manipulation [-webkit-touch-callout:none] [-webkit-tap-highlight-color:transparent]\",\n          \"transition-[border-color,background-color,transform] duration-200 ease-out\",\n          \"hover:border-neutral-700 hover:bg-[#1c1c1c]\",\n          \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\",\n          \"disabled:cursor-not-allowed disabled:opacity-50\",\n          confirmed ? \"cursor-default\" : \"cursor-pointer\",\n          holding && !reduced && \"scale-[0.985]\",\n          className\n        )}\n        style={\n          {\n            \"--ratchet-accent\": accent,\n            borderColor:\n              holding || confirmed\n                ? `color-mix(in oklab, ${accent} ${confirmed ? 45 : 20 + fill * 30}%, #262626)`\n                : undefined,\n            ...style,\n          } as React.CSSProperties\n        }\n        onPointerDown={(e) => {\n          if (e.pointerType === \"mouse\" && e.button !== 0) return;\n          e.currentTarget.setPointerCapture(e.pointerId);\n          startHold();\n        }}\n        onPointerMove={(e) => {\n          if (!holdingRef.current) return;\n          const r = e.currentTarget.getBoundingClientRect();\n          const slop = 12;\n          if (\n            e.clientX < r.left - slop ||\n            e.clientX > r.right + slop ||\n            e.clientY < r.top - slop ||\n            e.clientY > r.bottom + slop\n          )\n            cancelHold();\n        }}\n        onPointerUp={cancelHold}\n        onPointerCancel={cancelHold}\n        onLostPointerCapture={cancelHold}\n        onContextMenu={(e) => e.preventDefault()}\n        onKeyDown={(e) => {\n          if (e.key === \"Escape\") {\n            cancelHold();\n            return;\n          }\n          if (!isKey(e.key)) return;\n          e.preventDefault();\n          if (e.repeat) return;\n          startHold();\n        }}\n        onKeyUp={(e) => {\n          if (!isKey(e.key)) return;\n          e.preventDefault();\n          cancelHold();\n        }}\n        onBlur={cancelHold}\n        {...rest}\n      >\n        {/* Face light + an accent wash that rises with the fill. */}\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]\"\n          style={{\n            background:\n              \"radial-gradient(120% 90% at 50% 0%, rgba(255,255,255,0.06), transparent 55%)\",\n            boxShadow: \"inset 0 1px 0 rgba(255,255,255,0.07), inset 0 -1px 0 rgba(0,0,0,0.35)\",\n          }}\n        />\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 -z-10 rounded-[inherit] transition-opacity duration-150\"\n          style={{\n            background: `radial-gradient(90% 160% at 0% 50%, color-mix(in oklab, ${accent} 26%, transparent), transparent 70%)`,\n            opacity: confirmed ? 0.8 : fill * 0.9,\n          }}\n        />\n\n        {/* Toothed ring around the icon well. */}\n        <span aria-hidden className=\"relative block size-12 shrink-0\">\n          <svg viewBox=\"0 0 100 100\" className=\"absolute inset-0 size-full overflow-visible\">\n            <defs>\n              <filter id={glowId} x=\"-30%\" y=\"-30%\" width=\"160%\" height=\"160%\">\n                <feGaussianBlur stdDeviation=\"2.4\" />\n              </filter>\n            </defs>\n            {/* Glow layer: only lit teeth, blurred. */}\n            <g filter={`url(#${glowId})`} opacity={confirmed ? 0.9 : 0.7}>\n              {Array.from({ length: TEETH }, (_, i) => (\n                <path\n                  key={i}\n                  d={TOOTH_PATH}\n                  transform={`rotate(${i * (360 / TEETH)} 50 50)`}\n                  fill={accent}\n                  style={{\n                    opacity: confirmed || i < shownLit ? 1 : 0,\n                    transition: \"opacity 120ms ease-out\",\n                  }}\n                />\n              ))}\n            </g>\n            {Array.from({ length: TEETH }, (_, i) => {\n              const on = confirmed || i < shownLit;\n              const leading = !on && i === shownLit && !confirmed;\n              return (\n                <g key={i} transform={`rotate(${i * (360 / TEETH)} 50 50)`}>\n                  <motion.path\n                    d={TOOTH_PATH}\n                    fill={accent}\n                    className={cn(\n                      !on && \"[fill:rgba(255,255,255,0.11)]\",\n                      leading &&\n                        i === 0 &&\n                        \"group-hover/ratchet:[fill:color-mix(in_oklab,var(--ratchet-accent)_45%,#3f3f46)] group-focus-visible/ratchet:[fill:color-mix(in_oklab,var(--ratchet-accent)_45%,#3f3f46)]\"\n                    )}\n                    style={{\n                      transformBox: \"fill-box\",\n                      transformOrigin: \"50% 50%\",\n                      transition: \"fill 110ms ease-out\",\n                    }}\n                    initial={reduced ? false : { opacity: 0, scale: 0.6 }}\n                    animate={{\n                      opacity: introShown ? 1 : 0,\n                      scale: on && !reduced && !confirmed ? [1.55, 1] : 1,\n                    }}\n                    transition={\n                      on && !reduced && !confirmed\n                        ? { scale: { duration: 0.18, ease: \"easeOut\" } }\n                        : {\n                            opacity: { duration: 0.3, delay: reduced ? 0 : i * 0.018 },\n                            scale: { duration: 0.3, delay: reduced ? 0 : i * 0.018 },\n                          }\n                    }\n                  />\n                </g>\n              );\n            })}\n            {/* Completion flash: one ring of light that expands and fades. */}\n            {!reduced && flashKey > 0 ? (\n              <motion.circle\n                key={flashKey}\n                cx={50}\n                cy={50}\n                r={42}\n                fill=\"none\"\n                stroke={accent}\n                strokeWidth={3}\n                style={{ transformBox: \"fill-box\", transformOrigin: \"50% 50%\" }}\n                initial={{ opacity: 0.95, scale: 1 }}\n                animate={{ opacity: 0, scale: 1.28 }}\n                transition={{ duration: 0.6, ease: [0.2, 0.7, 0.3, 1] }}\n              />\n            ) : null}\n          </svg>\n\n          {/* Icon well. */}\n          <span\n            className=\"absolute inset-[9px] flex items-center justify-center rounded-full border border-white/[0.07] bg-neutral-950 shadow-[inset_0_1px_3px_rgba(0,0,0,0.6)]\"\n            style={{\n              boxShadow: confirmed\n                ? `inset 0 0 10px -2px color-mix(in oklab, ${accent} 55%, transparent)`\n                : undefined,\n            }}\n          >\n            <AnimatePresence mode=\"popLayout\" initial={false}>\n              {confirmed ? (\n                <motion.span\n                  key=\"check\"\n                  className=\"block size-[15px]\"\n                  style={{ color: accent }}\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.6, rotate: -30 }}\n                  animate={{ opacity: 1, scale: 1, rotate: 0 }}\n                  exit={{ opacity: 0, transition: { duration: 0.12 } }}\n                  transition={reduced ? { duration: 0.15 } : { type: \"spring\", stiffness: 520, damping: 26 }}\n                >\n                  <svg viewBox=\"0 0 24 24\" width=\"100%\" height=\"100%\" fill=\"none\" aria-hidden>\n                    <motion.path\n                      d=\"M5 12.5 10 17.5 19 7\"\n                      stroke=\"currentColor\"\n                      strokeWidth={2.4}\n                      strokeLinecap=\"round\"\n                      strokeLinejoin=\"round\"\n                      initial={reduced ? false : { pathLength: 0 }}\n                      animate={{ pathLength: 1 }}\n                      transition={{ duration: 0.3, ease: [0.65, 0, 0.35, 1], delay: 0.05 }}\n                    />\n                  </svg>\n                </motion.span>\n              ) : (\n                <motion.span\n                  key=\"icon\"\n                  className=\"block size-[15px] transition-colors duration-150\"\n                  style={{\n                    color: fill > 0 ? `color-mix(in oklab, ${accent} ${30 + fill * 70}%, #a3a3a3)` : \"#a3a3a3\",\n                  }}\n                  initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}\n                  animate={{ opacity: 1, scale: 1 }}\n                  exit={reduced ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, scale: 0.4, rotate: 25, transition: { duration: 0.14 } }}\n                  transition={reduced ? { duration: 0.15 } : { type: \"spring\", stiffness: 420, damping: 28 }}\n                >\n                  {icon ?? (tone === \"danger\" ? <TrashGlyph /> : <ArrowGlyph />)}\n                </motion.span>\n              )}\n            </AnimatePresence>\n          </span>\n        </span>\n\n        {/* Labels share one grid cell so the width never jumps. */}\n        <span className=\"relative grid overflow-hidden whitespace-nowrap py-1\">\n          <motion.span\n            aria-hidden={confirmed || undefined}\n            className=\"col-start-1 row-start-1\"\n            initial={false}\n            animate={reduced ? { opacity: confirmed ? 0 : 1 } : { opacity: confirmed ? 0 : 1, y: confirmed ? \"-110%\" : \"0%\" }}\n            transition={reduced ? { duration: 0.15 } : { y: { type: \"spring\", stiffness: 480, damping: 34 }, opacity: { duration: 0.14 } }}\n          >\n            {children}\n          </motion.span>\n          <motion.span\n            aria-hidden={!confirmed || undefined}\n            className=\"col-start-1 row-start-1\"\n            style={{ color: `color-mix(in oklab, ${accent} 35%, #fafafa)` }}\n            initial={false}\n            animate={reduced ? { opacity: confirmed ? 1 : 0 } : { opacity: confirmed ? 1 : 0, y: confirmed ? \"0%\" : \"110%\" }}\n            transition={reduced ? { duration: 0.15 } : { y: { type: \"spring\", stiffness: 480, damping: 34 }, opacity: { duration: 0.14 } }}\n          >\n            {confirmedLabel}\n          </motion.span>\n        </span>\n      </button>\n      <span id={hintId} className=\"sr-only\">\n        Press and hold to confirm\n      </span>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {confirmed ? confirmedLabel : null}\n      </span>\n    </>\n  );\n}\n"
    }
  ]
}