{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "handwritten-response",
  "type": "registry:ui",
  "title": "Handwritten Response",
  "description": "An AI answer written out in marker pen: Caveat handwriting, a highlighter swipe, a hand-drawn circle and a crossed-out correction, with each word inking in as it streams.",
  "files": [
    {
      "path": "registry/crafterui/ui/handwritten-response.tsx",
      "type": "registry:ui",
      "content": "\"use client\"\n\n// An answer in marker pen: handwriting, a highlighter, and annotations drawn\n// over the words rather than typeset around them.\n//\n// Caveat on a 1.36 line, pure black ink,\n// and a #f5e1a8 highlighter that sits low on the word and runs a little past\n// both ends. The circle and the strikethrough are SVG - a CSS underline or a\n// border-radius box reads as a shape, and the whole point is that they read as\n// something drawn by hand a moment ago.\n//\n// Markup the text carries:\n//   ==marked==     highlighter swipe\n//   ((circled))    ring drawn round it\n//   ~~struck~~     crossed out\n//\n// Nothing fades in. Every word is wiped on left to right at a steady nib speed,\n// the highlighter travels under the words as they are written, and the ring and\n// the strike are stroked on straight after the words they cover - so a streamed\n// answer reads as one pen working its way down the page.\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface HandwrittenResponseProps {\n  /** The answer. Understands ==mark==, ((circle)) and ~~strike~~. */\n  children: string\n  /** Seconds the pen takes to write an average (five-letter) word. @default 0.18 */\n  duration?: number\n  /** Skip the writing and show everything at once. @default false */\n  instant?: boolean\n  /** Extra classes for the block. @default undefined */\n  className?: string\n}\n\n// The highlighter amber. The band is deliberately shorter than the cap height\n// (see Swipe), so ascenders always sit on the page and never on the swipe -\n// which means the ink has to stay the theme foreground and the BAND has to\n// adapt instead. Thinned in dark mode: amber over near-black still reads as a\n// marker and carries white ink at ~6:1.\nconst HIGHLIGHT = \"bg-[#f5e1a8] dark:bg-[#f5e1a8]/40\"\n\n// Pen time is charged per character rather than per word, so the nib moves at\n// one steady speed instead of racing through \"extraordinarily\" and crawling\n// through \"a\" - the single thing that separates writing from stuff appearing.\nconst AVG_WORD = 5 // characters `duration` is quoted against\nconst OVERLAY_CHARS = 7 // pen time a ring or a strike costs, in characters\nconst MAX_SPREAD = 6 // seconds one arriving batch may take, however long it is\n\nconst SPACE = /^\\s+$/\n\ntype Mark = \"plain\" | \"mark\" | \"circle\" | \"strike\"\ntype Token = { text: string; mark: Mark }\n\n// One pass, so the marks cannot nest and cannot be mistaken for each other.\nconst SYNTAX = /==(.+?)==|\\(\\((.+?)\\)\\)|~~(.+?)~~/g\n\n// Every complete pair is consumed by SYNTAX, so anything of this shape left in a\n// plain run is an opener whose closer has not streamed in yet. Showing it would\n// put a literal \"==\" on the page mid-answer; the word underneath is shown plain\n// instead and picks up its mark when the pair completes.\nconst DANGLING = /==|\\(\\(|~~|[=(~]$/g\n\nfunction plain(text: string): Token {\n  return { text: text.replace(DANGLING, \"\"), mark: \"plain\" }\n}\n\nfunction parse(src: string): Token[] {\n  const out: Token[] = []\n  let last = 0\n  for (const m of src.matchAll(SYNTAX)) {\n    if (m.index > last) out.push(plain(src.slice(last, m.index)))\n    const mark: Mark = m[1] !== undefined ? \"mark\" : m[2] !== undefined ? \"circle\" : \"strike\"\n    out.push({ text: (m[1] ?? m[2] ?? m[3]) as string, mark })\n    last = m.index + m[0].length\n  }\n  if (last < src.length) out.push(plain(src.slice(last)))\n  return out.filter((t) => t.text.length > 0)\n}\n\n// When the overlay is being drawn, and for how long. Both undefined means the\n// words were already on the page, so the overlay is simply there.\ntype Drawn = { delay?: number; dur?: number }\n\n// pathLength normalises the dash units to 1, so the sweep needs no measuring of\n// the path and no ref. `offset` and `scale` are fractions of `dur`, for the\n// second strike pass that trails the first.\nfunction stroke(\n  { delay, dur }: Drawn,\n  offset = 0,\n  scale = 1\n): React.CSSProperties | undefined {\n  if (delay === undefined || dur === undefined) return undefined\n  return {\n    animation: `crafterui-draw ${dur * scale}s ease-out both`,\n    animationDelay: `${delay + dur * offset}s`,\n  }\n}\n\n/* Hand-drawn overlays. Both stretch to whatever they wrap: the viewBox is\n   normalised and preserveAspectRatio is off, while non-scaling-stroke keeps the\n   pen the same weight however far the box is pulled. */\nfunction Ring(drawn: Drawn) {\n  return (\n    <svg\n      className=\"pointer-events-none absolute top-[0.04em] left-[-0.26em] h-[calc(100%-0.08em)] w-[calc(100%+0.52em)] overflow-visible\"\n      viewBox=\"0 0 100 100\"\n      preserveAspectRatio=\"none\"\n      aria-hidden=\"true\"\n    >\n      <path\n        // Not a perfect ellipse - it starts low-left, comes round, and overshoots\n        // the closure the way a real pen does.\n        d=\"M8 54 C6 26 30 8 52 7 C76 6 95 20 95 46 C95 74 72 93 48 93 C24 93 7 78 6 52 C6 34 16 20 34 12\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={2}\n        strokeLinecap=\"round\"\n        vectorEffect=\"non-scaling-stroke\"\n        data-draw=\"\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={stroke(drawn)}\n      />\n    </svg>\n  )\n}\n\nfunction Strike(drawn: Drawn) {\n  return (\n    <svg\n      // w-full, not left-0 right-0: an <svg> is a replaced element, so when it\n      // is absolutely positioned with width:auto the browser takes its intrinsic\n      // width and ignores `right` - the line then runs on past the word.\n      className=\"pointer-events-none absolute bottom-[0.30em] left-0 h-[0.34em] w-full overflow-visible\"\n      viewBox=\"0 0 100 10\"\n      preserveAspectRatio=\"none\"\n      aria-hidden=\"true\"\n    >\n      {/* Two passes at slightly different heights - one stroke looks typeset.\n          The second trails the first, because two in lockstep look printed. */}\n      <path\n        d=\"M1 4 C24 2 46 7 68 4 C82 2 92 6 99 4\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.6}\n        strokeLinecap=\"round\"\n        vectorEffect=\"non-scaling-stroke\"\n        data-draw=\"\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={stroke(drawn, 0, 0.7)}\n      />\n      <path\n        d=\"M2 7 C26 5 44 9 66 6 C80 4 92 8 98 6\"\n        fill=\"none\"\n        stroke=\"currentColor\"\n        strokeWidth={1.3}\n        strokeLinecap=\"round\"\n        opacity={0.8}\n        vectorEffect=\"non-scaling-stroke\"\n        data-draw=\"\"\n        pathLength={1}\n        strokeDasharray={1}\n        style={stroke(drawn, 0.3, 0.7)}\n      />\n    </svg>\n  )\n}\n\n/* The highlighter. A rounded band sized in em and anchored above the baseline,\n   because a padded background box grows to the whole line and reads as a label\n   rather than as one swipe of a marker. It takes the same left-to-right wipe as\n   the words, over the same window, so the amber is always already under the ink\n   - drawing it afterwards would show the words before their marker. */\nfunction Swipe({ delay, dur }: Drawn) {\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-ink=\"\"\n      style={{\n        ...(dur === undefined\n          ? null\n          : {\n              animation: `crafterui-write ${dur}s linear both`,\n              animationDelay: `${delay}s`,\n            }),\n      }}\n      className={cn(\n        \"absolute right-[-0.24em] bottom-[0.12em] left-[-0.24em] h-[0.6em] rounded-full\",\n        HIGHLIGHT\n      )}\n    />\n  )\n}\n\nexport function HandwrittenResponse({\n  children,\n  duration = 0.18,\n  instant = false,\n  className,\n}: HandwrittenResponseProps) {\n  const { tokens, total } = React.useMemo(() => {\n    const tokens = parse(children ?? \"\").map((tk) => ({\n      ...tk,\n      parts: tk.text.split(/(\\s+)/).filter(Boolean),\n    }))\n    const total = tokens.reduce(\n      (n, tk) => n + tk.parts.filter((p) => !SPACE.test(p)).length,\n      0\n    )\n    return { tokens, total }\n  }, [children])\n\n  // How many words were already on the page at the last commit. Two things\n  // depend on it, and both were wrong before:\n  //\n  //   A word animates only the first time it appears. Closing a ==pair== renests\n  //   the words it wraps, which remounts them - without this they would flash a\n  //   second time, after they had already settled.\n  //\n  //   Its delay is measured from the batch it arrived in, never from its index\n  //   in the answer. Index-based delay compounds: the 80th word ends up waiting\n  //   seconds, and because the animation fills backwards it holds its layout at\n  //   zero opacity the whole time. The text finishes arriving and then keeps\n  //   surfacing long after the stream has stopped.\n  const revealed = React.useRef(0)\n  const start = revealed.current\n\n  React.useEffect(() => {\n    revealed.current = total\n  }, [total])\n\n  // Cost the arriving batch before rendering it: the nib runs at `duration` per\n  // average word unless that would drag the batch past MAX_SPREAD, in which case\n  // everything is compressed evenly rather than the tail being cut off.\n  let pending = 0\n  for (let w = 0, i = 0; i < tokens.length; i++) {\n    const tk = tokens[i]\n    let fresh = false\n    for (const part of tk.parts) {\n      const space = SPACE.test(part)\n      if (w >= start) {\n        pending += part.length\n        if (!space) fresh = true\n      }\n      if (!space) w++\n    }\n    // A ring or a strike is a separate stroke of the pen; the swipe rides along\n    // with the words and so costs nothing extra.\n    if (fresh && (tk.mark === \"circle\" || tk.mark === \"strike\")) {\n      pending += OVERLAY_CHARS\n    }\n  }\n  const perChar = Math.min(duration / AVG_WORD, MAX_SPREAD / Math.max(1, pending))\n\n  let word = 0\n  let cursor = 0 // characters of pen time already spent in this batch\n\n  return (\n    <div\n      className={cn(\n        \"font-[Caveat] text-[1.75rem] leading-[2.375rem] text-foreground\",\n        className\n      )}\n    >\n      <style>\n        {`@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@500&display=swap');\n          /* The nib, not a fade. inset() is (top right bottom left) and each\n             value cuts in from its own edge, so writing left to right means\n             opening the RIGHT inset - driving the left one would uncover the end\n             of the word first. The other three stay negative so Caveat's tails\n             and ascenders are never squared off by the box, and the closed state\n             overshoots 100% by that same overhang so no sliver of the glyph is\n             left showing before the word is written. */\n          @keyframes crafterui-write {\n            from { clip-path: inset(-0.4em calc(100% + 0.25em) -0.4em -0.25em) }\n            to   { clip-path: inset(-0.4em -0.25em -0.4em -0.25em) }\n          }\n          @keyframes crafterui-draw { from { stroke-dashoffset: 1 } to { stroke-dashoffset: 0 } }\n          @media (prefers-reduced-motion: reduce) {\n            [data-ink], [data-draw] {\n              animation: none !important;\n              clip-path: none !important;\n              stroke-dashoffset: 0 !important;\n            }\n          }`}\n      </style>\n\n      {tokens.map((token, t) => {\n        const from = cursor\n        let animated = false\n\n        const inked = token.parts.map((part, i) => {\n          if (SPACE.test(part)) {\n            // The gap between words is pen travel, so it is charged for too.\n            if (!instant && word >= start) cursor += part.length\n            return <React.Fragment key={`s${t}-${i}`}>{part}</React.Fragment>\n          }\n\n          const index = word++\n          const fresh = !instant && index >= start\n          const style = fresh\n            ? {\n                animation: `crafterui-write ${part.length * perChar}s linear both`,\n                animationDelay: `${cursor * perChar}s`,\n              }\n            : undefined\n\n          if (fresh) {\n            animated = true\n            cursor += part.length\n          }\n\n          return (\n            <span\n              key={index}\n              data-ink=\"\"\n              className=\"inline-block whitespace-pre\"\n              style={style}\n            >\n              {part}\n            </span>\n          )\n        })\n\n        if (token.mark === \"plain\")\n          return <React.Fragment key={t}>{inked}</React.Fragment>\n\n        // The swipe runs with the words; the ring and the strike are drawn once\n        // the words are down, and take pen time of their own so the next word\n        // waits for the marker instead of racing it.\n        const swipe: Drawn = animated\n          ? { delay: from * perChar, dur: Math.max(cursor - from, 1) * perChar }\n          : {}\n        const after: Drawn = animated\n          ? { delay: cursor * perChar, dur: OVERLAY_CHARS * perChar }\n          : {}\n        if (animated && token.mark !== \"mark\") cursor += OVERLAY_CHARS\n\n        return (\n          <span key={t} className=\"relative inline-block\">\n            {token.mark === \"mark\" ? <Swipe {...swipe} /> : null}\n            <span className=\"relative\">{inked}</span>\n            {token.mark === \"circle\" ? <Ring {...after} /> : null}\n            {token.mark === \"strike\" ? <Strike {...after} /> : null}\n          </span>\n        )\n      })}\n    </div>\n  )\n}\n"
    }
  ]
}
