{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stencil",
  "type": "registry:ui",
  "title": "Stencil",
  "description": "Canvas headline spray-painted through a cut stencil plate: seeded S-curve sweeps deposit grainy paint inside the letter cutouts with faint overspray and a few drips, then the plate lifts away to reveal the word; drag to add coats.",
  "dependencies": [],
  "registryDependencies": [
    "https://crucibleui.com/r/use-visibility-pause.json",
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/text/stencil/stencil.tsx",
      "type": "registry:ui",
      "target": "components/crucible/stencil.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\nexport interface StencilHandle {\n  /** Wipe the paint, lay the plate back down and spray the headline again. */\n  replay(): void;\n}\n\nexport interface StencilProps extends Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"color\"\n> {\n  /** The headline (also rendered as a visually hidden heading for screen readers + SEO). @default \"STENCIL\" */\n  text?: string;\n  /** Element the visually hidden real text is rendered as, so it stays a heading. @default \"h2\" */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\";\n  /** Spray paint color. @default \"#f2f0eb\" (off-white) */\n  paint?: string;\n  /** Stencil plate color — keep it a little lighter than the surface behind it. @default \"#2a2b2f\" (graphite) */\n  plate?: string;\n  /** Maximum glyph size in px; the headline scales down (and wraps to two lines) to fit. @default auto (~48% of the height) */\n  fontSize?: number;\n  /**\n   * Canvas font stack. Any counters (the holes in O, A, B…) get stencil\n   * bridges cut automatically, so plain bold fonts read as real stencils.\n   * Web fonts are picked up once `document.fonts` is ready.\n   * @default a bold condensed sans stack (Impact / Oswald / Arial Narrow…)\n   */\n  fontFamily?: string;\n  /** Number of S-curve sweeps the can makes per line of text. 1–8. @default 3 */\n  passes?: number;\n  /** Paint runs that trickle 4–18px down from the letters' bottom edges during the intro. 0–16. @default 5 */\n  drips?: number;\n  /** Deterministic seed for the spray grain, sweep paths and drip spots. @default 7 */\n  seed?: number;\n  /** Imperative handle: `replay()`. */\n  ref?: React.Ref<StencilHandle>;\n}\n\nconst DEFAULT_FONT =\n  '\"Saira Stencil One\", \"Big Shoulders Stencil Display\", Impact, Haettenschweiler, \"Oswald\", \"Bebas Neue\", \"Arial Narrow Bold\", \"Arial Narrow\", \"Roboto Condensed\", sans-serif';\n\nconst START = 0.3; // first sweep begins\nconst GAP = 0.07; // the can reverses between passes\nconst SETTLE = 0.4; // pause between the last pass and the plate lift\nconst LIFT_DUR = 1.05;\nconst OVERSPRAY = 0.22; // alpha of paint that creeps under the plate edge\nconst MAX_DRIPS = 40;\n\n/** Deterministic mulberry32 PRNG. */\nfunction mulberry32(seed: number): () => number {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\n    a = (a + 0x6d2b79f5) | 0;\n    let t = Math.imul(a ^ (a >>> 15), 1 | a);\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\nconst clamp = (v: number, lo: number, hi: number) =>\n  Math.min(hi, Math.max(lo, v));\nconst smooth = (a: number, b: number, v: number) => {\n  const t = clamp((v - a) / (b - a), 0, 1);\n  return t * t * (3 - 2 * t);\n};\nconst easeOut = (u: number, p = 3) => 1 - Math.pow(1 - clamp(u, 0, 1), p);\n\ninterface Drip {\n  x: number;\n  y: number;\n  len: number;\n  w: number;\n  start: number;\n  dur: number;\n  drawn: number;\n}\n\ninterface Layout {\n  w: number;\n  h: number;\n  dpr: number;\n  fs: number;\n  lines: number;\n  /** CSS-px glyph mask (post-bridging). */\n  mask: Uint8Array;\n  /** Text block bounds (CSS px). */\n  box: { x0: number; y0: number; x1: number; y1: number };\n  /** Plate rect (CSS px). */\n  plateRect: { x: number; y: number; w: number; h: number };\n  /** Candidate drip origins — points on the letters' bottom edges. */\n  edges: number[];\n  maskC: HTMLCanvasElement;\n  padSoftC: HTMLCanvasElement;\n  padHardC: HTMLCanvasElement;\n  plateC: HTMLCanvasElement;\n  cutShadowC: HTMLCanvasElement;\n  wordC: HTMLCanvasElement;\n  platePaintC: HTMLCanvasElement;\n  /** Fresh grain splats this frame. */\n  freshC: HTMLCanvasElement;\n  /** Fresh soft base coat this frame (kept out of the overspray so the halo stays speckled). */\n  coatC: HTMLCanvasElement;\n  dripFreshC: HTMLCanvasElement;\n  tmpC: HTMLCanvasElement;\n  blob: HTMLCanvasElement;\n  /** Spray cone radius for the intro sweeps (CSS px). */\n  R: number;\n  passDur: number;\n  passCount: number;\n  sprayEnd: number;\n  liftAt: number;\n}\n\n/**\n * Stencil — a headline spray-painted through a cut stencil plate. The glyphs\n * are rasterized into a mask (counters get stencil bridges cut automatically),\n * a graphite plate with those letters cut out sits over the surface, and an\n * off-screen can sweeps a few seeded S-curve passes, depositing thousands of\n * soft, grainy low-alpha splats: full strength inside the cutouts, a faint\n * overspray creeping under the plate edge, and the rest landing on the plate\n * itself. A few drips run down from the letters' bottom edges, then the plate\n * lifts away — shadow growing, fading out — revealing the crisp painted word.\n * Pointer drag sprays more paint locally (coats stack; lingering makes it\n * run). DPR-clamped, pauses offscreen/hidden; the real text ships as a\n * visually hidden heading; reduced motion shows the finished painted word.\n */\nexport function Stencil({\n  text = \"STENCIL\",\n  as = \"h2\",\n  paint = \"#f2f0eb\",\n  plate = \"#2a2b2f\",\n  fontSize,\n  fontFamily = DEFAULT_FONT,\n  passes = 3,\n  drips = 5,\n  seed = 7,\n  className,\n  ref,\n  onPointerDown,\n  onPointerMove,\n  onPointerUp,\n  onPointerCancel,\n  onKeyDown,\n  ...props\n}: StencilProps) {\n  const Tag = as;\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const reducedMotion = useReducedMotion();\n  const reducedRef = React.useRef(reducedMotion);\n  React.useLayoutEffect(() => {\n    reducedRef.current = reducedMotion;\n  }, [reducedMotion]);\n\n  const layoutRef = React.useRef<Layout | null>(null);\n  const clockRef = React.useRef(0); // seconds since the intro (re)started\n  const lastElapsedRef = React.useRef(0);\n  const introDoneRef = React.useRef(false);\n  const dripsRef = React.useRef<Drip[]>([]);\n  const randRef = React.useRef(mulberry32(seed));\n  const dirtyRef = React.useRef(true);\n  const dragRef = React.useRef({\n    active: false,\n    id: -1,\n    x: 0,\n    y: 0,\n    lx: 0,\n    ly: 0,\n    dwell: 0,\n  });\n\n  const passCount = clamp(Math.round(passes), 1, 8);\n  const dripCount = clamp(Math.round(drips), 0, 16);\n\n  // ---------------------------------------------------------------- painting\n  /** Bind a context for drawing in CSS px. */\n  const cssCtx = (c: HTMLCanvasElement, dpr: number) => {\n    const ctx = c.getContext(\"2d\")!;\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    return ctx;\n  };\n\n  /** Spray one burst of grainy splats + a soft base coat at (x, y) into the fresh layer. */\n  const sprayAt = React.useCallback(\n    (\n      L: Layout,\n      x: number,\n      y: number,\n      R: number,\n      amount: number,\n      rand: () => number,\n    ) => {\n      // Soft base coat — gives the coverage its smooth, misted body.\n      const coat = cssCtx(L.coatC, L.dpr);\n      // Jittered so repeated bursts on one spot never quantize into rings.\n      coat.globalAlpha = 0.15 * Math.min(1, amount);\n      const br = R * (0.85 + rand() * 0.3);\n      const bx = x + (rand() - 0.5) * R * 0.35;\n      const by = y + (rand() - 0.5) * R * 0.35;\n      coat.drawImage(L.blob, bx - br, by - br, br * 2, br * 2);\n      const ctx = cssCtx(L.freshC, L.dpr);\n      // Grain — many tiny low-alpha dots in a gaussian cone.\n      const n = Math.round(R * 0.75 * amount);\n      const grain = clamp(L.fs / 110, 0.7, 1.6);\n      ctx.fillStyle = paint;\n      for (let i = 0; i < n; i++) {\n        const r1 = Math.max(rand(), 1e-6);\n        const mag = Math.sqrt(-2 * Math.log(r1)) * R * 0.42;\n        const ang = rand() * Math.PI * 2;\n        const px = x + Math.cos(ang) * mag;\n        const py = y + Math.sin(ang) * mag;\n        const s = grain * (0.45 + rand() * rand() * 1.9);\n        ctx.globalAlpha = 0.14 + rand() * 0.42;\n        if (s < 1.1) ctx.fillRect(px - s / 2, py - s / 2, s, s);\n        else {\n          ctx.beginPath();\n          ctx.arc(px, py, s * 0.6, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n      ctx.globalAlpha = 1;\n    },\n    [paint],\n  );\n\n  /** Point on intro sweep `i` at progress u — alternating S-curves stacked down the text block. */\n  const sweepPoint = (\n    L: Layout,\n    i: number,\n    u: number,\n    rand0: number,\n  ): [number, number] => {\n    const { x0, x1, y0, y1 } = L.box;\n    const dir = i % 2 === 0 ? 1 : -1;\n    const xa = x0 - L.R * 0.8;\n    const xb = x1 + L.R * 0.8;\n    const x = dir > 0 ? xa + (xb - xa) * u : xb - (xb - xa) * u;\n    const band = (y1 - y0) / L.passCount;\n    const yc = y0 + band * (i + 0.5);\n    const amp = band * 0.5 + L.fs * 0.04;\n    const y = yc + Math.sin(u * Math.PI * 2 + rand0 * 1.2 - 0.6) * amp * dir;\n    return [x, y];\n  };\n\n  /** Spray the stretch of sweep `i` between progress u0 and u1. */\n  const sprayAlong = React.useCallback(\n    (L: Layout, i: number, u0: number, u1: number) => {\n      if (u1 <= u0) return;\n      const r0 = mulberry32(seed * 977 + i * 131)();\n      const [ax, ay] = sweepPoint(L, i, u0, r0);\n      const [bx, by] = sweepPoint(L, i, u1, r0);\n      const steps = Math.max(1, Math.ceil(Math.hypot(bx - ax, by - ay) / 3));\n      for (let s = 1; s <= steps; s++) {\n        const u = u0 + ((u1 - u0) * s) / steps;\n        const [x, y] = sweepPoint(L, i, u, r0);\n        sprayAt(L, x, y, L.R, 1, randRef.current);\n      }\n    },\n    [sprayAt, seed],\n  );\n\n  /** Extend every drip to its length at time t, drawing the new run into the drip layer. */\n  const growDrips = React.useCallback(\n    (L: Layout, t: number, instant: boolean) => {\n      let any = false;\n      const ctx = cssCtx(L.dripFreshC, L.dpr);\n      ctx.fillStyle = paint;\n      for (const d of dripsRef.current) {\n        const p = instant ? 1 : easeOut((t - d.start) / d.dur, 2.4);\n        const target = d.len * p;\n        if (target <= d.drawn + 0.05) continue;\n        any = true;\n        for (let l = d.drawn; l <= target; l += 0.6) {\n          const k = l / d.len;\n          const r = d.w * (0.42 + 0.22 * k); // thickens toward the bead\n          ctx.globalAlpha = 0.9;\n          ctx.beginPath();\n          ctx.arc(\n            d.x + Math.sin(l * 0.35 + d.x) * 0.25,\n            d.y + l,\n            r,\n            0,\n            Math.PI * 2,\n          );\n          ctx.fill();\n        }\n        d.drawn = target;\n        if (p >= 1) {\n          ctx.beginPath();\n          ctx.arc(d.x, d.y + d.len, d.w * 0.72, 0, Math.PI * 2);\n          ctx.fill();\n        }\n      }\n      ctx.globalAlpha = 1;\n      return any;\n    },\n    [paint],\n  );\n\n  /** Route the fresh splats into the word (inside the cutouts + faint overspray) and onto the plate. */\n  const flush = React.useCallback((L: Layout, overspray = OVERSPRAY) => {\n    const tmp = L.tmpC.getContext(\"2d\")!;\n    const word = L.wordC.getContext(\"2d\")!;\n    const pp = L.platePaintC.getContext(\"2d\")!;\n    for (const c of [tmp, word, pp]) c.setTransform(1, 0, 0, 1, 0, 0);\n    const pass = (\n      src: HTMLCanvasElement,\n      clip: HTMLCanvasElement,\n      op: GlobalCompositeOperation,\n    ) => {\n      tmp.globalCompositeOperation = \"copy\";\n      tmp.drawImage(src, 0, 0);\n      tmp.globalCompositeOperation = op;\n      tmp.drawImage(clip, 0, 0);\n      tmp.globalCompositeOperation = \"source-over\";\n    };\n    // Overspray under the plate edge (soft padded mask, low alpha).\n    pass(L.freshC, L.padSoftC, \"destination-in\");\n    word.globalAlpha = overspray;\n    word.drawImage(L.tmpC, 0, 0);\n    word.globalAlpha = 1;\n    // Full-strength paint through the cutouts.\n    for (const src of [L.coatC, L.freshC]) {\n      pass(src, L.maskC, \"destination-in\");\n      word.drawImage(L.tmpC, 0, 0);\n    }\n    // Everything else lands on the plate.\n    for (const src of [L.coatC, L.freshC]) {\n      pass(src, L.plateC, \"destination-in\");\n      pp.drawImage(L.tmpC, 0, 0);\n    }\n    // Drips may creep a few px past the cut edge.\n    pass(L.dripFreshC, L.padHardC, \"destination-in\");\n    word.drawImage(L.tmpC, 0, 0);\n    for (const c of [L.freshC, L.coatC, L.dripFreshC]) {\n      const ctx = c.getContext(\"2d\")!;\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, c.width, c.height);\n    }\n  }, []);\n\n  /** Seed the intro drips on bottom edges of the letters. */\n  const planDrips = React.useCallback(\n    (L: Layout) => {\n      const rand = mulberry32(seed * 313 + 17);\n      const out: Drip[] = [];\n      const minGap = L.fs * 0.3;\n      const scale = clamp(L.fs / 120, 0.75, 1.5);\n      let guard = 0;\n      while (out.length < dripCount && L.edges.length && guard++ < 400) {\n        const k = Math.floor(rand() * (L.edges.length / 2)) * 2;\n        const x = L.edges[k];\n        const y = L.edges[k + 1];\n        if (out.some((d) => Math.abs(d.x - x) < minGap)) continue;\n        const len = (4 + rand() * 14) * scale;\n        out.push({\n          x,\n          y: y - L.fs * (0.02 + rand() * 0.05),\n          len,\n          w: (2 + rand() * 1.4) * scale,\n          start: L.sprayEnd - 0.55 + out.length * 0.16 + rand() * 0.25,\n          dur: 0.7 + (len / (18 * scale)) * 0.9,\n          drawn: 0,\n        });\n      }\n      dripsRef.current = out;\n    },\n    [seed, dripCount],\n  );\n\n  const clearPaint = (L: Layout) => {\n    for (const c of [L.wordC, L.platePaintC, L.freshC, L.coatC, L.dripFreshC]) {\n      const ctx = c.getContext(\"2d\")!;\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, c.width, c.height);\n    }\n  };\n\n  /** Paint the finished word in one go (reduced motion, resize after the intro). */\n  const paintInstant = React.useCallback(\n    (L: Layout) => {\n      clearPaint(L);\n      randRef.current = mulberry32(seed);\n      for (let i = 0; i < L.passCount; i++) sprayAlong(L, i, 0, 1);\n      planDrips(L);\n      growDrips(L, 0, true);\n      flush(L);\n    },\n    [seed, sprayAlong, planDrips, growDrips, flush],\n  );\n\n  // ---------------------------------------------------------------- render\n  const render = React.useCallback(() => {\n    const canvas = canvasRef.current;\n    const L = layoutRef.current;\n    if (!canvas || !L) return;\n    const ctx = canvas.getContext(\"2d\")!;\n    ctx.setTransform(1, 0, 0, 1, 0, 0);\n    ctx.clearRect(0, 0, canvas.width, canvas.height);\n    ctx.drawImage(L.wordC, 0, 0);\n\n    const t = clockRef.current;\n    const lift =\n      introDoneRef.current || reducedRef.current\n        ? 1\n        : clamp((t - L.liftAt) / LIFT_DUR, 0, 1);\n    if (lift >= 1) return;\n    // Unstick first (rise toward the viewer, shadow blooming), then pull away.\n    const rise = smooth(0, 0.32, lift);\n    const e = smooth(0.22, 1, lift);\n    const alpha = 1 - smooth(0.35, 0.9, lift);\n\n    // Plate thickness casts a hairline shadow into each cutout while it's down.\n    ctx.globalAlpha = 1 - smooth(0, 0.25, lift);\n    ctx.drawImage(L.cutShadowC, 0, 0);\n\n    const { x, y, w, h } = L.plateRect;\n    const cx = (x + w / 2) * L.dpr;\n    const cy = (y + h / 2) * L.dpr;\n    ctx.save();\n    ctx.translate(cx + e * L.fs * 0.2, cy - e * (h * 0.6 + L.fs * 0.3));\n    ctx.rotate(-0.05 * e);\n    const sc = 1 + 0.025 * rise + 0.05 * e;\n    ctx.scale(sc, sc);\n    ctx.translate(-cx, -cy);\n    ctx.globalAlpha = alpha;\n    const lifted = Math.max(rise, e);\n    ctx.shadowColor = `rgba(0,0,0,${0.6 * (1 - e * 0.5)})`;\n    ctx.shadowBlur = (2 + 14 * rise + 22 * e) * L.dpr;\n    ctx.shadowOffsetY = (1 + 8 * rise + 18 * e) * L.dpr;\n    ctx.shadowOffsetX = -lifted * 6 * L.dpr;\n    ctx.drawImage(L.plateC, 0, 0);\n    ctx.shadowColor = \"transparent\";\n    ctx.drawImage(L.platePaintC, 0, 0);\n    ctx.restore();\n    ctx.globalAlpha = 1;\n  }, []);\n\n  /** Pointer spray: interpolate along the stroke, and let paint build up (and run) where it lingers. */\n  const sprayDrag = React.useCallback(\n    (L: Layout, dt: number) => {\n      const d = dragRef.current;\n      const R = clamp(L.fs * 0.2, 14, 46);\n      const dist = Math.hypot(d.x - d.lx, d.y - d.ly);\n      const steps = Math.max(1, Math.ceil(dist / 4));\n      const amount = clamp((dt * 60) / steps, 0.25, 1) * 0.55;\n      for (let s = 1; s <= steps; s++) {\n        const x = d.lx + ((d.x - d.lx) * s) / steps;\n        const y = d.ly + ((d.y - d.ly) * s) / steps;\n        sprayAt(L, x, y, R, amount, randRef.current);\n      }\n      d.lx = d.x;\n      d.ly = d.y;\n      if (reducedRef.current) return;\n      // Lingering over a letter's bottom edge pools the paint into a fresh drip.\n      d.dwell += dt * (dist < R * 0.5 ? 1 : 0.35);\n      if (d.dwell > 0.75 && dripsRef.current.length < MAX_DRIPS) {\n        d.dwell = 0;\n        let best = -1;\n        let bestD = R * 1.3;\n        for (let k = 0; k < L.edges.length; k += 2) {\n          const dd = Math.hypot(L.edges[k] - d.x, L.edges[k + 1] - d.y);\n          if (\n            dd < bestD &&\n            !dripsRef.current.some((q) => Math.abs(q.x - L.edges[k]) < 4)\n          ) {\n            bestD = dd;\n            best = k;\n          }\n        }\n        if (best >= 0) {\n          const rand = randRef.current;\n          const scale = clamp(L.fs / 120, 0.75, 1.5);\n          const len = (6 + rand() * 12) * scale;\n          dripsRef.current.push({\n            x: L.edges[best],\n            y: L.edges[best + 1] - L.fs * 0.03,\n            len,\n            w: (1.4 + rand() * 1.2) * scale,\n            start: clockRef.current,\n            dur: 0.8 + (len / (18 * scale)) * 0.9,\n            drawn: 0,\n          });\n        }\n      }\n    },\n    [sprayAt],\n  );\n\n  // ---------------------------------------------------------------- loop\n  const tick = React.useCallback(\n    (elapsed: number) => {\n      if (elapsed < lastElapsedRef.current) lastElapsedRef.current = elapsed;\n      const dt = Math.min(elapsed - lastElapsedRef.current, 0.05);\n      lastElapsedRef.current = elapsed;\n      const L = layoutRef.current;\n      if (!L || dt <= 0) return;\n\n      const drag = dragRef.current;\n      let changed = false;\n\n      if (!introDoneRef.current) {\n        const t0 = clockRef.current;\n        const t1 = t0 + dt;\n        clockRef.current = t1;\n        // Intro sweeps overlapping (t0, t1].\n        for (let i = 0; i < L.passCount; i++) {\n          const ps = START + i * (L.passDur + GAP);\n          const a = clamp((t0 - ps) / L.passDur, 0, 1);\n          const b = clamp((t1 - ps) / L.passDur, 0, 1);\n          if (b > a) {\n            const ease = (v: number) => 0.5 - 0.5 * Math.cos(Math.PI * v);\n            sprayAlong(L, i, ease(a), ease(b));\n          }\n        }\n        if (\n          t1 >= L.liftAt + LIFT_DUR &&\n          dripsRef.current.every((d) => t1 >= d.start + d.dur)\n        ) {\n          introDoneRef.current = true;\n        }\n        changed = true;\n      } else {\n        clockRef.current += dt;\n      }\n\n      if (growDrips(L, clockRef.current, false)) changed = true;\n\n      if (drag.active) {\n        sprayDrag(L, dt);\n        changed = true;\n      }\n\n      if (!changed && !dirtyRef.current) return;\n      // With the plate gone, a hand-held can mists the surround more heavily.\n      flush(L, drag.active && introDoneRef.current ? OVERSPRAY * 2 : OVERSPRAY);\n      render();\n      dirtyRef.current = false;\n    },\n    [sprayAlong, growDrips, sprayDrag, flush, render],\n  );\n\n  const containerRef = useVisibilityPause<HTMLDivElement>(tick, {\n    paused: reducedMotion,\n  });\n\n  // ---------------------------------------------------------------- layout\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const container = containerRef.current;\n    if (!canvas || !container) return;\n    let cancelled = false;\n    let lastW = 0;\n    let lastH = 0;\n\n    const build = (force: boolean) => {\n      if (cancelled) return;\n      const w = Math.max(container.clientWidth, 1);\n      const h = Math.max(container.clientHeight, 1);\n      if (!force && w === lastW && h === lastH) return;\n      lastW = w;\n      lastH = h;\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const W = Math.round(w * dpr);\n      const H = Math.round(h * dpr);\n      canvas.width = W;\n      canvas.height = H;\n\n      const prev = layoutRef.current;\n      const mk = (c?: HTMLCanvasElement) => {\n        const el = c ?? document.createElement(\"canvas\");\n        el.width = W;\n        el.height = H;\n        return el;\n      };\n      const maskC = mk(prev?.maskC);\n      const mctx = maskC.getContext(\"2d\", { willReadFrequently: true });\n      if (!mctx) return;\n\n      // Fit: one line, or a balanced two-line wrap if that renders larger.\n      const probe = 100;\n      const font = (px: number) => `800 ${px}px ${fontFamily}`;\n      const setSpacing = (px: number) => {\n        if (\"letterSpacing\" in mctx) {\n          (\n            mctx as CanvasRenderingContext2D & { letterSpacing: string }\n          ).letterSpacing = `${px}px`;\n        }\n      };\n      mctx.setTransform(1, 0, 0, 1, 0, 0);\n      mctx.clearRect(0, 0, W, H);\n      mctx.font = font(probe);\n      setSpacing(probe * 0.03);\n      const target = fontSize ?? Math.min(h * 0.48, 200);\n      const fit = (lines: string[]) => {\n        const widest =\n          Math.max(...lines.map((l) => mctx.measureText(l).width), 1) / probe;\n        // Plate padding: 0.32em each side, 0.24em top/bottom.\n        return Math.min(\n          target,\n          (w * 0.94) / (widest + 0.64),\n          (h * 0.92) / (lines.length * 1.04 + 0.5),\n        );\n      };\n      let lines = [text];\n      let fs = fit(lines);\n      const words = text.split(\" \");\n      if (words.length > 1) {\n        let best: string[] | null = null;\n        let bestDiff = Infinity;\n        for (let i = 1; i < words.length; i++) {\n          const a = words.slice(0, i).join(\" \");\n          const b = words.slice(i).join(\" \");\n          const diff = Math.abs(\n            mctx.measureText(a).width - mctx.measureText(b).width,\n          );\n          if (diff < bestDiff) {\n            bestDiff = diff;\n            best = [a, b];\n          }\n        }\n        if (best) {\n          const fs2 = fit(best);\n          if (fs2 > fs * 1.25) {\n            lines = best;\n            fs = fs2;\n          }\n        }\n      }\n\n      mctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      mctx.font = font(fs);\n      setSpacing(fs * 0.03);\n      mctx.textAlign = \"center\";\n      mctx.textBaseline = \"alphabetic\";\n      const m = mctx.measureText(lines.join(\"\"));\n      const ascent = m.actualBoundingBoxAscent || fs * 0.74;\n      const descent = m.actualBoundingBoxDescent || fs * 0.04;\n      const lh = fs * 1.04;\n      const blockH = (lines.length - 1) * lh + ascent + descent;\n      const top = (h - blockH) / 2;\n      mctx.fillStyle = \"#fff\";\n      let bx0 = w;\n      let bx1 = 0;\n      lines.forEach((line, i) => {\n        // letterSpacing trails the last glyph — nudge back by half to stay centered.\n        mctx.fillText(line, w / 2 + fs * 0.015, top + ascent + i * lh);\n        const lw = mctx.measureText(line).width;\n        bx0 = Math.min(bx0, w / 2 - lw / 2);\n        bx1 = Math.max(bx1, w / 2 + lw / 2);\n      });\n      const box = { x0: bx0, y0: top, x1: bx1, y1: top + blockH };\n\n      // CSS-px mask for bridging + drip spots.\n      const readMask = () => {\n        const data = mctx.getImageData(0, 0, W, H).data;\n        const mask = new Uint8Array(w * h);\n        for (let y = 0; y < h; y++) {\n          const sy = Math.min(H - 1, Math.round((y + 0.5) * dpr - 0.5));\n          for (let x = 0; x < w; x++) {\n            const sx = Math.min(W - 1, Math.round((x + 0.5) * dpr - 0.5));\n            mask[y * w + x] = data[(sy * W + sx) * 4 + 3] > 110 ? 1 : 0;\n          }\n        }\n        return mask;\n      };\n      let mask = readMask();\n\n      // Stencil bridges: any enclosed counter would fall out of a real plate,\n      // so cut a tie above and below each one.\n      {\n        const N = w * h;\n        const outside = new Uint8Array(N);\n        const queue = new Int32Array(N);\n        let qh = 0;\n        let qt = 0;\n        const push = (i: number) => {\n          if (!mask[i] && !outside[i]) {\n            outside[i] = 1;\n            queue[qt++] = i;\n          }\n        };\n        for (let x = 0; x < w; x++) {\n          push(x);\n          push((h - 1) * w + x);\n        }\n        for (let y = 0; y < h; y++) {\n          push(y * w);\n          push(y * w + w - 1);\n        }\n        while (qh < qt) {\n          const i = queue[qh++];\n          const x = i % w;\n          if (x > 0) push(i - 1);\n          if (x < w - 1) push(i + 1);\n          if (i >= w) push(i - w);\n          if (i < N - w) push(i + w);\n        }\n        const seen = new Uint8Array(N);\n        const bw = Math.max(1.6, fs * 0.06);\n        const minArea = Math.max(6, fs * fs * 0.0015);\n        const strips: [number, number, number, number][] = [];\n        for (let s = 0; s < N; s++) {\n          if (mask[s] || outside[s] || seen[s]) continue;\n          // Flood one counter.\n          qh = 0;\n          qt = 0;\n          seen[s] = 1;\n          queue[qt++] = s;\n          let x0 = w;\n          let x1 = 0;\n          let y0 = h;\n          let y1 = 0;\n          let sumX = 0;\n          while (qh < qt) {\n            const i = queue[qh++];\n            const x = i % w;\n            const y = (i - x) / w;\n            sumX += x;\n            if (x < x0) x0 = x;\n            if (x > x1) x1 = x;\n            if (y < y0) y0 = y;\n            if (y > y1) y1 = y;\n            const nb = [\n              x > 0 ? i - 1 : -1,\n              x < w - 1 ? i + 1 : -1,\n              i >= w ? i - w : -1,\n              i < N - w ? i + w : -1,\n            ];\n            for (const j of nb) {\n              if (j >= 0 && !mask[j] && !outside[j] && !seen[j]) {\n                seen[j] = 1;\n                queue[qt++] = j;\n              }\n            }\n          }\n          if (qt < minArea) continue;\n          const cx = Math.round((sumX / qt + (x0 + x1) / 2) / 2);\n          // Find the column's top-most counter pixel, then cut upward through ink.\n          const colTop = (() => {\n            for (let y = y0; y <= y1; y++)\n              if (seen[y * w + cx] && !mask[y * w + cx]) return y;\n            return y0;\n          })();\n          const colBot = (() => {\n            for (let y = y1; y >= y0; y--)\n              if (seen[y * w + cx] && !mask[y * w + cx]) return y;\n            return y1;\n          })();\n          let y = colTop - 1;\n          while (y > 0 && mask[y * w + cx]) y--;\n          strips.push([cx - bw / 2, y, bw, colTop - y + 1]);\n          y = colBot + 1;\n          while (y < h - 1 && mask[y * w + cx]) y++;\n          strips.push([cx - bw / 2, colBot, bw, y - colBot + 1]);\n        }\n        if (strips.length) {\n          for (const [sx, sy, sw, sh] of strips) mctx.clearRect(sx, sy, sw, sh);\n          mask = readMask();\n        }\n      }\n\n      // Bottom-edge drip candidates.\n      const edges: number[] = [];\n      const stepX = Math.max(1, Math.round(fs * 0.02));\n      for (\n        let y = Math.max(1, Math.floor(box.y0));\n        y < Math.min(h - 3, Math.ceil(box.y1) + 2);\n        y++\n      ) {\n        for (let x = 1; x < w - 1; x += stepX) {\n          const i = y * w + x;\n          if (!(\n            mask[i] &&\n            mask[i - 1] &&\n            mask[i + 1] &&\n            !mask[i + w] &&\n            !mask[i + 2 * w]\n          ))\n            continue;\n          // Only the letters' true feet — no ink below in this column for half an em.\n          let clear = true;\n          for (let k = 2; k < fs * 0.9 && y + k < h; k++)\n            if (mask[i + k * w]) clear = false;\n          if (clear) edges.push(x, y);\n        }\n      }\n\n      // Padded masks: hard (drips creep ~3px) and soft (overspray falloff).\n      const stamp = (\n        c: HTMLCanvasElement,\n        radii: number[],\n        alphas: number[],\n      ) => {\n        const ctx = c.getContext(\"2d\")!;\n        ctx.setTransform(1, 0, 0, 1, 0, 0);\n        ctx.clearRect(0, 0, W, H);\n        radii.forEach((r, ri) => {\n          ctx.globalAlpha = alphas[ri];\n          for (let a = 0; a < 12; a++) {\n            const ang = (a / 12) * Math.PI * 2;\n            ctx.drawImage(\n              maskC,\n              Math.cos(ang) * r * dpr,\n              Math.sin(ang) * r * dpr,\n            );\n          }\n        });\n        ctx.globalAlpha = 1;\n        ctx.drawImage(maskC, 0, 0);\n      };\n      const padHardC = mk(prev?.padHardC);\n      stamp(padHardC, [1.5, 3], [1, 1]);\n      const padSoftC = mk(prev?.padSoftC);\n      const sr = clamp(fs * 0.055, 3, 10);\n      stamp(padSoftC, [sr, sr * 0.66, sr * 0.33], [0.06, 0.1, 0.22]);\n\n      // The plate: graphite with lighter edges, cut letters and two hanging holes.\n      const padX = fs * 0.32;\n      const padY = fs * 0.24;\n      const plateRect = {\n        x: box.x0 - padX,\n        y: box.y0 - padY,\n        w: box.x1 - box.x0 + padX * 2,\n        h: box.y1 - box.y0 + padY * 2,\n      };\n      const plateC = mk(prev?.plateC);\n      {\n        const ctx = cssCtx(plateC, dpr);\n        ctx.clearRect(0, 0, w, h);\n        const { x, y, w: pw, h: ph } = plateRect;\n        const rr = Math.min(8, fs * 0.06);\n        const path = new Path2D();\n        path.roundRect(x, y, pw, ph, rr);\n        ctx.fillStyle = plate;\n        ctx.fill(path);\n        ctx.save();\n        ctx.clip(path);\n        const g = ctx.createLinearGradient(0, y, 0, y + ph);\n        g.addColorStop(0, \"rgba(255,255,255,0.07)\");\n        g.addColorStop(0.5, \"rgba(255,255,255,0)\");\n        g.addColorStop(1, \"rgba(0,0,0,0.18)\");\n        ctx.fillStyle = g;\n        ctx.fillRect(x, y, pw, ph);\n        // Milled-sheet speckle.\n        const rand = mulberry32(seed * 53 + 3);\n        const specks = Math.round((pw * ph) / 90);\n        for (let i = 0; i < specks; i++) {\n          ctx.fillStyle =\n            rand() < 0.5 ? \"rgba(255,255,255,0.035)\" : \"rgba(0,0,0,0.08)\";\n          ctx.fillRect(x + rand() * pw, y + rand() * ph, 1, 1);\n        }\n        // Lighter worn edges.\n        ctx.lineWidth = 2;\n        ctx.strokeStyle = \"rgba(255,255,255,0.09)\";\n        ctx.stroke(path);\n        ctx.restore();\n        ctx.lineWidth = 1;\n        ctx.strokeStyle = \"rgba(255,255,255,0.14)\";\n        const inner = new Path2D();\n        inner.roundRect(x + 0.5, y + 0.5, pw - 1, ph - 1, rr);\n        ctx.stroke(inner);\n        // Cut the letters (and two hanging holes) out of the sheet.\n        ctx.globalCompositeOperation = \"destination-out\";\n        ctx.setTransform(1, 0, 0, 1, 0, 0);\n        ctx.drawImage(maskC, 0, 0);\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n        const hr = clamp(fs * 0.035, 2, 6);\n        for (const hx of [x + padX * 0.42, x + pw - padX * 0.42]) {\n          ctx.beginPath();\n          ctx.arc(hx, y + padY * 0.48, hr, 0, Math.PI * 2);\n          ctx.fill();\n        }\n        ctx.globalCompositeOperation = \"source-over\";\n      }\n      // Cut-face rim: the plate's far edge of each cutout catches the light.\n      const tmpC = mk(prev?.tmpC);\n      {\n        const t = tmpC.getContext(\"2d\")!;\n        t.setTransform(1, 0, 0, 1, 0, 0);\n        t.globalCompositeOperation = \"copy\";\n        t.drawImage(maskC, 1.2 * dpr, 1.2 * dpr);\n        t.globalCompositeOperation = \"destination-out\";\n        t.drawImage(maskC, 0, 0);\n        t.globalCompositeOperation = \"source-in\";\n        t.fillStyle = \"rgba(255,255,255,0.2)\";\n        t.fillRect(0, 0, W, H);\n        t.globalCompositeOperation = \"source-over\";\n        const p = plateC.getContext(\"2d\")!;\n        p.setTransform(1, 0, 0, 1, 0, 0);\n        p.globalCompositeOperation = \"source-atop\";\n        p.drawImage(tmpC, 0, 0);\n        p.globalCompositeOperation = \"source-over\";\n      }\n      // Plate thickness: hairline shadow inside each cutout's top-left edge.\n      const cutShadowC = mk(prev?.cutShadowC);\n      {\n        const c = cutShadowC.getContext(\"2d\")!;\n        c.setTransform(1, 0, 0, 1, 0, 0);\n        c.globalCompositeOperation = \"copy\";\n        c.drawImage(maskC, 0, 0);\n        c.globalCompositeOperation = \"destination-out\";\n        c.drawImage(maskC, 2 * dpr, 2 * dpr);\n        c.globalCompositeOperation = \"source-in\";\n        c.fillStyle = \"rgba(0,0,0,0.6)\";\n        c.fillRect(0, 0, W, H);\n        c.globalCompositeOperation = \"source-over\";\n      }\n\n      // Soft spray sprite in the paint color.\n      const blob = prev?.blob ?? document.createElement(\"canvas\");\n      blob.width = blob.height = 64;\n      {\n        const b = blob.getContext(\"2d\")!;\n        b.clearRect(0, 0, 64, 64);\n        // Solid paint, alpha-shaped below — a color gradient to transparent\n        // black would leave a dark fringe on every splat.\n        b.fillStyle = paint;\n        b.globalAlpha = 1;\n        b.fillRect(0, 0, 64, 64);\n        // Feather the core so the coat builds softly.\n        b.globalCompositeOperation = \"destination-in\";\n        const f = b.createRadialGradient(32, 32, 0, 32, 32, 32);\n        f.addColorStop(0, \"rgba(0,0,0,1)\");\n        f.addColorStop(0.5, \"rgba(0,0,0,0.55)\");\n        f.addColorStop(1, \"rgba(0,0,0,0)\");\n        b.fillStyle = f;\n        b.fillRect(0, 0, 64, 64);\n        b.globalCompositeOperation = \"source-over\";\n      }\n\n      const passTotal = passCount * lines.length;\n      const R = Math.max(((box.y1 - box.y0) / passTotal) * 1.3, fs * 0.28);\n      const passDur = clamp(0.5 + (box.x1 - box.x0) / 1500, 0.6, 1.05);\n      const sprayEnd = START + passTotal * (passDur + GAP) - GAP;\n\n      const L: Layout = {\n        w,\n        h,\n        dpr,\n        fs,\n        lines: lines.length,\n        mask,\n        box,\n        plateRect,\n        edges,\n        maskC,\n        padSoftC,\n        padHardC,\n        plateC,\n        cutShadowC,\n        wordC: mk(prev?.wordC),\n        platePaintC: mk(prev?.platePaintC),\n        freshC: mk(prev?.freshC),\n        coatC: mk(prev?.coatC),\n        dripFreshC: mk(prev?.dripFreshC),\n        tmpC,\n        blob,\n        R,\n        passDur,\n        passCount: passTotal,\n        sprayEnd,\n        liftAt: sprayEnd + SETTLE,\n      };\n      layoutRef.current = L;\n\n      if (reducedRef.current || introDoneRef.current) {\n        introDoneRef.current = true;\n        paintInstant(L);\n      } else {\n        // (Re)start the intro against the new geometry.\n        clearPaint(L);\n        randRef.current = mulberry32(seed);\n        clockRef.current = 0;\n        planDrips(L);\n      }\n      dirtyRef.current = true;\n      render();\n    };\n\n    const ro = new ResizeObserver(() => build(false));\n    ro.observe(container);\n    build(true);\n    // Re-measure once web fonts land so the mask matches the final glyphs.\n    document.fonts?.ready.then(() => build(true)).catch(() => {});\n\n    return () => {\n      cancelled = true;\n      ro.disconnect();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [\n    text,\n    fontSize,\n    fontFamily,\n    paint,\n    plate,\n    passCount,\n    seed,\n    paintInstant,\n    planDrips,\n    render,\n  ]);\n\n  // Reduced motion flipping on mid-intro: jump to the finished word.\n  React.useEffect(() => {\n    const L = layoutRef.current;\n    if (!reducedMotion || !L || introDoneRef.current) return;\n    introDoneRef.current = true;\n    paintInstant(L);\n    render();\n  }, [reducedMotion, paintInstant, render]);\n\n  const replay = React.useCallback(() => {\n    const L = layoutRef.current;\n    if (!L) return;\n    if (reducedRef.current) {\n      paintInstant(L);\n      render();\n      return;\n    }\n    clearPaint(L);\n    randRef.current = mulberry32(seed);\n    clockRef.current = 0;\n    introDoneRef.current = false;\n    planDrips(L);\n    dirtyRef.current = true;\n    render();\n  }, [paintInstant, planDrips, render, seed]);\n\n  React.useImperativeHandle(ref, () => ({ replay }), [replay]);\n\n  // ---------------------------------------------------------------- input\n  const local = (event: React.PointerEvent<HTMLDivElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect();\n    return [event.clientX - rect.left, event.clientY - rect.top] as const;\n  };\n\n  const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerDown?.(event);\n    if (event.defaultPrevented || event.button > 0) return;\n    const [x, y] = local(event);\n    event.currentTarget.setPointerCapture?.(event.pointerId);\n    dragRef.current = {\n      active: true,\n      id: event.pointerId,\n      x,\n      y,\n      lx: x,\n      ly: y,\n      dwell: 0,\n    };\n    if (reducedRef.current) {\n      const L = layoutRef.current;\n      if (!L) return;\n      sprayDrag(L, 1 / 60);\n      flush(L, OVERSPRAY * 2);\n      render();\n    }\n  };\n\n  const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {\n    onPointerMove?.(event);\n    const d = dragRef.current;\n    if (!d.active || d.id !== event.pointerId) return;\n    const [x, y] = local(event);\n    d.x = x;\n    d.y = y;\n    if (reducedRef.current) {\n      const L = layoutRef.current;\n      if (!L) return;\n      sprayDrag(L, 1 / 60);\n      flush(L, OVERSPRAY * 2);\n      render();\n    }\n  };\n\n  const endDrag = (event: React.PointerEvent<HTMLDivElement>) => {\n    if (dragRef.current.id === event.pointerId) dragRef.current.active = false;\n  };\n\n  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(event);\n    if (event.defaultPrevented) return;\n    if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault();\n      replay();\n    }\n  };\n\n  return (\n    <div\n      ref={containerRef}\n      data-crucible=\"stencil\"\n      role=\"group\"\n      tabIndex={0}\n      aria-roledescription=\"spray-painted headline\"\n      aria-label={`${text} — drag to add paint, press Enter to spray it again`}\n      onPointerDown={handlePointerDown}\n      onPointerMove={handlePointerMove}\n      onPointerUp={(e) => {\n        onPointerUp?.(e);\n        endDrag(e);\n      }}\n      onPointerCancel={(e) => {\n        onPointerCancel?.(e);\n        endDrag(e);\n      }}\n      onKeyDown={handleKeyDown}\n      className={cn(\n        \"relative block h-full min-h-40 w-full cursor-crosshair touch-none overflow-hidden rounded-md outline-none select-none focus-visible:ring-2 focus-visible:ring-white/40\",\n        className,\n      )}\n      {...props}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"absolute inset-0 h-full w-full\"\n      />\n      {/* Real text for screen readers + SEO — the canvas is purely presentational. */}\n      <Tag className=\"sr-only\">{text}</Tag>\n    </div>\n  );\n}\n"
    }
  ]
}