{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "annotated-text",
  "type": "registry:ui",
  "title": "Annotated Text",
  "description": "Component for annotated-text",
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "components/ui/annotated-text.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useEffect, useRef, type ReactNode } from \"react\";\n\n/**\n * Every mark is generated rather than hand-drawn: a seeded random walk pushes\n * each sampled point off its ideal line, which is what reads as \"drawn by\n * hand\". The seed is fixed per mark, so the geometry is computed once at module\n * load and is identical on the server and in the browser.\n */\n\ntype Point = { x: number; y: number };\n\nfunction createRandom(seed: number) {\n  let state = seed >>> 0;\n  return function next() {\n    state = (state * 1664525 + 1013904223) >>> 0;\n    return state / 4294967296;\n  };\n}\n\n/** A signed nudge of at most `amount`. */\nfunction shake(rand: () => number, amount: number) {\n  return (rand() - 0.5) * 2 * amount;\n}\n\nfunction round(value: number) {\n  return Math.round(value * 100) / 100;\n}\n\n/** Curve through the sampled points by anchoring each one and passing through the midpoints. */\nfunction trace(points: Point[], close = false) {\n  const [first, ...rest] = points;\n  if (!first) return \"\";\n\n  let d = `M${round(first.x)},${round(first.y)}`;\n\n  rest.forEach((anchor, index) => {\n    const next = rest[index + 1];\n    if (!next) {\n      d += ` L${round(anchor.x)},${round(anchor.y)}`;\n      return;\n    }\n    d += ` Q${round(anchor.x)},${round(anchor.y)} ${round(\n      (anchor.x + next.x) / 2,\n    )},${round((anchor.y + next.y) / 2)}`;\n  });\n\n  return close ? `${d} Z` : d;\n}\n\n/** A nearly straight stroke that bows by `sag` and trembles by `wobble`. */\nfunction stroke(\n  from: Point,\n  to: Point,\n  options: { sag?: number; wobble?: number; steps?: number },\n  rand: () => number,\n) {\n  const { sag = 0, wobble = 0.55, steps = 12 } = options;\n  const points: Point[] = [];\n\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps;\n    // Leave the endpoints exactly where they were asked for, so marks butt up\n    // against the edges of their box predictably.\n    const loose = i === 0 || i === steps ? 0 : 1;\n    points.push({\n      x: from.x + (to.x - from.x) * t + shake(rand, wobble) * loose,\n      y:\n        from.y +\n        (to.y - from.y) * t +\n        Math.sin(t * Math.PI) * sag +\n        shake(rand, wobble) * loose,\n    });\n  }\n\n  return points;\n}\n\n/** A horizontal sine, sampled four times per cycle. */\nfunction wave(\n  from: Point,\n  to: Point,\n  options: { amplitude: number; cycles: number; wobble?: number },\n  rand: () => number,\n) {\n  const { amplitude, cycles, wobble = 0.35 } = options;\n  const steps = Math.round(cycles * 4);\n  const points: Point[] = [];\n\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps;\n    points.push({\n      x: from.x + (to.x - from.x) * t,\n      y:\n        from.y +\n        (to.y - from.y) * t +\n        Math.sin(t * cycles * Math.PI * 2) * amplitude +\n        shake(rand, wobble),\n    });\n  }\n\n  return points;\n}\n\n/** An elliptical sweep whose radius drifts as it goes around. */\nfunction arc(\n  center: Point,\n  radii: { rx: number; ry: number },\n  sweep: { from: number; to: number },\n  options: { wobble?: number; steps?: number },\n  rand: () => number,\n) {\n  const { wobble = 0.03, steps = 28 } = options;\n  const points: Point[] = [];\n\n  for (let i = 0; i <= steps; i++) {\n    const t = i / steps;\n    const angle = ((sweep.from + (sweep.to - sweep.from) * t) * Math.PI) / 180;\n    const drift = 1 + shake(rand, wobble);\n    points.push({\n      x: center.x + Math.cos(angle) * radii.rx * drift,\n      y: center.y + Math.sin(angle) * radii.ry * drift,\n    });\n  }\n\n  return points;\n}\n\ntype Stroke = {\n  d: string;\n  width?: number;\n  opacity?: number;\n  fill?: boolean;\n};\n\nfunction wavyStrokes(): Stroke[] {\n  const rand = createRandom(17);\n  return [\n    {\n      d: trace(\n        wave(\n          { x: 2, y: 7 },\n          { x: 138, y: 7 },\n          { amplitude: 3, cycles: 9 },\n          rand,\n        ),\n      ),\n      width: 2.2,\n    },\n  ];\n}\n\nfunction underlineStrokes(): Stroke[] {\n  const rand = createRandom(23);\n  return [\n    { d: trace(stroke({ x: 3, y: 6 }, { x: 137, y: 5 }, { sag: -2 }, rand)) },\n  ];\n}\n\nfunction doubleUnderlineStrokes(): Stroke[] {\n  const rand = createRandom(41);\n  return [\n    { d: trace(stroke({ x: 3, y: 5 }, { x: 137, y: 4 }, { sag: -2 }, rand)) },\n    {\n      d: trace(stroke({ x: 5, y: 12 }, { x: 135, y: 11 }, { sag: -1.2 }, rand)),\n      width: 1.8,\n      opacity: 0.75,\n    },\n  ];\n}\n\nfunction strikethroughStrokes(): Stroke[] {\n  const rand = createRandom(59);\n  return [\n    { d: trace(stroke({ x: 3, y: 5 }, { x: 137, y: 5 }, { sag: 1.2 }, rand)) },\n  ];\n}\n\nfunction crossOutStrokes(): Stroke[] {\n  const rand = createRandom(73);\n  return [\n    {\n      d: trace(\n        stroke({ x: 4, y: 32 }, { x: 136, y: 8 }, { sag: -4, wobble: 1 }, rand),\n      ),\n    },\n    {\n      d: trace(\n        stroke({ x: 6, y: 10 }, { x: 134, y: 30 }, { sag: 4, wobble: 1 }, rand),\n      ),\n      width: 2,\n      opacity: 0.7,\n    },\n  ];\n}\n\nfunction arrowStrokes(): Stroke[] {\n  const rand = createRandom(89);\n  return [\n    { d: trace(stroke({ x: 3, y: 7 }, { x: 140, y: 8 }, { sag: -2.5 }, rand)) },\n    {\n      d: trace([\n        { x: 132, y: 3 },\n        { x: 142, y: 8 },\n        { x: 131, y: 13 },\n      ]),\n    },\n  ];\n}\n\nfunction circleStrokes(): Stroke[] {\n  const rand = createRandom(101);\n  return [\n    {\n      d: trace(\n        arc(\n          { x: 110, y: 32 },\n          { rx: 100, ry: 27 },\n          { from: 150, to: 520 },\n          { wobble: 0.035 },\n          rand,\n        ),\n      ),\n      width: 3,\n    },\n    {\n      d: trace(\n        arc(\n          { x: 110, y: 32 },\n          { rx: 95, ry: 24 },\n          { from: 165, to: 480 },\n          { wobble: 0.03 },\n          rand,\n        ),\n      ),\n      width: 1.5,\n      opacity: 0.55,\n    },\n  ];\n}\n\nfunction boxStrokes(): Stroke[] {\n  const rand = createRandom(127);\n  const topLeft = { x: 12, y: 10 };\n  const topRight = { x: 188, y: 10 };\n  const bottomRight = { x: 188, y: 54 };\n  const bottomLeft = { x: 12, y: 54 };\n\n  // The long edges bow outward; the short ones just tremble.\n  const outline = [\n    ...stroke(topLeft, topRight, { sag: -3, steps: 8 }, rand),\n    ...stroke(topRight, bottomRight, { steps: 4 }, rand),\n    ...stroke(bottomRight, bottomLeft, { sag: 3, steps: 8 }, rand),\n    ...stroke(bottomLeft, topLeft, { steps: 4 }, rand),\n  ];\n\n  return [{ d: trace(outline, true), width: 2.6 }];\n}\n\nfunction bracketStrokes(): Stroke[] {\n  const rand = createRandom(149);\n  return [\n    {\n      d: trace(\n        arc(\n          { x: 30, y: 30 },\n          { rx: 24, ry: 34 },\n          { from: 130, to: 230 },\n          { steps: 14 },\n          rand,\n        ),\n      ),\n      width: 2.6,\n    },\n    {\n      d: trace(\n        arc(\n          { x: 130, y: 30 },\n          { rx: 24, ry: 34 },\n          { from: 50, to: -50 },\n          { steps: 14 },\n          rand,\n        ),\n      ),\n      width: 2.6,\n    },\n  ];\n}\n\nfunction highlightStrokes(): Stroke[] {\n  const rand = createRandom(163);\n  // A closed blob: along the top, down the right end, back along the bottom.\n  const outline = [\n    ...stroke({ x: 5, y: 6 }, { x: 165, y: 5 }, { sag: -2, steps: 10 }, rand),\n    ...stroke({ x: 165, y: 5 }, { x: 165, y: 21 }, { steps: 3 }, rand),\n    ...stroke({ x: 165, y: 21 }, { x: 5, y: 22 }, { sag: 2, steps: 10 }, rand),\n    ...stroke({ x: 5, y: 22 }, { x: 5, y: 6 }, { steps: 3 }, rand),\n  ];\n\n  return [{ d: trace(outline, true), fill: true }];\n}\n\ntype MarkDefinition = {\n  wrapper: string;\n  decoration: string;\n  color: string;\n  viewBox?: string;\n  strokes?: Stroke[];\n  /** Marks that are plain CSS rather than a drawing. */\n  css?: string;\n  /** Filled marks sit behind the glyphs instead of on top of them. */\n  behindText?: boolean;\n};\n\nconst marks = {\n  wavy: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute bottom-[-0.4em] left-[-2%] h-[0.7em] w-[104%]\",\n    color: \"text-purple-500 dark:text-purple-300\",\n    viewBox: \"0 0 140 14\",\n    strokes: wavyStrokes(),\n  },\n  underline: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute bottom-[-0.32em] left-[-1%] h-[0.5em] w-[102%]\",\n    color: \"text-purple-500 dark:text-purple-400\",\n    viewBox: \"0 0 140 10\",\n    strokes: underlineStrokes(),\n  },\n  doubleUnderline: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute bottom-[-0.5em] left-[-1%] h-[0.7em] w-[102%]\",\n    color: \"text-emerald-600 dark:text-emerald-400\",\n    viewBox: \"0 0 140 16\",\n    strokes: doubleUnderlineStrokes(),\n  },\n  dottedUnderline: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute bottom-[-0.35em] left-[-1%] block h-[0.55em] w-[102%]\",\n    color: \"text-neutral-500 dark:text-neutral-400\",\n    css: cn(\n      \"bg-[radial-gradient(circle,currentColor_1.5px,transparent_1.5px)]\",\n      \"bg-size-[0.5em_100%] bg-position-[0_100%] bg-repeat-x\",\n    ),\n  },\n  line: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute bottom-[-0.18em] left-[-1%] block h-[2px] w-[102%]\",\n    color: \"text-neutral-500\",\n    css: \"rounded-full bg-current\",\n  },\n  arrow: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute bottom-[-0.45em] left-[-1%] h-[0.8em] w-[106%]\",\n    color: \"text-blue-600 dark:text-blue-400\",\n    viewBox: \"0 0 150 18\",\n    strokes: arrowStrokes(),\n  },\n  highlight: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute inset-x-[-4%] bottom-[-0.08em] z-0 h-[1.15em] w-[108%]\",\n    color: \"text-yellow-300/60\",\n    viewBox: \"0 0 170 26\",\n    strokes: highlightStrokes(),\n    behindText: true,\n  },\n  circle: {\n    wrapper: \"relative inline-block px-1 whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute inset-[-0.6em_-0.55em] h-[calc(100%+1.2em)] w-[calc(100%+1.1em)]\",\n    color: \"text-cyan-600 dark:text-cyan-200\",\n    viewBox: \"0 0 220 64\",\n    strokes: circleStrokes(),\n  },\n  box: {\n    wrapper: \"relative inline-block px-[0.4em] whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute inset-[-0.4em_-0.4em] h-[calc(100%+0.8em)] w-[calc(100%+0.8em)]\",\n    color: \"text-orange-600 dark:text-orange-400\",\n    viewBox: \"0 0 200 64\",\n    strokes: boxStrokes(),\n  },\n  bracket: {\n    wrapper: \"relative inline-block px-[0.35em] whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute inset-y-[-0.25em] left-[-0.15em] h-[calc(100%+0.5em)] w-[calc(100%+0.3em)]\",\n    color: \"text-neutral-500\",\n    viewBox: \"0 0 160 60\",\n    strokes: bracketStrokes(),\n  },\n  strikethrough: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute top-1/2 left-[-1%] h-[0.5em] w-[102%] -translate-y-1/2\",\n    color: \"text-red-500 dark:text-red-400\",\n    viewBox: \"0 0 140 10\",\n    strokes: strikethroughStrokes(),\n  },\n  crossOut: {\n    wrapper: \"relative inline-block whitespace-nowrap\",\n    decoration:\n      \"pointer-events-none absolute top-1/2 left-[-2%] h-[1.4em] w-[104%] -translate-y-1/2\",\n    color: \"text-red-500 dark:text-red-400\",\n    viewBox: \"0 0 140 40\",\n    strokes: crossOutStrokes(),\n  },\n} satisfies Record<string, MarkDefinition>;\n\nexport type AnnotationVariant = keyof typeof marks;\n\nexport interface AnnotatedTextProps {\n  /** The words to annotate. Kept on a single line so the drawing lines up. */\n  children: ReactNode;\n  /** Which hand-drawn mark to draw. */\n  variant?: AnnotationVariant;\n  /** Tailwind text color class for the mark, e.g. \"text-rose-400\". */\n  color?: string;\n  /** Draw the annotation when it first enters the viewport. */\n  animate?: boolean;\n  /** Delay before drawing, in seconds. */\n  delay?: number;\n  /** Drawing duration, in seconds. */\n  duration?: number;\n  /** Additional classes applied to the wrapper. */\n  className?: string;\n}\n\nexport function AnnotatedText({\n  children,\n  variant = \"wavy\",\n  color,\n  className,\n  animate = true,\n  delay = 0,\n  duration = 0.65,\n}: AnnotatedTextProps) {\n  const ref = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const element = ref.current;\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    if (!element || !animate || reducedMotion.matches) return;\n\n    const drawings = element.querySelectorAll(\"[data-annotation-drawing]\");\n    const animations = Array.from(drawings, (drawing, index) => {\n      const reveal =\n        drawing.getAttribute(\"data-annotation-drawing\") === \"reveal\";\n      const opacity = Number(drawing.getAttribute(\"opacity\") ?? 1);\n      const animation = drawing.animate(\n        reveal\n          ? [{ clipPath: \"inset(0 100% 0 0)\" }, { clipPath: \"inset(0 0% 0 0)\" }]\n          : [\n              // A zero-length dash still paints a round cap. Keep it invisible\n              // during the delay, then reveal it as the stroke begins moving.\n              {\n                strokeDasharray: \"1\",\n                strokeDashoffset: \"1\",\n                opacity: 0,\n                offset: 0,\n              },\n              {\n                strokeDasharray: \"1\",\n                strokeDashoffset: \"0.999\",\n                opacity,\n                offset: 0.001,\n              },\n              {\n                strokeDasharray: \"1\",\n                strokeDashoffset: \"0\",\n                opacity,\n                offset: 1,\n              },\n            ],\n        {\n          duration: Math.max(0, duration) * 1000,\n          delay: Math.max(0, delay) * 1000 + index * 160,\n          easing: \"cubic-bezier(0.22, 0.61, 0.36, 1)\",\n          fill: \"backwards\",\n        },\n      );\n      animation.pause();\n      return animation;\n    });\n    const observer = new IntersectionObserver(\n      ([entry]) => {\n        if (!entry?.isIntersecting) return;\n        animations.forEach((animation) => animation.play());\n        observer.disconnect();\n      },\n      { threshold: 0.25 },\n    );\n    observer.observe(element);\n\n    const finish = () => {\n      if (!reducedMotion.matches) return;\n      observer.disconnect();\n      animations.forEach((animation) => animation.cancel());\n    };\n    reducedMotion.addEventListener(\"change\", finish);\n    return () => {\n      observer.disconnect();\n      animations.forEach((animation) => animation.cancel());\n      reducedMotion.removeEventListener(\"change\", finish);\n    };\n  }, [animate, delay, duration, variant]);\n\n  const mark: MarkDefinition = marks[variant];\n  const decorationClass = cn(mark.decoration, color ?? mark.color);\n\n  return (\n    <span ref={ref} className={cn(mark.wrapper, className)}>\n      {mark.behindText ? (\n        // The mark is an opaque fill, so the words ride on top of it as ink.\n        <span className=\"relative z-10\">{children}</span>\n      ) : (\n        children\n      )}\n\n      {mark.css ? (\n        <span\n          aria-hidden=\"true\"\n          data-annotation-drawing=\"reveal\"\n          className={cn(\"block\", decorationClass, mark.css)}\n        />\n      ) : (\n        <svg\n          className={cn(decorationClass, mark.behindText && \"z-0\")}\n          viewBox={mark.viewBox}\n          fill=\"none\"\n          preserveAspectRatio=\"none\"\n          aria-hidden=\"true\"\n        >\n          {mark.strokes?.map((line) => (\n            <path\n              key={line.d}\n              d={line.d}\n              pathLength={1}\n              data-annotation-drawing={line.fill ? \"reveal\" : \"stroke\"}\n              fill={line.fill ? \"currentColor\" : \"none\"}\n              stroke={line.fill ? \"none\" : \"currentColor\"}\n              strokeWidth={line.fill ? undefined : (line.width ?? 2.4)}\n              strokeLinecap=\"round\"\n              strokeLinejoin=\"round\"\n              opacity={line.opacity}\n            />\n          ))}\n        </svg>\n      )}\n    </span>\n  );\n}\n"
    }
  ]
}
