{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "decrypt-text",
  "title": "Decrypt Text",
  "description": "Text that starts unreadable and is decoded in place, on first view or behind a real button, with the sentence out of the accessibility tree until it opens.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/button.json",
    "https://miseui.junsu4621.workers.dev/r/scramble-text.json",
    "https://miseui.junsu4621.workers.dev/r/split-text.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/decrypt-text.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect, useLayoutEffect, useMemo, useRef, useState } from \"react\"\nimport { useInView, useReducedMotion } from \"motion/react\"\nimport { Button } from \"@/registry/miseui/ui/button\"\nimport { BEFORE_START, decryptPlan, type DecryptPlan, type DecryptTextVariant } from \"@/registry/miseui/ui/decrypt-math\"\nimport { DecryptVisual, createDecryptPainter } from \"@/registry/miseui/ui/decrypt-parts\"\nimport { useTimeline } from \"@/registry/miseui/ui/scramble-play\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype DecryptTextEffect = \"press\"\ntype DecryptTextTrigger = \"view\" | \"mount\"\ntype DecryptTextTag = \"h1\" | \"h2\" | \"h3\" | \"h4\" | \"p\" | \"span\" | \"div\"\n\nconst DEFAULT_SEED = 11\nconst VIEW_MARGIN = \"0px 0px -10% 0px\"\n/** The encrypted state is the point, so on its own it stays long enough to be seen as a state. */\nconst HOLD_MS = 320\n\ninterface PlaybackOptions {\n  visualRef: React.RefObject<HTMLSpanElement | null>\n  plan: DecryptPlan\n  variant: DecryptTextVariant\n  press: boolean\n  locked: boolean\n  due: boolean\n  reduceMotion: boolean\n}\n\nfunction useDecryptPlayback({ visualRef, plan, variant, press, locked, due, reduceMotion }: PlaybackOptions) {\n  const { play, stop } = useTimeline()\n  // A press text that opens already revealed has nothing to decode.\n  const wasLocked = useRef(locked)\n\n  useLayoutEffect(() => {\n    if (reduceMotion || !visualRef.current || (press && !locked && !wasLocked.current)) return\n    createDecryptPainter(visualRef.current, plan, variant)(BEFORE_START)\n  }, [visualRef, plan, variant, press, locked, reduceMotion])\n\n  useEffect(() => {\n    const pressedOpen = wasLocked.current && !locked\n    wasLocked.current = locked\n    if (reduceMotion || locked || !visualRef.current) return\n    if (press ? !pressedOpen : !due) return\n    const paint = createDecryptPainter(visualRef.current, plan, variant)\n    const hold = press ? 0 : HOLD_MS\n    play((elapsed) => paint(elapsed - hold))\n    return stop\n  }, [visualRef, plan, variant, press, locked, due, reduceMotion, play, stop])\n}\n\nfunction useRevealed(revealed: boolean | undefined, defaultRevealed: boolean, onChange?: (revealed: boolean) => void) {\n  const [inner, setInner] = useState(defaultRevealed)\n  const isRevealed = revealed ?? inner\n  const toggle = () => {\n    setInner(!isRevealed)\n    onChange?.(!isRevealed)\n  }\n  return [isRevealed, toggle] as const\n}\n\ntype DecryptTextProps = Omit<React.HTMLAttributes<HTMLElement>, \"children\"> & {\n  text: string\n  /** The element to render. A heading stays a heading. */\n  as?: DecryptTextTag\n  /** `dots` password dots replaced letter by letter, `redact` a bar over each word retracts in turn, `cipher` muted monospace letters lock one by one, `scan` a line crosses and decodes what it passes. */\n  variant?: DecryptTextVariant\n  /** `press` stays encrypted behind a real button and keeps the text from assistive tech until then. */\n  effect?: DecryptTextEffect\n  /** `view` plays once when first seen, `mount` plays right away. Ignored by `press`. */\n  trigger?: DecryptTextTrigger\n  /** The same seed gives the same cipher. */\n  seed?: number\n  revealed?: boolean\n  defaultRevealed?: boolean\n  onRevealedChange?: (revealed: boolean) => void\n  /** What the button says while encrypted. */\n  label?: string\n  /** What the button says once decoded. */\n  hideLabel?: string\n}\n\nfunction DecryptText({\n  text,\n  as: Tag = \"span\",\n  variant = \"dots\",\n  effect,\n  trigger = \"view\",\n  seed = DEFAULT_SEED,\n  revealed,\n  defaultRevealed = false,\n  onRevealedChange,\n  label = \"Decrypt\",\n  hideLabel = \"Hide\",\n  className,\n  ...props\n}: DecryptTextProps) {\n  const press = effect === \"press\"\n  const reduceMotion = useReducedMotion() ?? false\n  const visualRef = useRef<HTMLSpanElement>(null)\n  const seen = useInView(visualRef, { once: true, margin: VIEW_MARGIN })\n  const [isRevealed, toggle] = useRevealed(revealed, defaultRevealed, onRevealedChange)\n  const locked = press && !isRevealed\n  const plan = useMemo(() => decryptPlan(text, variant, seed), [text, variant, seed])\n  useDecryptPlayback({ visualRef, plan, variant, press, locked, due: trigger === \"mount\" || seen, reduceMotion })\n\n  const root = { \"data-slot\": \"decrypt-text\", \"data-variant\": variant, \"data-effect\": effect, \"data-revealed\": press ? String(isRevealed) : undefined }\n  const faceClass = variant === \"cipher\" ? \"font-mono\" : undefined\n  const visual = <DecryptVisual runKey={`${variant}:${seed}:${reduceMotion}:${text}`} plan={plan} variant={variant} locked={locked} visualRef={visualRef} />\n\n  if (!press) {\n    return (\n      <Tag {...props} {...root} className={cn(\"relative\", faceClass, className)}>\n        {/* Assistive tech gets the sentence once, never the cipher. */}\n        <span className=\"sr-only select-none\">{text}</span>\n        {visual}\n      </Tag>\n    )\n  }\n  return (\n    <div {...props} {...root} className={cn(\"grid grid-cols-[minmax(0,1fr)] gap-2\", className)}>\n      <Tag className={cn(\"relative\", faceClass)}>\n        {/* Empty until pressed, then read once. The live region itself never moves: the letters on screen are hidden from it. */}\n        <span className=\"sr-only select-none\" aria-live=\"polite\">\n          {isRevealed ? text : \"\"}\n        </span>\n        {visual}\n      </Tag>\n      {/* The same button stays and only its label changes, so keyboard focus has nowhere to get lost. */}\n      <Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"justify-self-start font-sans pointer-coarse:h-10\" onClick={toggle}>\n        {isRevealed ? hideLabel : label}\n      </Button>\n    </div>\n  )\n}\n\nexport { DecryptText, type DecryptTextProps, type DecryptTextVariant, type DecryptTextEffect, type DecryptTextTrigger }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/decrypt-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { Fragment } from \"react\"\nimport { barScale, markOpacity, scanFractions, scanProgress, type DecryptCell, type DecryptPlan, type DecryptTextVariant, type DecryptWord } from \"@/registry/miseui/ui/decrypt-math\"\nimport { GlyphCell, WordRow, glyphCells, paintGlyph, type FramePainter } from \"@/registry/miseui/ui/scramble-play\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\nconst CIPHER_LAYER = \"text-muted-foreground\"\n\nfunction RedactWord({ word, locked }: { word: DecryptWord; locked: boolean }) {\n  return (\n    <span data-slot=\"decrypt-text-word\" className=\"relative inline-block whitespace-nowrap\">\n      <span className={locked ? \"invisible\" : undefined}>{word.text}</span>\n      {/* Retracts toward the end of the word, so the letters appear in reading order. */}\n      <span\n        data-slot=\"decrypt-text-bar\"\n        className=\"pointer-events-none absolute -inset-x-[0.04em] inset-y-[0.12em] origin-right rounded-[2px] bg-foreground\"\n        style={{ transform: `scaleX(${locked ? 1 : 0})` }}\n      />\n    </span>\n  )\n}\n\nfunction CipherCell({ cell, variant, locked }: { cell: DecryptCell; variant: DecryptTextVariant; locked: boolean }) {\n  return (\n    <GlyphCell target={cell.target} before={locked ? cell.before : undefined} layerClassName={CIPHER_LAYER}>\n      {variant === \"cipher\" ? (\n        <span data-slot=\"decrypt-text-mark\" className=\"pointer-events-none absolute inset-x-0 bottom-0 h-0.5 bg-mise\" style={{ opacity: 0 }} />\n      ) : null}\n    </GlyphCell>\n  )\n}\n\ninterface DecryptVisualProps {\n  plan: DecryptPlan\n  variant: DecryptTextVariant\n  locked: boolean\n  visualRef: React.Ref<HTMLSpanElement>\n  /** Changing it drops a half-played frame. Inside the observed element, because useInView keeps watching the node it first got. */\n  runKey: string\n}\n\nfunction DecryptVisual({ plan, variant, locked, visualRef, runKey }: DecryptVisualProps) {\n  return (\n    <span\n      ref={visualRef}\n      aria-hidden=\"true\"\n      data-slot=\"decrypt-text-visual\"\n      className={cn(\"relative\", variant === \"scan\" && \"inline-block max-w-full\", locked && \"select-none\")}\n    >\n      <Fragment key={runKey}>\n        {plan.words.map((word, position) =>\n          variant === \"redact\" ? (\n            <span key={word.key}>\n              <RedactWord word={word} locked={locked} />\n              {position < plan.words.length - 1 ? \" \" : null}\n            </span>\n          ) : (\n            <WordRow key={word.key} last={position === plan.words.length - 1}>\n              {word.cells.map((cell) => (\n                <CipherCell key={cell.index} cell={cell} variant={variant} locked={locked} />\n              ))}\n            </WordRow>\n          ),\n        )}\n        {variant === \"scan\" ? (\n          <span data-slot=\"decrypt-text-line\" className=\"pointer-events-none absolute inset-y-0 left-0 w-px bg-mise\" style={{ opacity: 0 }} />\n        ) : null}\n      </Fragment>\n    </span>\n  )\n}\n\nfunction redactPainter(visual: HTMLElement, plan: DecryptPlan): FramePainter {\n  const nodes = Array.from(visual.querySelectorAll<HTMLElement>(\"[data-slot='decrypt-text-word']\"))\n  return (elapsed) => {\n    plan.words.forEach((word, position) => {\n      const scale = barScale(word.startAt, elapsed)\n      const [text, bar] = Array.from(nodes[position].children) as HTMLElement[]\n      bar.style.transform = `scaleX(${scale})`\n      text.style.visibility = scale >= 1 ? \"hidden\" : \"visible\"\n    })\n    return elapsed >= plan.durationMs\n  }\n}\n\nfunction letterPainter(visual: HTMLElement, plan: DecryptPlan): FramePainter {\n  const nodes = glyphCells(visual)\n  const cells = plan.words.flatMap((word) => word.cells)\n  const marks = Array.from(visual.querySelectorAll<HTMLElement>(\"[data-slot='decrypt-text-mark']\"))\n  return (elapsed) => {\n    cells.forEach((cell, position) => {\n      paintGlyph(nodes[position], elapsed >= cell.settleAt ? null : cell.before)\n      if (marks[position]) marks[position].style.opacity = String(markOpacity(cell.settleAt, elapsed))\n    })\n    return elapsed >= plan.durationMs\n  }\n}\n\n/** Reads the layout once per run, so the frames themselves never measure. */\nfunction scanPainter(visual: HTMLElement, plan: DecryptPlan): FramePainter {\n  const nodes = glyphCells(visual)\n  const cells = plan.words.flatMap((word) => word.cells)\n  const line = visual.querySelector<HTMLElement>(\"[data-slot='decrypt-text-line']\")\n  const block = visual.getBoundingClientRect()\n  const fractions = scanFractions(nodes.map((node) => node.getBoundingClientRect()), block.left, block.width)\n  return (elapsed) => {\n    const progress = scanProgress(elapsed)\n    cells.forEach((cell, position) => paintGlyph(nodes[position], elapsed >= 0 && fractions[position] <= progress ? null : cell.before))\n    const finished = elapsed >= plan.durationMs\n    if (line) {\n      line.style.transform = `translateX(${progress * block.width}px)`\n      line.style.opacity = finished || elapsed < 0 ? \"0\" : \"1\"\n    }\n    return finished\n  }\n}\n\nfunction createDecryptPainter(visual: HTMLElement, plan: DecryptPlan, variant: DecryptTextVariant): FramePainter {\n  if (variant === \"redact\") return redactPainter(visual, plan)\n  if (variant === \"scan\") return scanPainter(visual, plan)\n  return letterPainter(visual, plan)\n}\n\nexport { DecryptVisual, createDecryptPainter }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/decrypt-math.ts",
      "content": "import { noiseGlyph } from \"@/registry/miseui/ui/scramble-math\"\nimport { splitText } from \"@/registry/miseui/ui/split-text-math\"\n\ntype DecryptTextVariant = \"dots\" | \"redact\" | \"cipher\" | \"scan\"\n\nconst DOT = \"•\"\nconst LETTER_STEP_MS: Record<\"dots\" | \"cipher\", number> = { dots: 38, cipher: 46 }\n/** A long sentence must not still be decoding when someone starts reading it. */\nconst MAX_WAVE_MS = 900\nconst WORD_STEP_MS = 170\nconst MAX_WORD_WAVE_MS = 800\nconst RETRACT_MS = 280\nconst SCAN_MS = 900\nconst MARK_MS = 200\n/** Stiffness that lands within 0.3% of the end at progress 1. */\nconst SPRING_RATE = 8\n\n/** A paint at a negative time is the encrypted state. */\nconst BEFORE_START = -1\n\ninterface DecryptCell {\n  target: string\n  index: number\n  /** What sits in the cell until it is decoded. */\n  before: string\n  settleAt: number\n}\n\ninterface DecryptWord {\n  key: string\n  text: string\n  /** When its bar begins to retract. */\n  startAt: number\n  cells: readonly DecryptCell[]\n}\n\ninterface DecryptPlan {\n  words: readonly DecryptWord[]\n  durationMs: number\n}\n\nfunction waveStep(stepMs: number, maxMs: number, count: number) {\n  return count > 1 ? Math.min(stepMs, maxMs / (count - 1)) : 0\n}\n\n/** Redact goes by word and scan by position on screen, so only the two letter-by-letter variants have a time per letter. */\nfunction letterSettle(variant: DecryptTextVariant, index: number, count: number) {\n  if (variant !== \"dots\" && variant !== \"cipher\") return 0\n  return Math.round(index * waveStep(LETTER_STEP_MS[variant], MAX_WAVE_MS, count))\n}\n\nfunction planDuration(variant: DecryptTextVariant, words: readonly DecryptWord[]) {\n  const cells = words.flatMap((word) => word.cells)\n  if (variant === \"redact\") return (words.at(-1)?.startAt ?? 0) + RETRACT_MS\n  if (variant === \"scan\") return SCAN_MS\n  return (cells.at(-1)?.settleAt ?? 0) + (variant === \"cipher\" ? MARK_MS : 0)\n}\n\nfunction decryptPlan(text: string, variant: DecryptTextVariant, seed: number): DecryptPlan {\n  const split = splitText(text, \"chars\")\n  const count = split.reduce((sum, word) => sum + word.pieces.length, 0)\n  const wordStep = waveStep(WORD_STEP_MS, MAX_WORD_WAVE_MS, split.length)\n  const words = split.map((word, position) => ({\n    key: word.key,\n    text: word.pieces.map((piece) => piece.text).join(\"\"),\n    startAt: Math.round(position * wordStep),\n    cells: word.pieces.map((piece) => ({\n      target: piece.text,\n      index: piece.index,\n      before: variant === \"dots\" ? DOT : noiseGlyph(piece.text, { set: \"letters\", seed, index: piece.index, tick: 0 }),\n      settleAt: letterSettle(variant, piece.index, count),\n    })),\n  }))\n  return { words, durationMs: planDuration(variant, words) }\n}\n\n/** Closed form of a critically damped spring, the same no-overshoot settle as SPRING_DEFAULT, for a value painted by hand. */\nfunction springProgress(progress: number) {\n  if (progress <= 0) return 0\n  if (progress >= 1) return 1\n  return 1 - (1 + SPRING_RATE * progress) * Math.exp(-SPRING_RATE * progress)\n}\n\n/** 1 is a whole bar, 0 is gone. */\nfunction barScale(startAt: number, elapsed: number) {\n  return 1 - springProgress((elapsed - startAt) / RETRACT_MS)\n}\n\n/** Full at the moment a letter locks, then gone. */\nfunction markOpacity(settleAt: number, elapsed: number) {\n  if (elapsed < settleAt || elapsed >= settleAt + MARK_MS) return 0\n  return 1 - (elapsed - settleAt) / MARK_MS\n}\n\n/** Linear on purpose: a scanner moves at one speed, and easing would bunch up the letters at the ends. */\nfunction scanProgress(elapsed: number) {\n  return Math.min(1, Math.max(0, elapsed / SCAN_MS))\n}\n\n/** Where each cell sits across the block, 0 to 1. Wrapped lines all scan together. Falls back to reading order with no layout. */\nfunction scanFractions(rects: readonly { left: number; width: number }[], blockLeft: number, blockWidth: number) {\n  if (blockWidth <= 0) return rects.map((_, index) => (rects.length > 1 ? index / (rects.length - 1) : 0))\n  return rects.map((rect) => Math.min(1, Math.max(0, (rect.left + rect.width / 2 - blockLeft) / blockWidth)))\n}\n\nexport {\n  BEFORE_START,\n  DOT,\n  MARK_MS,\n  barScale,\n  decryptPlan,\n  markOpacity,\n  scanFractions,\n  scanProgress,\n  springProgress,\n  type DecryptCell,\n  type DecryptPlan,\n  type DecryptTextVariant,\n  type DecryptWord,\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "type": "registry:ui"
}