{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "cipher",
  "type": "registry:ui",
  "title": "Cipher",
  "description": "Characters churn through scrambled forge-rune glyphs before quenching into the final text, one at a time. Triggerable on mount, hover, or scroll into view; resolves instantly under reduced motion.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/text/cipher/cipher.tsx",
      "type": "registry:ui",
      "target": "components/crucible/cipher.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nexport interface CipherProps extends Omit<React.ComponentPropsWithoutRef<\"span\">, \"children\"> {\n  /** The text to resolve into. */\n  children: string;\n  /** What starts the scramble. @default \"mount\" */\n  trigger?: \"mount\" | \"hover\" | \"view\";\n  /** Order characters lock into place. @default \"random\" */\n  resolveOrder?: \"start\" | \"end\" | \"center\" | \"random\";\n  /** Glyphs drawn from while a character is still scrambling. @default abstract glyph set */\n  charset?: string;\n  /** Seconds for the whole string to resolve. @default 1.1 */\n  duration?: number;\n  /** Seconds to wait before the scramble starts. @default 0 */\n  delay?: number;\n  /**\n   * Re-run every time the element scrolls back into view (`trigger=\"view\"`).\n   * The `hover` trigger always re-scrambles on each hover. @default false\n   */\n  repeat?: boolean;\n  /** Color of the still-scrambling glyphs. @default neutral-400 */\n  scrambleColor?: string;\n  /** Color of the brief flash when a character locks in. @default white */\n  flashColor?: string;\n}\n\nconst DEFAULT_CHARSET = \"⟁⟂⟃⟄⏃⏚⌬⌭⎔⋈⋔※᛭ᚨᚱᚲ0123456789#%&\";\n\nfunction computeRanks(n: number, order: NonNullable<CipherProps[\"resolveOrder\"]>): number[] {\n  const ranks = new Array<number>(n);\n  if (order === \"end\") {\n    for (let i = 0; i < n; i++) ranks[i] = n - 1 - i;\n    return ranks;\n  }\n  if (order === \"center\") {\n    const center = (n - 1) / 2;\n    const idx = Array.from({ length: n }, (_, i) => i).sort(\n      (a, b) => Math.abs(a - center) - Math.abs(b - center) || a - b\n    );\n    idx.forEach((charIndex, rank) => {\n      ranks[charIndex] = rank;\n    });\n    return ranks;\n  }\n  if (order === \"random\") {\n    const idx = Array.from({ length: n }, (_, i) => i);\n    for (let i = idx.length - 1; i > 0; i--) {\n      const j = Math.floor(Math.random() * (i + 1));\n      const tmp = idx[i]!;\n      idx[i] = idx[j]!;\n      idx[j] = tmp;\n    }\n    idx.forEach((charIndex, rank) => {\n      ranks[charIndex] = rank;\n    });\n    return ranks;\n  }\n  for (let i = 0; i < n; i++) ranks[i] = i;\n  return ranks;\n}\n\n/**\n * Cipher — characters churn through scrambled glyphs before locking into the\n * final text, one at a time, in the order you choose — each lock-in marked by\n * a brief white flash. Resolves instantly under reduced motion.\n */\nexport function Cipher({\n  children,\n  trigger = \"mount\",\n  resolveOrder = \"random\",\n  charset = DEFAULT_CHARSET,\n  duration = 1.1,\n  delay = 0,\n  repeat = false,\n  scrambleColor = \"#a3a3a3\",\n  flashColor = \"#ffffff\",\n  className,\n  style,\n  ...props\n}: CipherProps) {\n  const reducedMotion = useReducedMotion();\n  const chars = React.useMemo(() => Array.from(children), [children]);\n  // Runs of non-space chars (words) vs. whitespace, so the render can keep each\n  // word on one line while letting the line break at spaces.\n  const wordGroups = React.useMemo(() => {\n    const groups: { start: number; end: number; isSpace: boolean }[] = [];\n    let i = 0;\n    while (i < chars.length) {\n      const isSpace = /\\s/.test(chars[i]!);\n      let j = i;\n      while (j < chars.length && /\\s/.test(chars[j]!) === isSpace) j++;\n      groups.push({ start: i, end: j, isSpace });\n      i = j;\n    }\n    return groups;\n  }, [chars]);\n  const [display, setDisplay] = React.useState<string[]>(chars);\n  const [resolvedMask, setResolvedMask] = React.useState<boolean[]>(() => chars.map(() => true));\n  const runIdRef = React.useRef(0);\n  const wrapperRef = React.useRef<HTMLSpanElement>(null);\n  const hasRunRef = React.useRef(false);\n\n  const run = React.useCallback(() => {\n    if (reducedMotion) {\n      runIdRef.current++; // cancel any scramble already in flight\n      setDisplay(chars);\n      setResolvedMask(chars.map(() => true));\n      return;\n    }\n    const n = chars.length;\n    if (n === 0) return;\n    const ranks = computeRanks(n, resolveOrder);\n    const runId = ++runIdRef.current;\n    const durationMs = Math.max(duration, 0.05) * 1000;\n    const delayMs = Math.max(delay, 0) * 1000;\n    const startTime = performance.now();\n\n    setResolvedMask(chars.map(() => false));\n\n    const tick = (now: number) => {\n      if (runIdRef.current !== runId) return;\n      const elapsed = now - startTime - delayMs;\n      if (elapsed < 0) {\n        requestAnimationFrame(tick);\n        return;\n      }\n      const progress = Math.min(1, elapsed / durationMs);\n      const resolvedCount = Math.floor(progress * n);\n      const nextDisplay = new Array<string>(n);\n      const nextMask = new Array<boolean>(n);\n      for (let i = 0; i < n; i++) {\n        const original = chars[i]!;\n        const isResolved = progress >= 1 || ranks[i]! < resolvedCount || /\\s/.test(original);\n        nextMask[i] = isResolved;\n        nextDisplay[i] = isResolved ? original : (charset[Math.floor(Math.random() * charset.length)] ?? original);\n      }\n      setDisplay(nextDisplay);\n      setResolvedMask(nextMask);\n      if (progress < 1) requestAnimationFrame(tick);\n    };\n\n    requestAnimationFrame(tick);\n  }, [chars, charset, duration, delay, resolveOrder, reducedMotion]);\n\n  // Stop any in-flight scramble loop on unmount.\n  React.useEffect(\n    () => () => {\n      runIdRef.current++;\n    },\n    []\n  );\n\n  // Reset display whenever the target text itself changes.\n  React.useEffect(() => {\n    runIdRef.current++; // a scramble of the old text must not overwrite the new one\n    setDisplay(chars);\n    setResolvedMask(chars.map(() => true));\n    hasRunRef.current = false;\n  }, [chars]);\n\n  React.useEffect(() => {\n    if (trigger !== \"mount\") return;\n    run();\n  }, [trigger, run]);\n\n  React.useEffect(() => {\n    if (trigger !== \"view\") return;\n    const node = wrapperRef.current;\n    if (!node || typeof IntersectionObserver === \"undefined\") return;\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (entry?.isIntersecting && (!hasRunRef.current || repeat)) {\n          hasRunRef.current = true;\n          run();\n        }\n      },\n      { threshold: 0.6 }\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [trigger, repeat, run]);\n\n  const handleMouseEnter = trigger === \"hover\" ? () => run() : undefined;\n\n  if (reducedMotion) {\n    return (\n      <span\n        ref={wrapperRef}\n        aria-label={children}\n        className={cn(\"inline-block\", className)}\n        style={style}\n        {...props}\n      >\n        <span aria-hidden>{children}</span>\n      </span>\n    );\n  }\n\n  return (\n    <span\n      ref={wrapperRef}\n      aria-label={children}\n      onMouseEnter={handleMouseEnter}\n      className={cn(\"inline-block\", className)}\n      style={\n        {\n          \"--cipher-scramble-color\": scrambleColor,\n          \"--cipher-flash-color\": flashColor,\n          ...style,\n        } as React.CSSProperties\n      }\n      {...props}\n    >\n      <style>{`\n        @keyframes crucible-cipher-quench {\n          0% { color: var(--cipher-flash-color); text-shadow: 0 0 8px var(--cipher-flash-color); }\n          100% { color: inherit; text-shadow: none; }\n        }\n        @keyframes crucible-cipher-jitter {\n          0%, 100% { transform: translateY(0); opacity: 0.9; }\n          50% { transform: translateY(-1px); opacity: 1; }\n        }\n        [data-crucible=\"cipher\"] .cipher-quench {\n          animation: crucible-cipher-quench 0.32s ease-out;\n        }\n        [data-crucible=\"cipher\"] .cipher-scramble {\n          color: var(--cipher-scramble-color);\n          animation: crucible-cipher-jitter 0.15s steps(2) infinite;\n        }\n        @media (prefers-reduced-motion: reduce) {\n          [data-crucible=\"cipher\"] .cipher-scramble { animation: none; }\n        }\n      `}</style>\n      <span data-crucible=\"cipher\" aria-hidden className=\"inline-block\">\n        {/* Group per-character spans by word so the line only breaks at spaces —\n            bare inline-block chars otherwise wrap mid-word (e.g. 'bet|ter'). The\n            global index i is preserved for the scramble animation. */}\n        {wordGroups.map((g, gi) =>\n          g.isSpace ? (\n            <span key={`g-${gi}`} className=\"whitespace-pre\">\n              {display.slice(g.start, g.end).join(\"\")}\n            </span>\n          ) : (\n            <span key={`g-${gi}`} className=\"inline-block whitespace-nowrap\">\n              {Array.from({ length: g.end - g.start }, (_, k) => {\n                const i = g.start + k;\n                return (\n                  <span\n                    key={resolvedMask[i] ? `r-${i}` : `s-${i}`}\n                    className={cn(\"inline-block whitespace-pre\", resolvedMask[i] ? \"cipher-quench\" : \"cipher-scramble\")}\n                  >\n                    {display[i]}\n                  </span>\n                );\n              })}\n            </span>\n          )\n        )}\n      </span>\n    </span>\n  );\n}\n"
    }
  ]
}