{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-stitch-unpick",
  "title": "Text Stitch Unpick",
  "description": "Headline rendered as running-stitch embroidery: the cursor is a seam ripper that picks a letter's stitches loose into a gravity-swayed dangling thread, lingering unravels it into a static pile, and a click (or the Re-sew button) sews it back in.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/text-stitch-unpick/component.tsx",
      "content": "\"use client\";\n\nimport {\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type MouseEvent as ReactMouseEvent,\n  type PointerEvent as ReactPointerEvent,\n} from \"react\";\n\n// ---------------------------------------------------------------------------\n// StitchPick — a headline rendered as running-stitch embroidery that a\n// passing cursor (a seam ripper) picks loose, letter by letter. Each glyph is\n// two offset, dashed, fill:none SVG <text> strokes (thread texture, subtle\n// two-tone shimmer via a second stroke color at low opacity) laid out on a\n// monospace grid so per-letter hit zones are trivial index math, no DOM\n// measurement of proportional glyph widths.\n//\n// Per-letter state machine, entirely local to that letter:\n//   stitched  -> pointer enters its zone -> picked (thread detaches, dangles\n//                on a 4-point verlet chain: gravity + a little sway, driven\n//                by a shared rAF loop, direct SVG attribute writes on a path\n//                ref — no React state on the physics hot path).\n//   picked    -> pointer leaves before the dwell threshold -> stitched again\n//                (retracts: the dangling thread fades, the dash redraws).\n//             -> dwell threshold elapses while still hovered -> unraveled\n//                (thread fully detaches into a static loose pile at the\n//                glyph's baseline; physics stops, the pile just sits there).\n//   unraveled -> click that letter, or the always-present \"Re-sew\" button\n//                (which resews every unraveled letter at once) -> stitched,\n//                dash redraws with a short per-letter delayed stagger so it\n//                reads as sewn back in order rather than snapping at once.\n//\n// A single onPointerMove/onPointerLeave/onClick on the SVG root does the hit\n// testing (glyph index = floor(localX / advance)) rather than per-glyph DOM\n// listeners. Reduced motion skips the whole state machine: hover just\n// brightens that letter's thread stroke opacity, instantly, no dangling,\n// no pile.\n// ---------------------------------------------------------------------------\n\ntype LetterState = \"stitched\" | \"picked\" | \"unraveled\";\n\nexport interface StitchPickProps {\n  /** the rendered text */\n  text?: string;\n  /** Glyph font size in px. Default 56. */\n  size?: number;\n  /** Hover dwell before a picked letter fully unravels into a pile, ms. Default 650. */\n  dwellMs?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst POINTS = 4;\nconst GRAVITY = 0.055;\nconst DAMPING = 0.96;\nconst SEGMENT = 4.2;\nconst CONSTRAINT_ITERS = 3;\n\ninterface ThreadSim {\n  x: number[];\n  y: number[];\n  px: number[];\n  py: number[];\n  anchorX: number;\n  anchorY: number;\n  seed: number;\n}\n\nfunction makeSim(anchorX: number, anchorY: number, seed: number): ThreadSim {\n  const x = new Array(POINTS).fill(anchorX);\n  const y = new Array(POINTS).fill(anchorY);\n  return { x, y, px: [...x], py: [...y], anchorX, anchorY, seed };\n}\n\nfunction stepSim(sim: ThreadSim, t: number) {\n  sim.x[0] = sim.anchorX;\n  sim.y[0] = sim.anchorY;\n  sim.px[0] = sim.anchorX;\n  sim.py[0] = sim.anchorY;\n\n  for (let i = 1; i < POINTS; i++) {\n    const vx = (sim.x[i] - sim.px[i]) * DAMPING;\n    const vy = (sim.y[i] - sim.py[i]) * DAMPING;\n    const sway = Math.sin(t * 0.004 + sim.seed + i) * 0.06;\n    sim.px[i] = sim.x[i];\n    sim.py[i] = sim.y[i];\n    sim.x[i] += vx + sway;\n    sim.y[i] += vy + GRAVITY;\n  }\n\n  for (let iter = 0; iter < CONSTRAINT_ITERS; iter++) {\n    for (let i = 1; i < POINTS; i++) {\n      const ax = sim.x[i - 1];\n      const ay = sim.y[i - 1];\n      const dx = sim.x[i] - ax;\n      const dy = sim.y[i] - ay;\n      const dist = Math.hypot(dx, dy) || 0.0001;\n      const diff = (dist - SEGMENT) / dist;\n      if (i === 1) {\n        sim.x[i] -= dx * diff;\n        sim.y[i] -= dy * diff;\n      } else {\n        sim.x[i] -= dx * diff * 0.5;\n        sim.y[i] -= dy * diff * 0.5;\n        sim.x[i - 1] += dx * diff * 0.5;\n        sim.y[i - 1] += dy * diff * 0.5;\n      }\n    }\n  }\n}\n\nfunction pathFor(sim: ThreadSim): string {\n  let d = `M ${sim.x[0].toFixed(1)} ${sim.y[0].toFixed(1)}`;\n  for (let i = 1; i < POINTS; i++) d += ` L ${sim.x[i].toFixed(1)} ${sim.y[i].toFixed(1)}`;\n  return d;\n}\n\nexport function StitchPick({ text = \"UNRAVEL\", size = 56, dwellMs = 650, className = \"\" }: StitchPickProps) {\n  const glyphs = useMemo(() => Array.from(text), [text]);\n\n  const svgRef = useRef<SVGSVGElement | null>(null);\n  const measureRef = useRef<SVGTextElement | null>(null);\n  const threadPathRefs = useRef<(SVGPathElement | null)[]>([]);\n  const reducedRef = useRef(false);\n\n  const [advance, setAdvance] = useState(size * 0.62);\n  const [states, setStates] = useState<LetterState[]>(() => glyphs.map(() => \"stitched\"));\n  const statesRef = useRef(states);\n  statesRef.current = states;\n\n  const [brightIndex, setBrightIndex] = useState(-1);\n  const hoveredRef = useRef(-1);\n  const dwellTimers = useRef<Record<number, ReturnType<typeof setTimeout>>>({});\n  const sims = useRef<Record<number, ThreadSim>>({});\n  // Indices currently animating, tracked independently of `states` — the rAF\n  // loop reads this ref directly instead of deriving liveness from\n  // statesRef, since a pointermove-triggered setState is not guaranteed to\n  // flush before the next animation frame runs.\n  const activeRef = useRef<Set<number>>(new Set());\n  const rafRef = useRef<number | undefined>(undefined);\n\n  const fontSize = size;\n  const height = fontSize * 1.7;\n  const baseline = fontSize * 1.05;\n  const width = Math.max(1, glyphs.length) * advance;\n\n  useEffect(() => {\n    const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    reducedRef.current = mq.matches;\n    const onChange = () => {\n      reducedRef.current = mq.matches;\n    };\n    mq.addEventListener(\"change\", onChange);\n    return () => mq.removeEventListener(\"change\", onChange);\n  }, []);\n\n  const measure = useCallback(() => {\n    const el = measureRef.current;\n    if (!el) return;\n    const len = el.getComputedTextLength();\n    if (len > 0) setAdvance(len);\n  }, []);\n\n  useEffect(() => {\n    measure();\n    if (typeof document !== \"undefined\" && \"fonts\" in document) {\n      document.fonts.ready.then(measure).catch(() => {});\n    }\n  }, [measure, size]);\n\n  const runLoop = useCallback(() => {\n    const loop = (t: number) => {\n      for (const i of activeRef.current) {\n        const sim = sims.current[i];\n        if (!sim) continue;\n        stepSim(sim, t);\n        const el = threadPathRefs.current[i];\n        if (el) el.setAttribute(\"d\", pathFor(sim));\n      }\n      if (activeRef.current.size > 0) {\n        rafRef.current = requestAnimationFrame(loop);\n      } else {\n        rafRef.current = undefined;\n      }\n    };\n    if (rafRef.current === undefined) {\n      rafRef.current = requestAnimationFrame(loop);\n    }\n  }, []);\n\n  useEffect(\n    () => () => {\n      if (rafRef.current !== undefined) cancelAnimationFrame(rafRef.current);\n      Object.values(dwellTimers.current).forEach((id) => clearTimeout(id));\n    },\n    []\n  );\n\n  // Reset per-letter state when the headline text itself changes — states\n  // is a fixed-length array seeded once from the initial glyph count, so a\n  // later prop change would otherwise leave extra/changed glyphs stuck with\n  // no state entry (invisible, inert to hover).\n  const lastTextRef = useRef(text);\n  useEffect(() => {\n    if (lastTextRef.current === text) return;\n    lastTextRef.current = text;\n    Object.values(dwellTimers.current).forEach((id) => clearTimeout(id));\n    dwellTimers.current = {};\n    sims.current = {};\n    activeRef.current.clear();\n    hoveredRef.current = -1;\n    setStates(glyphs.map(() => \"stitched\"));\n  }, [text, glyphs]);\n\n  const setLetterState = useCallback((i: number, next: LetterState) => {\n    setStates((prev) => {\n      if (prev[i] === next) return prev;\n      const copy = prev.slice();\n      copy[i] = next;\n      return copy;\n    });\n  }, []);\n\n  const enterLetter = useCallback(\n    (i: number) => {\n      if (i < 0 || i >= glyphs.length || glyphs[i] === \" \") return;\n      if (statesRef.current[i] !== \"stitched\") return;\n      const anchorX = i * advance + advance / 2;\n      const anchorY = baseline + 2;\n      sims.current[i] = makeSim(anchorX, anchorY, i * 1.37);\n      activeRef.current.add(i);\n      setLetterState(i, \"picked\");\n      runLoop();\n      clearTimeout(dwellTimers.current[i]);\n      dwellTimers.current[i] = setTimeout(() => {\n        if (statesRef.current[i] === \"picked\") {\n          activeRef.current.delete(i);\n          setLetterState(i, \"unraveled\");\n        }\n      }, dwellMs);\n    },\n    [advance, baseline, dwellMs, glyphs, runLoop, setLetterState]\n  );\n\n  const leaveLetter = useCallback(\n    (i: number) => {\n      if (i < 0 || i >= glyphs.length) return;\n      if (statesRef.current[i] === \"picked\") {\n        clearTimeout(dwellTimers.current[i]);\n        activeRef.current.delete(i);\n        setLetterState(i, \"stitched\");\n      }\n    },\n    [glyphs.length, setLetterState]\n  );\n\n  const indexAt = (clientX: number) => {\n    const rect = svgRef.current?.getBoundingClientRect();\n    if (!rect || advance <= 0) return -1;\n    const idx = Math.floor((clientX - rect.left) / advance);\n    return Math.min(glyphs.length - 1, Math.max(0, idx));\n  };\n\n  const onPointerMove = (e: ReactPointerEvent<SVGSVGElement>) => {\n    const idx = indexAt(e.clientX);\n    if (reducedRef.current) {\n      if (idx !== brightIndex) setBrightIndex(idx);\n      return;\n    }\n    if (idx === hoveredRef.current) return;\n    leaveLetter(hoveredRef.current);\n    enterLetter(idx);\n    hoveredRef.current = idx;\n  };\n\n  const onPointerLeave = () => {\n    if (reducedRef.current) {\n      setBrightIndex(-1);\n      return;\n    }\n    leaveLetter(hoveredRef.current);\n    hoveredRef.current = -1;\n  };\n\n  const onClickLetter = (e: ReactMouseEvent<SVGSVGElement>) => {\n    const idx = indexAt(e.clientX);\n    if (idx >= 0 && statesRef.current[idx] === \"unraveled\") {\n      clearTimeout(dwellTimers.current[idx]);\n      setLetterState(idx, \"stitched\");\n    }\n  };\n\n  const resewAll = () => {\n    Object.values(dwellTimers.current).forEach((id) => clearTimeout(id));\n    dwellTimers.current = {};\n    setStates((prev) => prev.map(() => \"stitched\" as LetterState));\n  };\n\n  const anyUnraveled = states.some((s) => s === \"unraveled\");\n\n  return (\n    <div className={`inline-flex flex-col items-center gap-4 ${className}`}>\n      <style>{CSS}</style>\n      <svg\n        ref={svgRef}\n        width={width}\n        height={height}\n        viewBox={`0 0 ${width} ${height}`}\n        className=\"ns-stitch-svg select-none\"\n        role=\"img\"\n        aria-label={text}\n        onPointerMove={onPointerMove}\n        onPointerLeave={onPointerLeave}\n        onClick={onClickLetter}\n      >\n        <text\n          ref={measureRef}\n          x={-9999}\n          y={-9999}\n          fontFamily=\"var(--font-mono)\"\n          fontSize={fontSize}\n          fontWeight={700}\n          aria-hidden=\"true\"\n        >\n          {\"M\"}\n        </text>\n\n        {glyphs.map((ch, i) => {\n          const cx = i * advance;\n          const state = states[i];\n          if (ch === \" \") return null;\n          const bright = i === brightIndex;\n          return (\n            <g key={i} data-letter-state={state}>\n              <g\n                className=\"ns-stitch-thread\"\n                style={{\n                  opacity: state === \"stitched\" ? 1 : 0,\n                  transitionDelay: state === \"stitched\" ? `${i * 18}ms` : \"0ms\",\n                }}\n              >\n                <text\n                  x={cx}\n                  y={baseline}\n                  fontFamily=\"var(--font-mono)\"\n                  fontSize={fontSize}\n                  fontWeight={700}\n                  fill=\"none\"\n                  stroke=\"var(--foreground)\"\n                  strokeOpacity={bright ? 1 : 0.85}\n                  strokeWidth={1.6}\n                  strokeDasharray=\"3 2.4\"\n                  strokeLinecap=\"round\"\n                  aria-hidden=\"true\"\n                >\n                  {ch}\n                </text>\n                <text\n                  x={cx + 0.6}\n                  y={baseline - 0.6}\n                  fontFamily=\"var(--font-mono)\"\n                  fontSize={fontSize}\n                  fontWeight={700}\n                  fill=\"none\"\n                  stroke=\"var(--ns-accent)\"\n                  strokeOpacity={bright ? 0.4 : 0.22}\n                  strokeWidth={1}\n                  strokeDasharray=\"3 2.4\"\n                  strokeLinecap=\"round\"\n                  aria-hidden=\"true\"\n                >\n                  {ch}\n                </text>\n              </g>\n\n              {state === \"picked\" && (\n                <path\n                  ref={(el) => {\n                    threadPathRefs.current[i] = el;\n                  }}\n                  d={`M ${cx + advance / 2} ${baseline + 2} L ${cx + advance / 2} ${baseline + 2}`}\n                  fill=\"none\"\n                  stroke=\"var(--foreground)\"\n                  strokeWidth={1.4}\n                  strokeDasharray=\"2 2\"\n                  strokeLinecap=\"round\"\n                  aria-hidden=\"true\"\n                />\n              )}\n\n              {state === \"unraveled\" && (\n                <g className=\"ns-stitch-pile\" aria-hidden=\"true\">\n                  <path\n                    d={`M ${cx + advance * 0.16} ${baseline - 2}\n                        q ${advance * 0.16} 12 ${advance * 0.34} 6\n                        q ${advance * 0.22} -9 ${advance * 0.42} 3\n                        q -${advance * 0.1} 9 ${advance * 0.1} 13\n                        q ${advance * 0.14} -5 ${advance * 0.28} 0`}\n                    fill=\"none\"\n                    stroke=\"var(--foreground)\"\n                    strokeWidth={1.5}\n                    strokeDasharray=\"2.2 2.2\"\n                    strokeLinecap=\"round\"\n                  />\n                  <path\n                    d={`M ${cx + advance * 0.22} ${baseline + 1}\n                        q ${advance * 0.12} 8 ${advance * 0.3} 4`}\n                    fill=\"none\"\n                    stroke=\"var(--ns-accent)\"\n                    strokeOpacity={0.3}\n                    strokeWidth={1}\n                    strokeDasharray=\"2 2\"\n                    strokeLinecap=\"round\"\n                  />\n                </g>\n              )}\n            </g>\n          );\n        })}\n      </svg>\n\n      <button\n        type=\"button\"\n        onClick={resewAll}\n        className=\"ns-stitch-resew rounded-[6px] border border-border px-3 py-1.5 font-mono text-[11px] uppercase tracking-wide text-foreground hover:bg-border/40 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent\"\n      >\n        {anyUnraveled ? \"Re-sew\" : \"Re-sew (nothing loose)\"}\n      </button>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-stitch-thread{transition:opacity 200ms ease-out;}\n.ns-stitch-thread text{transition:stroke-opacity 150ms ease-out;}\n.ns-stitch-pile{animation:ns-stitch-settle 220ms cubic-bezier(0.16,1,0.3,1);}\n@keyframes ns-stitch-settle{from{opacity:0;transform:translateY(-3px) scale(0.9);}to{opacity:1;transform:none;}}\n@media (prefers-reduced-motion: reduce){\n  .ns-stitch-thread{transition:opacity 0ms;}\n  .ns-stitch-thread text{transition:stroke-opacity 120ms linear;}\n  .ns-stitch-pile{animation:none;}\n}\n.ns-stitch-svg text{paint-order:stroke;}\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/text-stitch-unpick.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "text",
      "headline",
      "svg",
      "physics",
      "hover",
      "micro-interaction",
      "typography"
    ],
    "instruction": "Build <StitchPick text? size? dwellMs? className?>, a headline rendered as dashed-stroke, fill:none SVG <text> glyphs (\"running stitch\") that a passing cursor picks apart letter by letter. LAYOUT: lay every glyph on a monospace grid (fontFamily var(--font-mono), fontWeight 700) so per-letter hit-testing is index math, not proportional-width measurement: render one throwaway offscreen <text>M</text> once, read its getComputedTextLength() in an effect (re-run once more after document.fonts.ready resolves, in case the mono face swaps in after first paint) to get the true per-glyph advance in px, then position glyph i at x = i * advance. THREAD RENDERING: each glyph is TWO offset <text> elements, both fill=\"none\" stroke-dasharray=\"3 2.4\" (the running-stitch dash), one full-strength stroke=var(--foreground) strokeWidth 1.6 slightly on-baseline, one stroke=var(--ns-accent) strokeOpacity ~0.22 strokeWidth 1 offset +0.6/-0.6px — the second, low-alpha, off-hue pass is the loud collection's subtle two-tone shimmer, not a wash. STATE MACHINE, per letter, independent of every other letter: stitched (default: both dashed strokes visible) -> pointer enters that letter's horizontal zone (glyph index = floor((clientX - svgLeft) / advance), computed once from a single onPointerMove on the SVG root rather than per-glyph listeners) -> picked: the stitched <g> fades to opacity 0 over 200ms and a dangling thread <path> appears, driven by a 4-point verlet chain (point 0 pinned at the glyph's top-stitch anchor; points 1-3 integrate gravity 0.055 + a small per-letter-seeded sin sway + velocity damping 0.96, then 3 iterations of a distance constraint at segment length 4.2, all inside a SHARED requestAnimationFrame loop that starts when the first letter is picked and stops itself the frame no letter is left in the picked state — every frame writes the path's `d` attribute directly via a ref, never through React state). If the pointer leaves that letter's zone before `dwellMs` (default 650) elapses, the letter returns straight to stitched (dashed <g> fades back in, a `transition-delay: index*18ms` stagger so a multi-letter re-stitch reads as sewn back in left-to-right order rather than snapping at once) and the dwell timer is cleared. If the pointer stays past dwellMs, the letter advances to unraveled: physics stops, the dangling path is replaced by a small static wavy \"pile\" shape (a couple of quadratic-bezier loops near the glyph baseline, same dashed thread styling) that plays one 220ms settle-in (opacity+translateY) and then just sits there — it does NOT auto re-stitch on pointer leave anymore. UNRAVELING RESOLUTION: clicking directly on an unraveled letter (same index math, on a root onClick) resews just that letter; a real, always-rendered <button> below the headline, labeled \"Re-sew\" (or \"Re-sew (nothing loose)\" when nothing is unraveled, so its accessible name always reflects real state and it is never a no-op the user can't discover) resews every unraveled letter at once — this button is also the component's one required real interactive control for keyboard/screen-reader users, since the letter-hover/click interactions are a mouse-only bonus layer on top of it, not a replacement for it. A11Y: the SVG itself is role=\"img\" aria-label={text} (the individual glyph strokes are aria-hidden, so a screen reader gets the plain headline text once, not per-letter noise); the Re-sew button is a real focusable <button> with a visible focus-visible ring. REDUCED MOTION: the entire picked/unraveled state machine is bypassed — onPointerMove instead just tracks a single \"brightened\" letter index and bumps that letter's stroke-opacity (foreground 0.85->1, accent 0.22->0.4) over a fast 120ms linear color transition, no dangling, no physics, no pile, so hover still visibly differs from rest with zero motion. TOKENS: var(--foreground) for the primary thread, var(--ns-accent) at low opacity for the shimmer pass, var(--border) for the Re-sew button's chrome — no fill color anywhere on the glyphs themselves (stroke only is the whole point of the embroidery look). Not SVG <path> pathLength/dash-window tricks anywhere — the dasharray here is a static texture, not a normalized progress indicator, so the pathLength+non-scaling-stroke screen-space trap does not apply, but is avoided regardless as a matter of course. DEMO: a centered headline (\"UNRAVEL\") self-driving through the whole state space unattended — a quick multi-letter sweep (pick+auto-restitch), a long dwell on one letter (unravels, then a synthetic click resews it), dwells on two more letters followed by a synthetic click on the Re-sew button — all via real dispatched PointerEvent/click sequences at the SVG's DOM node, not simulated state."
  },
  "type": "registry:ui"
}