{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "confirm-dial-align",
  "title": "Confirm Dial Align",
  "description": "Destructive-action confirm gated on precision, not patience: rotate an SVG dial's notch to within 3deg of a fixed index, hold it there 400ms, and the dial snaps home and arms the confirm button — overshoot and it springs back like a slipping safe tumbler.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/confirm-dial-align/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState, type ReactNode } from \"react\";\n\n// TumblerGate — a destructive-action confirm gated on ACCURACY, the third\n// axis besides confirm-hold-ink's time and confirm-slide-shatter's distance. An\n// SVG dial carries one rotating notch; it must be turned (drag, wheel, or\n// arrow keys) until the notch sits within 3deg of the fixed index mark and\n// HELD there for a 400ms dwell — leaving the band before the dwell completes\n// is a failed catch: the dial springs out to +8deg like a safe tumbler that\n// almost caught and slipped. Completing the dwell spring-snaps to exact\n// zero, both ticks thicken to --foreground, and the destructive button arms\n// (its one legitimate use of --accent). Because fine-motor precision is\n// hostile to some users, an always-visible \"type to confirm\" disclosure\n// offers an equivalent path, and two failed catches auto-expand it. Direct-\n// DOM writes on the drag/spring hot path, React state only at the rare\n// armed/failed/fallback transitions. Zero deps.\n\nconst TOL = 3; // deg — dwell capture band around the index mark\nconst DWELL_MS = 400;\nconst STEP = 2; // deg per wheel tick / arrow key\nconst FRICTION = 0.6; // drag angular delta damping\nconst SLIP_TARGET = 8; // deg — where a failed catch springs out to\nconst SNAP_K = 260;\nconst SNAP_ZETA = 0.85; // one tiny overshoot settling to exact zero\nconst SLIP_K = 380;\nconst SLIP_ZETA = 0.55; // stiffer, snappier \"give\"\nconst SETTLE_EPS = 0.05; // deg\nconst V_EPS = 0.3; // deg/s\nconst FORCE_SETTLE_MS = 700;\n\nfunction wrapDeg(a: number) {\n  let r = a % 360;\n  if (r > 180) r -= 360;\n  if (r <= -180) r += 360;\n  return r;\n}\n\nexport function TumblerGate({\n  destructiveLabel = \"Delete organization\",\n  doneLabel = \"Deleted\",\n  confirmWord = \"delete\",\n  initialAngle = 150,\n  onConfirm,\n  className = \"\",\n}: {\n  destructiveLabel?: ReactNode;\n  doneLabel?: ReactNode;\n  /** word the type-to-confirm fallback checks against, case-insensitive */\n  confirmWord?: string;\n  /** starting dial offset in degrees from the index mark */\n  initialAngle?: number;\n  onConfirm?: () => void;\n  className?: string;\n}) {\n  const knobRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const notchGroupRef = useRef<SVGGElement>(null);\n  const notchLineRef = useRef<SVGLineElement>(null);\n  const indexLineRef = useRef<SVGLineElement>(null);\n\n  const [armed, setArmed] = useState(false);\n  const [confirmed, setConfirmed] = useState(false);\n  const [failedAttempts, setFailedAttempts] = useState(0);\n  const [fallbackOpen, setFallbackOpen] = useState(false);\n  const [typedValue, setTypedValue] = useState(\"\");\n  const [announcement, setAnnouncement] = useState(\"\");\n\n  const armedRef = useRef(false);\n  const confirmedRef = useRef(false);\n  const dwellingRef = useRef(false);\n  const failedRef = useRef(0);\n  const fallbackOpenRef = useRef(false);\n  const reducedRef = useRef(false);\n  const angleRef = useRef(wrapDeg(initialAngle));\n  const dwellTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(\n    undefined\n  );\n  const animRef = useRef<{\n    raf: number;\n    mode: \"snap\" | \"slip\" | null;\n    target: number;\n    v: number;\n    start: number;\n  }>({ raf: 0, mode: null, target: 0, v: 0, start: 0 });\n\n  const onConfirmRef = useRef(onConfirm);\n  onConfirmRef.current = onConfirm;\n  const confirmWordRef = useRef(confirmWord);\n  confirmWordRef.current = confirmWord;\n\n  // low-level DOM write: transform + native slider value + aria-valuetext.\n  // never touches the dwell/slip state machine — safe to call from the\n  // spring animation loop as well as the input handlers.\n  const render = (angle: number) => {\n    notchGroupRef.current?.setAttribute(\"transform\", `rotate(${angle} 100 100)`);\n    const input = inputRef.current;\n    if (input && Math.round(input.valueAsNumber) !== Math.round(angle)) {\n      input.value = String(angle);\n    }\n    const rounded = Math.round(angle);\n    const text = armedRef.current\n      ? \"Unlocked\"\n      : `${Math.abs(rounded)} degree${Math.abs(rounded) === 1 ? \"\" : \"s\"} from unlocked`;\n    input?.setAttribute(\"aria-valuetext\", text);\n  };\n\n  const stopAnim = () => {\n    if (animRef.current.raf) cancelAnimationFrame(animRef.current.raf);\n    animRef.current.raf = 0;\n    animRef.current.mode = null;\n  };\n\n  const animateTo = (target: number, mode: \"snap\" | \"slip\") => {\n    stopAnim();\n    if (reducedRef.current) {\n      angleRef.current = target;\n      render(target);\n      return;\n    }\n    const st = animRef.current;\n    st.mode = mode;\n    st.target = target;\n    st.v = 0;\n    st.start = performance.now();\n    let last = st.start;\n    const k = mode === \"snap\" ? SNAP_K : SLIP_K;\n    const zeta = mode === \"snap\" ? SNAP_ZETA : SLIP_ZETA;\n    const c = 2 * zeta * Math.sqrt(k);\n    const step = (now: number) => {\n      const dt = Math.min(0.05, (now - last) / 1000);\n      last = now;\n      const x = angleRef.current;\n      st.v += (k * (target - x) - c * st.v) * dt;\n      const nx = x + st.v * dt;\n      angleRef.current = nx;\n      render(nx);\n      const settled = Math.abs(nx - target) < SETTLE_EPS && Math.abs(st.v) < V_EPS;\n      const timedOut = now - st.start > FORCE_SETTLE_MS;\n      if (settled || timedOut || st.mode !== mode) {\n        angleRef.current = target;\n        render(target);\n        st.raf = 0;\n        st.mode = null;\n        return;\n      }\n      st.raf = requestAnimationFrame(step);\n    };\n    st.raf = requestAnimationFrame(step);\n  };\n\n  const arm = (text: string) => {\n    if (armedRef.current) return;\n    armedRef.current = true;\n    setArmed(true);\n    setAnnouncement(text);\n    if (notchLineRef.current) {\n      notchLineRef.current.style.stroke = \"var(--foreground)\";\n      notchLineRef.current.style.strokeWidth = \"3\";\n    }\n    if (indexLineRef.current) {\n      indexLineRef.current.style.stroke = \"var(--foreground)\";\n      indexLineRef.current.style.strokeWidth = \"3\";\n    }\n  };\n\n  const completeDwell = () => {\n    dwellingRef.current = false;\n    arm(\"Aligned, delete enabled.\");\n    animateTo(0, \"snap\");\n  };\n\n  const triggerSlip = () => {\n    const n = failedRef.current + 1;\n    failedRef.current = n;\n    setFailedAttempts(n);\n    if (n >= 2 && !fallbackOpenRef.current) {\n      fallbackOpenRef.current = true;\n      setFallbackOpen(true);\n      setAnnouncement(\"Two failed attempts. Type to confirm is available.\");\n    }\n    animateTo(SLIP_TARGET, \"slip\");\n  };\n\n  // the dial's state machine — dwell start/cancel/complete. Called only\n  // from direct user input (drag, wheel, keyboard), never from the spring\n  // animation loop.\n  const commit = (newAngle: number) => {\n    if (armedRef.current) return;\n    angleRef.current = newAngle;\n    render(newAngle);\n    const within = Math.abs(newAngle) <= TOL;\n    if (within) {\n      if (!dwellingRef.current) {\n        dwellingRef.current = true;\n        clearTimeout(dwellTimerRef.current);\n        dwellTimerRef.current = setTimeout(completeDwell, DWELL_MS);\n      }\n    } else if (dwellingRef.current) {\n      clearTimeout(dwellTimerRef.current);\n      dwellingRef.current = false;\n      triggerSlip();\n    }\n  };\n\n  useEffect(() => {\n    const knob = knobRef.current;\n    const input = inputRef.current;\n    if (!knob || !input) return;\n\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onMq = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onMq);\n\n    const pointerAngleDeg = (e: PointerEvent) => {\n      const r = knob.getBoundingClientRect();\n      const rad = Math.atan2(\n        e.clientY - (r.top + r.height / 2),\n        e.clientX - (r.left + r.width / 2)\n      );\n      return (rad * 180) / Math.PI;\n    };\n\n    let dragging = false;\n    let lastDeg = 0;\n\n    const onPointerDown = (e: PointerEvent) => {\n      if (armedRef.current) return;\n      if (e.pointerType === \"mouse\" && e.button !== 0) return;\n      try {\n        knob.setPointerCapture(e.pointerId);\n      } catch {\n        // synthetic pointerId — drag still works without capture\n      }\n      dragging = true;\n      stopAnim();\n      lastDeg = pointerAngleDeg(e);\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (!dragging || armedRef.current) return;\n      const deg = pointerAngleDeg(e);\n      const delta = wrapDeg(deg - lastDeg);\n      lastDeg = deg;\n      commit(wrapDeg(angleRef.current + delta * FRICTION));\n    };\n    const onPointerEnd = () => {\n      dragging = false;\n    };\n    const onWheel = (e: WheelEvent) => {\n      if (armedRef.current) return;\n      e.preventDefault();\n      stopAnim();\n      const dir = e.deltaY > 0 ? -1 : 1;\n      commit(wrapDeg(angleRef.current + dir * STEP));\n    };\n    const onInput = () => {\n      if (armedRef.current) {\n        input.value = String(angleRef.current);\n        return;\n      }\n      const v = input.valueAsNumber;\n      if (Number.isNaN(v)) return;\n      stopAnim();\n      commit(wrapDeg(v));\n    };\n\n    knob.addEventListener(\"pointerdown\", onPointerDown);\n    knob.addEventListener(\"pointermove\", onPointerMove);\n    knob.addEventListener(\"pointerup\", onPointerEnd);\n    knob.addEventListener(\"pointercancel\", onPointerEnd);\n    knob.addEventListener(\"lostpointercapture\", onPointerEnd);\n    knob.addEventListener(\"wheel\", onWheel, { passive: false });\n    input.addEventListener(\"input\", onInput);\n\n    render(angleRef.current);\n\n    return () => {\n      mq.removeEventListener(\"change\", onMq);\n      knob.removeEventListener(\"pointerdown\", onPointerDown);\n      knob.removeEventListener(\"pointermove\", onPointerMove);\n      knob.removeEventListener(\"pointerup\", onPointerEnd);\n      knob.removeEventListener(\"pointercancel\", onPointerEnd);\n      knob.removeEventListener(\"lostpointercapture\", onPointerEnd);\n      knob.removeEventListener(\"wheel\", onWheel);\n      input.removeEventListener(\"input\", onInput);\n      clearTimeout(dwellTimerRef.current);\n      stopAnim();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  const handleConfirm = () => {\n    if (!armedRef.current || confirmedRef.current) return;\n    confirmedRef.current = true;\n    setConfirmed(true);\n    setAnnouncement(\"Confirmed.\");\n    onConfirmRef.current?.();\n  };\n\n  const handleTypedChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const v = e.target.value;\n    setTypedValue(v);\n    if (v.trim().toLowerCase() === confirmWordRef.current.trim().toLowerCase()) {\n      arm(\"Confirmed, delete enabled.\");\n    }\n  };\n\n  return (\n    <div className={`flex flex-col items-center gap-6 ${className}`}>\n      <div\n        ref={knobRef}\n        className=\"tumbler-knob relative h-44 w-44 cursor-grab touch-none select-none rounded-full border border-border bg-surface active:cursor-grabbing focus-within:outline focus-within:outline-2 focus-within:outline-offset-4 focus-within:outline-accent\"\n      >\n        <input\n          ref={inputRef}\n          type=\"range\"\n          role=\"slider\"\n          min={-180}\n          max={180}\n          step={STEP}\n          defaultValue={angleRef.current}\n          aria-label=\"Alignment dial\"\n          className=\"sr-only\"\n        />\n        <svg viewBox=\"0 0 200 200\" aria-hidden className=\"absolute inset-0 h-full w-full\">\n          <circle cx={100} cy={100} r={90} fill=\"none\" stroke=\"var(--border)\" strokeWidth={1.5} />\n          <line\n            ref={indexLineRef}\n            x1={100}\n            y1={6}\n            x2={100}\n            y2={26}\n            stroke=\"var(--border)\"\n            strokeWidth={2}\n            strokeLinecap=\"round\"\n          />\n          <g ref={notchGroupRef} transform={`rotate(${angleRef.current} 100 100)`}>\n            <line\n              ref={notchLineRef}\n              x1={100}\n              y1={14}\n              x2={100}\n              y2={70}\n              stroke=\"var(--border)\"\n              strokeWidth={2}\n              strokeLinecap=\"round\"\n            />\n          </g>\n          <circle cx={100} cy={100} r={5} fill=\"var(--surface)\" stroke=\"var(--border)\" strokeWidth={1.5} />\n        </svg>\n      </div>\n\n      <div aria-live=\"polite\" className=\"sr-only\">\n        {announcement}\n      </div>\n\n      <button\n        type=\"button\"\n        disabled={!armed || confirmed}\n        onClick={handleConfirm}\n        className={[\n          \"rounded-sm border px-5 py-2.5 text-sm font-medium transition-colors duration-150\",\n          \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\",\n          \"disabled:cursor-not-allowed disabled:opacity-40\",\n          armed\n            ? \"border-accent bg-surface text-foreground enabled:hover:bg-border/60\"\n            : \"border-border bg-surface text-muted\",\n        ].join(\" \")}\n      >\n        {confirmed ? doneLabel : destructiveLabel}\n      </button>\n\n      <details\n        className=\"w-full max-w-xs text-center\"\n        open={fallbackOpen}\n        onToggle={(e) => setFallbackOpen(e.currentTarget.open)}\n      >\n        <summary\n          data-tumbler-toggle=\"\"\n          className=\"cursor-pointer select-none font-mono text-xs uppercase tracking-[0.15em] text-muted underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\"\n        >\n          Type to confirm instead\n        </summary>\n        <div className=\"mt-3 flex flex-col items-center gap-1.5\">\n          <label htmlFor=\"tumbler-confirm-input\" className=\"font-mono text-xs text-muted\">\n            Type &quot;{confirmWord}&quot; to enable\n          </label>\n          <input\n            id=\"tumbler-confirm-input\"\n            data-tumbler-confirm-input=\"\"\n            type=\"text\"\n            value={typedValue}\n            onChange={handleTypedChange}\n            autoComplete=\"off\"\n            className=\"w-40 rounded-sm border border-border bg-background px-2 py-1 text-center text-sm text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent\"\n          />\n        </div>\n      </details>\n\n      {failedAttempts > 0 && !armed && (\n        <p aria-hidden className=\"font-mono text-[11px] text-muted\">\n          {failedAttempts} failed {failedAttempts === 1 ? \"attempt\" : \"attempts\"}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/confirm-dial-align.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "dial",
      "confirm",
      "destructive",
      "slider",
      "physics",
      "accessibility",
      "confirmation"
    ],
    "instruction": "A destructive-action confirm built on precision rather than patience or distance: a 176px SVG dial (an outer ring in --border, a rotating notch group, and a fixed index tick at 12 o'clock) must be turned by pointer drag, mouse wheel, or arrow keys until the notch sits within 3deg of the index and is HELD there for a 400ms dwell. Drag maps pointer-angle delta around the dial's center to rotation damped by a 0.6 friction factor (a raw 1:1 turn would feel twitchy at this tolerance); wheel and arrow keys step exactly 2deg via a visually-hidden native input[type=range] (role=slider, min -180 max 180, step 2) that also carries a live aria-valuetext like '12 degrees from unlocked' (or 'Unlocked' once armed) so screen-reader users get the same continuous feedback sighted users get from the notch's position. Leaving the 3deg band before the 400ms dwell completes is a failed catch: the dial springs out to a fixed +8deg stop under a stiff, lightly underdamped spring (k=380, zeta=0.55) — a deliberate 'give' read as a safe tumbler that almost caught and slipped, visually and mechanically distinct from a plain rubber-band snap-back. Completing the dwell cancels the timer, spring-snaps the notch to exact zero (k=260, zeta=0.85, one small overshoot), thickens both the notch and index ticks from --border to --foreground, and arms the destructive button, which is the component's one legitimate use of --accent (a border that appears only once the precision test is actually passed). Because a fine-motor precision gate is inherently hostile to motor-impaired users, an always-visible 'Type to confirm instead' native <details>/<summary> disclosure offers an equal alternate path — typing the configured word (default 'delete', case-insensitive) arms the button exactly like a successful dwell; after two failed dial catches that disclosure auto-expands (aria-live announces it) so the escape hatch is offered, not just available. Every alignment success or fallback match announces politely via aria-live ('Aligned, delete enabled.' / 'Confirmed, delete enabled.'). All physics run on direct-DOM refs (dial transform, slider value/aria-valuetext, spring loops) with React state reserved for the rare armed/confirmed/failed-count/fallback-open transitions. Once armed the dial locks (further drag/wheel/keys are ignored) since a caught tumbler has nothing left to prove. Under prefers-reduced-motion the slip and snap springs are replaced by instant jumps to the same end angles — no oscillation — while drag, wheel, and keyboard stepping behave identically either way."
  },
  "type": "registry:ui"
}