{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-reel",
  "title": "OTP Reel",
  "description": "6-box code entry where each box is a tiny slot-machine cipher reel: keystrokes spin a canvas glyph strip into a weighted detent on the typed digit with an accent flash; backspace reverse-spins to blank and idle cells carry a faint ambient drift.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/otp-reel/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CipherReelOtp — 6-box code entry where every box is a tiny slot-machine\n// cipher reel. Each cell is a real numeric <input> with a canvas overlay\n// drawing a vertical mono glyph strip (0-9 plus cipher glyphs); the input\n// text is transparent, the canvas is the visible face. Keystrokes spin the\n// reel at 14 rows/s, decelerating at 30 rows/s^2 into a detent spring\n// (k=380 s^-2, zeta=0.6, ~0.15-row overshoot) with an accent border flash on\n// lock. Backspace reverse-spins to blank, paste/autofill staggers cells\n// left-to-right, idle empty cells carry a faint ambient drift. The error prop\n// shakes the row and re-scrambles the reels out. Direct-DOM rAF sleeps the\n// instant every reel rests, pauses offscreen/hidden; all canvas ink is\n// derived from CSS tokens at mount and re-derived on theme class changes.\n// ---------------------------------------------------------------------------\n\nconst STRIP = \"0123456789#$%&*+\"; // digits first: digit d lives on row d\nconst N = STRIP.length;\nconst SPIN_V = 14; // rows/s entry speed\nconst DECEL = 30; // rows/s^2\nconst SPIN_ROWS = 2; // rows travelled before the detent takes over\nconst SPRING_K = 380; // s^-2\nconst SPRING_ZETA = 0.6; // ~0.15-row mechanical overshoot\nconst REV_V = 8; // rows/s backspace reverse-spin\nconst REV_ROWS = 1.2;\nconst AMBIENT_V = 0.15; // rows/s idle drift\nconst AMBIENT_A = 0.25; // idle strip alpha\nconst STAGGER_MS = 70; // paste/autofill left-to-right stagger\nconst FLASH_MS = 120; // accent border flash on lock\nconst DEADLINE_MS = 600; // forced-settle deadline per reel\nconst SCRAMBLE_MS = 240; // error scramble-out\nconst SHAKE_MS = 260; // error row shake, 3 cycles, +-4px\n\ntype Vec3 = readonly [number, number, number];\ntype Mode =\n  | \"empty\"\n  | \"queued\"\n  | \"spin\"\n  | \"spring\"\n  | \"rest\"\n  | \"reverse\"\n  | \"scramble\";\n\ninterface Cell {\n  mode: Mode;\n  pos: number; // row position, float; glyph = STRIP[(row + offset) mod N]\n  v: number; // rows/s\n  target: number; // absolute detent row\n  offset: number; // strip rotation so the detent row shows the typed digit\n  alpha: number; // base strip alpha for the current mode\n  startAt: number; // stagger gate (performance.now ms)\n  deadline: number; // forced-settle timestamp\n  revStart: number; // pos when the reverse-spin began\n  scrStart: number; // scramble start timestamp\n  pendingDigit: number;\n  dirty: boolean; // needs one redraw while otherwise asleep\n}\n\ninterface Engine {\n  type: (i: number, digit: number, delayMs: number) => void;\n  clear: (i: number) => void;\n  errorOut: () => void;\n  wake: () => void;\n}\n\nfunction parseColor(raw: string, fallback: Vec3): Vec3 {\n  const s = raw.trim();\n  if (s.startsWith(\"#\")) {\n    const hex = s.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex.slice(0, 1) + hex.slice(0, 1), 16);\n      const g = parseInt(hex.slice(1, 2) + hex.slice(1, 2), 16);\n      const b = parseInt(hex.slice(2, 3) + hex.slice(2, 3), 16);\n      return Number.isNaN(r + g + b) ? fallback : [r, g, b];\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      return Number.isNaN(r + g + b) ? fallback : [r, g, b];\n    }\n    return fallback;\n  }\n  const m = s.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : fallback;\n}\n\nconst rgba = (c: Vec3, a: number) =>\n  `rgba(${c[0]},${c[1]},${c[2]},${a.toFixed(3)})`;\n\nexport function CipherReelOtp({\n  length = 6,\n  label = \"Verification code\",\n  helperText = \"\",\n  error = false,\n  errorMessage = \"That code didn't match. Try again.\",\n  disabled = false,\n  autoFocus = false,\n  onChange,\n  onComplete,\n  className = \"\",\n}: {\n  /** number of reel cells; fixed at mount */\n  length?: number;\n  /** visible legend labelling the group */\n  label?: string;\n  /** muted helper line under the reels */\n  helperText?: string;\n  /** flip true to shake the row and scramble the reels clear */\n  error?: boolean;\n  /** helper line while error is set — the only place the error token appears */\n  errorMessage?: string;\n  /** blocks all reels */\n  disabled?: boolean;\n  /** focuses the first reel on mount */\n  autoFocus?: boolean;\n  /** called with the current code on every digit change */\n  onChange?: (code: string) => void;\n  /** called with the full code once every reel is filled */\n  onComplete?: (code: string) => void;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\n  const fieldRef = useRef<HTMLFieldSetElement>(null);\n  const rowRef = useRef<HTMLDivElement>(null);\n  const inputRefs = useRef<(HTMLInputElement | null)[]>([]);\n  const wrapRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);\n  const engineRef = useRef<Engine | null>(null);\n\n  const [values, setValues] = useState<string[]>(() =>\n    Array.from({ length }, () => \"\")\n  );\n  const valuesRef = useRef(values);\n  valuesRef.current = values;\n  const disabledRef = useRef(disabled);\n  disabledRef.current = disabled;\n  const onChangeRef = useRef(onChange);\n  onChangeRef.current = onChange;\n  const onCompleteRef = useRef(onComplete);\n  onCompleteRef.current = onComplete;\n\n  // -- canvas engine: locals only on the hot path, never React state --------\n  useEffect(() => {\n    const field = fieldRef.current;\n    const row = rowRef.current;\n    if (!field || !row) return;\n    const count = canvasRefs.current.length;\n    const ctxs = canvasRefs.current.map(\n      (cv) => cv?.getContext(\"2d\") ?? null\n    );\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    // token-derived ink: read at mount, re-derived on theme class change\n    let fg: Vec3 = [237, 237, 237];\n    let accent: Vec3 = [0, 107, 255];\n    const derive = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\"), fg);\n      accent = parseColor(cs.getPropertyValue(\"--ns-accent\"), accent);\n    };\n    derive();\n\n    const cells: Cell[] = Array.from({ length: count }, (_, i) => ({\n      mode: \"empty\",\n      pos: (i * 2.7 + 0.4) % N, // desynced ambient strips\n      v: 0,\n      target: 0,\n      offset: (i * 5) % N,\n      alpha: AMBIENT_A,\n      startAt: 0,\n      deadline: 0,\n      revStart: 0,\n      scrStart: 0,\n      pendingDigit: 0,\n      dirty: true,\n    }));\n\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let sized = false;\n    let cw = 0;\n    let ch = 0;\n    let dpr = 1;\n    let font = \"500 20px monospace\";\n    let shakeStart = -1;\n    const timeouts = new Set<number>();\n\n    const normalize = (c: Cell) => {\n      // subtract whole strips only — glyph lookup is invariant mod N\n      c.pos = ((c.pos % N) + N) % N;\n    };\n\n    const drawCell = (i: number) => {\n      const c = cells[i];\n      const ctx = ctxs[i];\n      if (!c || !ctx || !sized) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, cw, ch); // full clear + redraw every frame\n      ctx.font = font;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      const cx = cw / 2;\n      const cy = ch / 2;\n      if (c.mode === \"rest\") {\n        const g = STRIP[(((Math.round(c.pos) + c.offset) % N) + N) % N];\n        if (g) {\n          ctx.fillStyle = rgba(fg, 1);\n          ctx.fillText(g, cx, cy);\n        }\n        return;\n      }\n      if (c.mode === \"empty\") {\n        // idle look: one settled glyph at ambient alpha, never the\n        // multi-row blend — that blend is reserved for in-flight frames\n        const g = STRIP[(((Math.round(c.pos) + c.offset) % N) + N) % N];\n        if (g) {\n          ctx.fillStyle = rgba(fg, c.alpha);\n          ctx.fillText(g, cx, cy);\n        }\n        return;\n      }\n      const rowH = ch * 0.6;\n      const base = Math.floor(c.pos);\n      for (let r = base - 1; r <= base + 2; r++) {\n        const dy = (r - c.pos) * rowH;\n        if (Math.abs(dy) > cy + rowH * 0.5) continue;\n        const g = STRIP[(((r + c.offset) % N) + N) % N];\n        if (!g) continue;\n        const a = c.alpha * Math.max(0, 1 - (Math.abs(dy) / rowH) * 0.75);\n        if (a <= 0.012) continue;\n        ctx.fillStyle = rgba(fg, a);\n        ctx.fillText(g, cx, cy + dy);\n      }\n    };\n\n    const flash = (i: number) => {\n      // accent border flash at 35% alpha for 120 ms on detent lock\n      const el = wrapRefs.current[i];\n      if (!el || reduced) return;\n      el.style.borderColor = rgba(accent, 0.35);\n      el.style.boxShadow = `0 0 0 1px ${rgba(accent, 0.35)}, 0 0 10px ${rgba(accent, 0.18)}`;\n      const t = window.setTimeout(() => {\n        el.style.borderColor = \"\";\n        el.style.boxShadow = \"\";\n        timeouts.delete(t);\n      }, FLASH_MS);\n      timeouts.add(t);\n    };\n\n    const settle = (i: number) => {\n      const c = cells[i];\n      if (!c) return;\n      c.pos = c.target;\n      normalize(c);\n      c.v = 0;\n      c.alpha = 1;\n      c.mode = \"rest\";\n      c.dirty = false;\n      drawCell(i);\n      flash(i);\n    };\n\n    const resize = () => {\n      const first = wrapRefs.current[0];\n      if (!first) return;\n      const rect = first.getBoundingClientRect();\n      if (rect.width < 8 || rect.height < 8) {\n        sized = false; // zero-size guard\n        return;\n      }\n      cw = rect.width;\n      ch = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      font = `500 ${Math.round(ch * 0.42)}px ${getComputedStyle(row).fontFamily}`;\n      for (const cv of canvasRefs.current) {\n        if (!cv) continue;\n        cv.width = Math.round(cw * dpr);\n        cv.height = Math.round(ch * dpr);\n        // replaced element: CSS position does not size it — set both explicitly\n        cv.style.width = `${cw}px`;\n        cv.style.height = `${ch}px`;\n      }\n      sized = true;\n      for (const c of cells) c.dirty = true;\n      wake();\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      const dt = last ? Math.min(0.033, (now - last) / 1000) : 1 / 60;\n      last = now;\n      let active = false;\n      const ambientOk =\n        visible && !document.hidden && !reduced && !disabledRef.current;\n\n      // error shake: +-4px, 3 cycles, damped over 260 ms\n      if (shakeStart >= 0) {\n        const p = (now - shakeStart) / SHAKE_MS;\n        if (p >= 1) {\n          row.style.transform = \"\";\n          shakeStart = -1;\n        } else {\n          const x = 4 * Math.sin(Math.PI * 2 * 3 * p) * (1 - p);\n          row.style.transform = `translateX(${x.toFixed(2)}px)`;\n          active = true;\n        }\n      }\n\n      for (let i = 0; i < count; i++) {\n        const c = cells[i];\n        if (!c) continue;\n        switch (c.mode) {\n          case \"queued\": {\n            // paste/autofill stagger gate; keep drifting while waiting\n            if (now >= c.startAt) {\n              c.target = Math.round(c.pos) + SPIN_ROWS;\n              c.offset = (((c.pendingDigit - c.target) % N) + N) % N;\n              c.v = SPIN_V;\n              c.mode = \"spin\";\n            } else if (ambientOk) {\n              c.pos += AMBIENT_V * dt;\n            }\n            drawCell(i);\n            active = true;\n            break;\n          }\n          case \"spin\": {\n            c.v = Math.max(4, c.v - DECEL * dt);\n            c.pos += c.v * dt;\n            if (now >= c.deadline || c.pos >= c.target) {\n              settle(i); // forced-settle deadline\n            } else if (c.pos >= c.target - 0.45) {\n              c.mode = \"spring\";\n              drawCell(i);\n              active = true;\n            } else {\n              drawCell(i);\n              active = true;\n            }\n            break;\n          }\n          case \"spring\": {\n            if (now >= c.deadline) {\n              settle(i);\n              break;\n            }\n            const damp = 2 * SPRING_ZETA * Math.sqrt(SPRING_K);\n            c.v += (-SPRING_K * (c.pos - c.target) - damp * c.v) * dt;\n            c.pos += c.v * dt;\n            if (Math.abs(c.pos - c.target) < 0.004 && Math.abs(c.v) < 0.08) {\n              settle(i); // reel rests, its rAF share sleeps now\n            } else {\n              drawCell(i);\n              active = true;\n            }\n            break;\n          }\n          case \"reverse\": {\n            c.pos -= REV_V * dt;\n            const p = Math.min(1, (c.revStart - c.pos) / REV_ROWS);\n            c.alpha = 1 - (1 - AMBIENT_A) * p;\n            if (p >= 1 || now >= c.deadline) {\n              c.mode = \"empty\";\n              c.alpha = AMBIENT_A;\n              normalize(c);\n              c.dirty = true;\n            } else {\n              drawCell(i);\n              active = true;\n            }\n            break;\n          }\n          case \"scramble\": {\n            const p = Math.min(1, (now - c.scrStart) / SCRAMBLE_MS);\n            c.pos += (6 + 18 * p) * dt; // accelerating scramble-out\n            c.alpha = 1 - (1 - AMBIENT_A) * p;\n            if (p >= 1) {\n              c.mode = \"empty\";\n              c.alpha = AMBIENT_A;\n              normalize(c);\n              c.dirty = true;\n            } else {\n              drawCell(i);\n              active = true;\n            }\n            break;\n          }\n          case \"empty\": {\n            if (ambientOk) {\n              c.pos += AMBIENT_V * dt; // ambient drift is the default look\n              c.dirty = false;\n              drawCell(i);\n              active = true;\n            } else if (c.dirty) {\n              c.dirty = false;\n              drawCell(i); // one static frame, then sleep\n            }\n            break;\n          }\n          case \"rest\": {\n            if (c.dirty) {\n              c.dirty = false;\n              drawCell(i);\n            }\n            break;\n          }\n        }\n      }\n\n      // pause offscreen/hidden even mid-spin — forced-settle deadlines catch\n      // up the moment the loop wakes again\n      if (active && visible && !document.hidden) {\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const wake = () => {\n      if (!raf) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    // -- engine API ----------------------------------------------------------\n    const type = (i: number, digit: number, delayMs: number) => {\n      const c = cells[i];\n      if (!c) return;\n      if (reduced) {\n        // digits render instantly: no spin, plain advance\n        c.target = Math.round(c.pos);\n        c.offset = (((digit - c.target) % N) + N) % N;\n        c.pos = c.target;\n        c.v = 0;\n        c.alpha = 1;\n        c.mode = \"rest\";\n        c.dirty = true;\n        wake();\n        return;\n      }\n      const now = performance.now();\n      c.pendingDigit = digit;\n      c.alpha = 1;\n      c.startAt = now + delayMs;\n      c.deadline = c.startAt + DEADLINE_MS;\n      c.mode = \"queued\";\n      wake();\n    };\n\n    const clear = (i: number) => {\n      const c = cells[i];\n      if (!c) return;\n      if (reduced || c.mode === \"empty\") {\n        c.mode = \"empty\";\n        c.alpha = AMBIENT_A;\n        c.v = 0;\n        normalize(c);\n        c.dirty = true;\n        wake();\n        return;\n      }\n      c.revStart = c.pos;\n      c.deadline = performance.now() + DEADLINE_MS;\n      c.mode = \"reverse\";\n      c.v = 0;\n      wake();\n    };\n\n    const errorOut = () => {\n      const now = performance.now();\n      if (!reduced) shakeStart = now;\n      for (const c of cells) {\n        if (c.mode === \"empty\") continue;\n        if (reduced) {\n          c.mode = \"empty\";\n          c.alpha = AMBIENT_A;\n          c.v = 0;\n          normalize(c);\n          c.dirty = true;\n        } else {\n          c.scrStart = now;\n          c.mode = \"scramble\";\n        }\n      }\n      wake();\n    };\n\n    engineRef.current = { type, clear, errorOut, wake };\n\n    // -- init + observers ----------------------------------------------------\n    resize();\n\n    const ro = new ResizeObserver(resize);\n    ro.observe(row);\n    const io = new IntersectionObserver((entries) => {\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible) wake(); // ambient resumes on re-entry\n    });\n    io.observe(field);\n    const mo = new MutationObserver(() => {\n      derive(); // live theme re-derive on documentElement class flips\n      for (const c of cells) c.dirty = true;\n      wake();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      for (const t of timeouts) window.clearTimeout(t);\n      timeouts.clear();\n      row.style.transform = \"\";\n      engineRef.current = null;\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n\n  // wake the loop when re-enabled so ambient drift resumes\n  useEffect(() => {\n    if (!disabled) engineRef.current?.wake();\n  }, [disabled]);\n\n  // error rising edge: shake + scramble out, clear values, refocus\n  const prevError = useRef(false);\n  useEffect(() => {\n    if (error && !prevError.current) {\n      engineRef.current?.errorOut();\n      setValues((prev) => prev.map(() => \"\"));\n      inputRefs.current[0]?.focus();\n    }\n    prevError.current = error;\n  }, [error]);\n\n  // emit change/complete after state commits\n  const emitted = useRef(false);\n  useEffect(() => {\n    const code = values.join(\"\");\n    if (!emitted.current) {\n      emitted.current = true;\n      return;\n    }\n    onChangeRef.current?.(code);\n    if (values.length > 0 && values.every((v) => v !== \"\")) {\n      onCompleteRef.current?.(code);\n    }\n  }, [values]);\n\n  // -- interaction -----------------------------------------------------------\n  const focusCell = (i: number) => {\n    inputRefs.current[Math.max(0, Math.min(length - 1, i))]?.focus();\n  };\n\n  const commit = (i: number, d: string) => {\n    setValues((prev) => {\n      const next = [...prev];\n      next[i] = d;\n      return next;\n    });\n    engineRef.current?.type(i, Number(d), 0);\n    if (i < length - 1) focusCell(i + 1); // auto-advance\n  };\n\n  const clearCell = (i: number) => {\n    setValues((prev) => {\n      if (!prev[i]) return prev;\n      const next = [...prev];\n      next[i] = \"\";\n      return next;\n    });\n    engineRef.current?.clear(i);\n  };\n\n  const distribute = (raw: string) => {\n    const digits = raw.replace(/\\D/g, \"\").slice(0, length);\n    if (!digits) return;\n    setValues(() => Array.from({ length }, (_, i) => digits[i] ?? \"\"));\n    for (let i = 0; i < length; i++) {\n      const d = digits[i];\n      if (d !== undefined) {\n        engineRef.current?.type(i, Number(d), i * STAGGER_MS);\n      } else {\n        engineRef.current?.clear(i);\n      }\n    }\n    focusCell(Math.min(digits.length, length - 1));\n  };\n\n  const handleKeyDown = (i: number, e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (/^[0-9]$/.test(e.key)) {\n      e.preventDefault();\n      commit(i, e.key);\n      return;\n    }\n    if (e.key === \"Backspace\") {\n      e.preventDefault();\n      if (valuesRef.current[i]) {\n        clearCell(i); // reverse-spin to blank, stay put\n      } else if (i > 0) {\n        clearCell(i - 1); // empty cell: move left and clear\n        focusCell(i - 1);\n      }\n      return;\n    }\n    if (e.key === \"ArrowLeft\") {\n      e.preventDefault();\n      focusCell(i - 1);\n    } else if (e.key === \"ArrowRight\") {\n      e.preventDefault();\n      focusCell(i + 1);\n    } else if (e.key === \"Home\") {\n      e.preventDefault();\n      focusCell(0);\n    } else if (e.key === \"End\") {\n      e.preventDefault();\n      focusCell(length - 1);\n    }\n  };\n\n  const handleChange = (i: number, e: React.ChangeEvent<HTMLInputElement>) => {\n    // mobile keyboards and SMS autofill land here (keydown path is prevented)\n    const digits = e.target.value.replace(/\\D/g, \"\");\n    if (!digits) {\n      if (valuesRef.current[i]) clearCell(i);\n      return;\n    }\n    if (digits.length === 1) commit(i, digits);\n    else distribute(digits);\n  };\n\n  const handlePaste = (e: React.ClipboardEvent<HTMLFieldSetElement>) => {\n    e.preventDefault();\n    distribute(e.clipboardData.getData(\"text\"));\n  };\n\n  const complete = values.every((v) => v !== \"\");\n\n  return (\n    <fieldset\n      ref={fieldRef}\n      disabled={disabled}\n      onPaste={handlePaste}\n      className={`m-0 min-w-0 border-0 p-0 ${disabled ? \"opacity-50\" : \"\"} ${className}`}\n    >\n      <legend className=\"mb-3 block p-0 font-mono text-xs uppercase tracking-[0.18em] text-ns-muted\">\n        {label}\n      </legend>\n      <div\n        ref={rowRef}\n        className=\"flex items-center font-mono will-change-transform\"\n      >\n        {values.map((v, i) => (\n          <div\n            key={i}\n            ref={(el) => {\n              wrapRefs.current[i] = el;\n            }}\n            className={`relative h-14 w-11 overflow-hidden rounded-sm border border-border bg-background transition-[border-color,box-shadow] duration-150 hover:border-foreground/25 focus-within:border-ns-accent/60 focus-within:ring-2 focus-within:ring-ns-accent/30 ${\n              i === 0 ? \"\" : i === Math.ceil(length / 2) ? \"ml-4\" : \"ml-2\"\n            }`}\n          >\n            <canvas\n              ref={(el) => {\n                canvasRefs.current[i] = el;\n              }}\n              aria-hidden\n              className=\"pointer-events-none absolute left-0 top-0\"\n            />\n            <input\n              ref={(el) => {\n                inputRefs.current[i] = el;\n              }}\n              type=\"text\"\n              inputMode=\"numeric\"\n              pattern=\"[0-9]*\"\n              autoComplete={i === 0 ? \"one-time-code\" : \"off\"}\n              maxLength={1}\n              value={v}\n              disabled={disabled}\n              autoFocus={autoFocus && i === 0}\n              aria-label={`Digit ${i + 1} of ${length}`}\n              onChange={(e) => handleChange(i, e)}\n              onKeyDown={(e) => handleKeyDown(i, e)}\n              onFocus={(e) => e.currentTarget.select()}\n              className=\"absolute inset-0 h-full w-full rounded-sm bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent selection:text-transparent disabled:cursor-not-allowed\"\n            />\n          </div>\n        ))}\n      </div>\n      <p\n        aria-live=\"polite\"\n        className={`mt-3 min-h-[1.25rem] text-xs ${error ? \"\" : \"text-ns-muted\"}`}\n        style={error ? { color: \"var(--error, #ea001d)\" } : undefined}\n      >\n        {error ? errorMessage : complete ? \"Code complete.\" : helperText}\n      </p>\n    </fieldset>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/otp-reel.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "error": "#ea001d"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "error": "#ff6369"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "otp",
      "input",
      "form",
      "canvas",
      "slot-machine",
      "2fa",
      "micro-interaction"
    ],
    "instruction": "Build a 6-box OTP control where every box is a tiny slot-machine cipher reel. RENDER: 6 real <input inputmode=numeric pattern=[0-9]* autocomplete=one-time-code maxLength=1> cells inside a fieldset+legend, each with a per-cell canvas overlay drawing a vertical mono glyph strip (0-9 first so digit d lives on row d, plus cipher glyphs #$%&*+); the input text and caret are transparent, the canvas is the visible face, and each canvas gets EXPLICIT style.width/height (replaced element — CSS inset does not size it), backing store at devicePixelRatio clamped to 2, full clear + redraw every frame. MOTION: a keystroke spins the reel ~2 rows at 14 rows/s decelerating at 30 rows/s^2, then a per-cell strip-rotation offset lands the typed digit in a detent spring (k=380 s^-2, zeta=0.6, ~0.15-row mechanical overshoot), total ~160-220 ms, with the cell border flashing --ns-accent at 35% alpha for 120 ms on lock and a forced-settle deadline of 600 ms per reel so nothing can spin forever. Backspace reverse-spins 8 rows/s about 1.2 rows while fading to the blank state; paste and SMS autofill distribute digits with a 70 ms left-to-right stagger of the same spin; idle empty cells drift 0.15 rows/s at 25% alpha as the default ambient look. Error prop rising edge: the row shakes +-4 px for 3 damped cycles over 260 ms while every filled reel re-scrambles out over 240 ms and the code clears — motion carries the error, the --error token appears only on the helper text. INTERACTION: auto-advance on entry, Backspace on empty moves left, ArrowLeft/Right/Home/End navigate, focus selects, paste splits, aria-live polite helper announces completion and errors. PERFORMANCE: direct-DOM rAF with no React state on the hot path; each reel sleeps the instant it rests (a locked cell costs zero redraws), the loop pauses offscreen via IntersectionObserver and on document hidden with deadlines catching up on wake, zero-size containers guarded, every observer/listener/timeout torn down. THEME: glyph and accent ink parsed from getComputedStyle(--foreground/--ns-accent) at mount and re-derived by a MutationObserver on documentElement class changes so both themes render live. REDUCED MOTION: digits render instantly with no spin or drift and a plain focus highlight. Hover and focus affordances are token-relative (border-foreground/25, ring-ns-accent)."
  },
  "type": "registry:ui"
}