{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "reveal-ripple-tiles",
  "title": "Reveal Ripple Tiles",
  "description": "Media reveal driven by a height-field water sim: a ripple front sweeps from the trigger point, popping grid tiles open as the wave crosses them and refracting revealed tiles like wet glass; the cursor keeps stirring the water after full reveal.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/reveal-ripple-tiles/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// RippleUnfold — media behind a coarse tile grid. A height-field wave sim\n// sweeps a ripple front out from the trigger point (scroll-into-view or first\n// pointer entry): tiles pop open when local amplitude crosses a threshold,\n// and revealed tiles refract their source rect along the wave gradient like\n// wet glass. After full reveal the cursor keeps perturbing the water.\n// Single DPR-aware Canvas 2D, direct-DOM rAF loop that sleeps when the field\n// and every tile spring settle. All drawn ink derives from CSS tokens at\n// mount and re-derives live on documentElement class changes.\n// ---------------------------------------------------------------------------\n\ntype RGB = [number, number, number];\n\ninterface Palette {\n  background: RGB;\n  foreground: RGB;\n  surface: RGB;\n  border: RGB;\n  muted: RGB;\n  accent: RGB;\n}\n\nconst FALLBACK_DARK: Palette = {\n  background: [10, 10, 10],\n  foreground: [237, 237, 237],\n  surface: [23, 23, 23],\n  border: [46, 46, 46],\n  muted: [143, 143, 143],\n  accent: [0, 107, 255],\n};\n\nconst FALLBACK_LIGHT: Palette = {\n  background: [255, 255, 255],\n  foreground: [23, 23, 23],\n  surface: [250, 250, 250],\n  border: [235, 235, 235],\n  muted: [143, 143, 143],\n  accent: [0, 107, 255],\n};\n\nconst EPS = 0.004; // dual sleep epsilon for max|h| and max|v|\nconst SPRING_K = 90; // s^-2\nconst SPRING_C = 2 * 0.7 * Math.sqrt(SPRING_K); // zeta = 0.7\nconst SQUASH_MS = 320;\nconst DT_MAX = 0.033;\n\nfunction css(c: RGB, a: number) {\n  return `rgba(${c[0]},${c[1]},${c[2]},${a})`;\n}\n\n// resolve a CSS custom property to rgb via an in-document probe element\nfunction readToken(probe: HTMLElement, token: string, fallback: RGB): RGB {\n  probe.style.color = \"\";\n  probe.style.color = `var(${token})`;\n  const raw = getComputedStyle(probe).color;\n  let m = raw.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/);\n  if (m) return [+m[1]!, +m[2]!, +m[3]!];\n  m = raw.match(/color\\(srgb\\s+([\\d.]+)\\s+([\\d.]+)\\s+([\\d.]+)/);\n  if (m) {\n    return [\n      Math.round(+m[1]! * 255),\n      Math.round(+m[2]! * 255),\n      Math.round(+m[3]! * 255),\n    ];\n  }\n  return fallback;\n}\n\n// cubic-bezier(0.22, 1, 0.36, 1) solved via Newton–Raphson\nfunction makeBezier(p1x: number, p1y: number, p2x: number, p2y: number) {\n  const cx = 3 * p1x;\n  const bx = 3 * (p2x - p1x) - cx;\n  const ax = 1 - cx - bx;\n  const cy = 3 * p1y;\n  const by = 3 * (p2y - p1y) - cy;\n  const ay = 1 - cy - by;\n  const sampleX = (t: number) => ((ax * t + bx) * t + cx) * t;\n  const sampleY = (t: number) => ((ay * t + by) * t + cy) * t;\n  const slopeX = (t: number) => (3 * ax * t + 2 * bx) * t + cx;\n  return (x: number) => {\n    if (x <= 0) return 0;\n    if (x >= 1) return 1;\n    let t = x;\n    for (let i = 0; i < 6; i++) {\n      const s = slopeX(t);\n      if (Math.abs(s) < 1e-6) break;\n      t -= (sampleX(t) - x) / s;\n    }\n    return sampleY(Math.min(1, Math.max(0, t)));\n  };\n}\nconst glideEase = makeBezier(0.22, 1, 0.36, 1);\n\nexport function RippleUnfold({\n  src,\n  tileSize = 32,\n  threshold = 0.12,\n  refraction = 6,\n  impulse = 0.35,\n  className = \"aspect-[16/10]\",\n  \"aria-label\": ariaLabel = \"Media revealed by a ripple wave\",\n}: {\n  /** image URL; omitted → a token-derived generative artwork is drawn */\n  src?: string;\n  /** target tile edge in px; cols/rows derive from container size */\n  tileSize?: number;\n  /** |h| a tile must see before it pops open */\n  threshold?: number;\n  /** max source-rect refraction offset in px per unit wave gradient */\n  refraction?: number;\n  /** wave height injected per cursor move after full reveal */\n  impulse?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the reveal. Default \"Media revealed by a ripple wave\". */\n  \"aria-label\"?: string;\n}) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    const off = document.createElement(\"canvas\");\n    const octx = off.getContext(\"2d\");\n    if (!octx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    // hot-path state — locals only, the rAF loop is the sole writer\n    let w = 0;\n    let h = 0;\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let tw = 0;\n    let th = 0;\n    let total = 0;\n    let hf = new Float32Array(0); // wave height\n    let vf = new Float32Array(0); // wave velocity\n    let stateArr = new Uint8Array(0); // 0 closed, 1 open/scheduled\n    let scaleArr = new Float32Array(0); // tile spring scale\n    let svelArr = new Float32Array(0); // tile spring velocity\n    let openAtArr = new Float64Array(0); // timestamp the tile starts opening\n    let openedCount = 0;\n    let triggered = false;\n    let trigCol = 0;\n    let trigRow = 0;\n    let maxH = 0;\n    let maxV = 0;\n    let pendingPointer = -1; // cell index — throttles impulses to one/frame\n    let raf = 0;\n    let last = 0;\n    let visible = true;\n    let disposed = false;\n    let img: HTMLImageElement | null = null;\n    let imgReady = false;\n\n    // theme ink — derived from CSS tokens, re-derived on theme class change\n    let pal: Palette = FALLBACK_DARK;\n    let borderStroke = \"\";\n    let fgFaint = \"\";\n    let accentPrefix = \"\";\n\n    const deriveColors = () => {\n      const dark = document.documentElement.classList.contains(\"dark\");\n      const fb = dark ? FALLBACK_DARK : FALLBACK_LIGHT;\n      const probe = document.createElement(\"span\");\n      probe.style.display = \"none\";\n      root.appendChild(probe);\n      pal = {\n        background: readToken(probe, \"--background\", fb.background),\n        foreground: readToken(probe, \"--foreground\", fb.foreground),\n        surface: readToken(probe, \"--surface\", fb.surface),\n        border: readToken(probe, \"--border\", fb.border),\n        muted: readToken(probe, \"--ns-muted\", fb.muted),\n        accent: readToken(probe, \"--ns-accent\", fb.accent),\n      };\n      probe.remove();\n      borderStroke = css(pal.border, 1);\n      fgFaint = css(pal.foreground, 0.05);\n      accentPrefix = `rgba(${pal.accent[0]},${pal.accent[1]},${pal.accent[2]},`;\n    };\n\n    // token-derived generative artwork used when no src is provided —\n    // monochrome orbital study so the demo needs no network fetch\n    const paintArtwork = () => {\n      const W = off.width;\n      const H = off.height;\n      if (W < 2 || H < 2) return;\n      const o = octx;\n      const m = Math.min(W, H);\n      o.setTransform(1, 0, 0, 1, 0, 0);\n      o.fillStyle = css(pal.surface, 1);\n      o.fillRect(0, 0, W, H);\n\n      // diagonal hatch field, lower-left\n      o.strokeStyle = css(pal.foreground, 0.05);\n      o.lineWidth = Math.max(1, m / 640);\n      o.beginPath();\n      const hatch = m * 0.022;\n      for (let x = -H; x < W * 0.6; x += hatch) {\n        o.moveTo(x, H);\n        o.lineTo(x + H * 0.9, H * 0.1);\n      }\n      o.stroke();\n\n      // concentric orbit rings\n      const ocx = W * 0.63;\n      const ocy = H * 0.4;\n      for (let i = 0; i < 7; i++) {\n        o.strokeStyle = css(pal.muted, 0.55 - i * 0.065);\n        o.lineWidth = Math.max(1, m / 520);\n        o.beginPath();\n        o.arc(ocx, ocy, m * (0.07 + 0.075 * i), 0, Math.PI * 2);\n        o.stroke();\n      }\n      // core disc + satellite on the third ring\n      o.fillStyle = css(pal.foreground, 0.92);\n      o.beginPath();\n      o.arc(ocx, ocy, m * 0.048, 0, Math.PI * 2);\n      o.fill();\n      const orbitR = m * (0.07 + 0.075 * 3);\n      o.beginPath();\n      o.arc(\n        ocx + Math.cos(-0.6) * orbitR,\n        ocy + Math.sin(-0.6) * orbitR,\n        m * 0.016,\n        0,\n        Math.PI * 2\n      );\n      o.fill();\n\n      // instrumentation baseline + ticks\n      const by = H * 0.82;\n      o.strokeStyle = css(pal.muted, 0.7);\n      o.lineWidth = Math.max(1, m / 640);\n      o.beginPath();\n      o.moveTo(W * 0.08, by);\n      o.lineTo(W * 0.92, by);\n      o.stroke();\n      o.strokeStyle = css(pal.foreground, 0.6);\n      o.beginPath();\n      for (let i = 0; i <= 12; i++) {\n        const tx = W * 0.08 + (W * 0.84 * i) / 12;\n        o.moveTo(tx, by - m * 0.012);\n        o.lineTo(tx, by + m * 0.012);\n      }\n      o.stroke();\n\n      // inset frame\n      o.strokeStyle = css(pal.muted, 0.3);\n      o.lineWidth = Math.max(1, m / 640);\n      o.strokeRect(m * 0.035, m * 0.035, W - m * 0.07, H - m * 0.07);\n    };\n\n    const paintOffscreen = () => {\n      if (off.width < 2 || off.height < 2) return;\n      if (imgReady && img && img.naturalWidth > 0 && img.naturalHeight > 0) {\n        const sc = Math.max(\n          off.width / img.naturalWidth,\n          off.height / img.naturalHeight\n        );\n        const dw = img.naturalWidth * sc;\n        const dh = img.naturalHeight * sc;\n        octx.setTransform(1, 0, 0, 1, 0, 0);\n        octx.fillStyle = css(pal.surface, 1);\n        octx.fillRect(0, 0, off.width, off.height);\n        octx.drawImage(img, (off.width - dw) / 2, (off.height - dh) / 2, dw, dh);\n        return;\n      }\n      paintArtwork();\n    };\n\n    const revealAll = () => {\n      for (let idx = 0; idx < total; idx++) {\n        stateArr[idx] = 1;\n        scaleArr[idx] = 1;\n        svelArr[idx] = 0;\n        openAtArr[idx] = -1e6; // squash long finished\n      }\n      openedCount = total;\n    };\n\n    const rebuildGrid = () => {\n      cols = Math.max(2, Math.min(96, Math.round(w / Math.max(8, tileSize))));\n      rows = Math.max(2, Math.min(96, Math.round(h / Math.max(8, tileSize))));\n      tw = w / cols;\n      th = h / rows;\n      total = cols * rows;\n      hf = new Float32Array(total);\n      vf = new Float32Array(total);\n      stateArr = new Uint8Array(total);\n      scaleArr = new Float32Array(total);\n      svelArr = new Float32Array(total);\n      openAtArr = new Float64Array(total);\n      openedCount = 0;\n      maxH = 0;\n      maxV = 0;\n      pendingPointer = -1;\n      // resizing mid-reveal restarts nothing: an already-triggered surface\n      // settles straight to fully revealed\n      if (triggered) revealAll();\n    };\n\n    const resize = () => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        // zero-size guard — no canvas work until we have real bounds\n        w = 0;\n        h = 0;\n        total = 0;\n        return;\n      }\n      w = rect.width;\n      h = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(w * dpr));\n      canvas.height = Math.max(1, Math.round(h * dpr));\n      off.width = canvas.width;\n      off.height = canvas.height;\n      paintOffscreen();\n      rebuildGrid();\n    };\n\n    // classic discrete wave equation, Neumann edges, damping 0.985/step\n    // (0.95/step once every tile has opened — nothing is visibly changing\n    // by then, so the idle tail decays below EPS in under a second instead\n    // of several)\n    const step = () => {\n      const c = cols;\n      const damping = openedCount >= total ? 0.95 : 0.985;\n      for (let j = 0; j < rows; j++) {\n        const row = j * c;\n        for (let i = 0; i < c; i++) {\n          const idx = row + i;\n          const hc = hf[idx]!;\n          const l = i > 0 ? hf[idx - 1]! : hc;\n          const r = i < c - 1 ? hf[idx + 1]! : hc;\n          const u = j > 0 ? hf[idx - c]! : hc;\n          const d = j < rows - 1 ? hf[idx + c]! : hc;\n          vf[idx] = (vf[idx]! + ((l + r + u + d) * 0.25 - hc) * 0.5) * damping;\n        }\n      }\n      maxH = 0;\n      maxV = 0;\n      for (let idx = 0; idx < total; idx++) {\n        const nh = hf[idx]! + vf[idx]!;\n        hf[idx] = nh;\n        const ah = nh < 0 ? -nh : nh;\n        const av = vf[idx]! < 0 ? -vf[idx]! : vf[idx]!;\n        if (ah > maxH) maxH = ah;\n        if (av > maxV) maxV = av;\n      }\n    };\n\n    const openTile = (idx: number, at: number) => {\n      stateArr[idx] = 1;\n      scaleArr[idx] = 0.6;\n      svelArr[idx] = 0;\n      openAtArr[idx] = at;\n      openedCount++;\n    };\n\n    // safeguard: a coarse wave can die below threshold before reaching the\n    // far corners — schedule the stragglers outward from the trigger cell\n    const flush = (now: number) => {\n      for (let j = 0; j < rows; j++) {\n        for (let i = 0; i < cols; i++) {\n          const idx = j * cols + i;\n          if (stateArr[idx] === 0) {\n            openTile(idx, now + Math.hypot(i - trigCol, j - trigRow) * 16);\n          }\n        }\n      }\n    };\n\n    // draw + tile springs; returns whether anything still animates\n    const render = (now: number, dt: number): boolean => {\n      let animating = false;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, w, h);\n      ctx.lineWidth = 1;\n      const offW = off.width;\n      const offH = off.height;\n      const swD = tw * dpr;\n      const shD = th * dpr;\n      const maxSx = Math.max(0, offW - swD);\n      const maxSy = Math.max(0, offH - shD);\n\n      for (let j = 0; j < rows; j++) {\n        for (let i = 0; i < cols; i++) {\n          const idx = j * cols + i;\n          const x = i * tw;\n          const y = j * th;\n          const opened = stateArr[idx] === 1 && now >= openAtArr[idx]!;\n\n          if (!opened) {\n            if (stateArr[idx] === 1) animating = true; // scheduled, waiting\n            // closed tile: faint fg lift + border hairline + accent shimmer\n            ctx.fillStyle = fgFaint;\n            ctx.fillRect(x + 0.5, y + 0.5, tw - 1, th - 1);\n            const amp = Math.abs(hf[idx] ?? 0);\n            if (amp > 0.012) {\n              ctx.fillStyle =\n                accentPrefix + Math.min(0.4, amp * 0.85).toFixed(3) + \")\";\n              ctx.fillRect(x + 0.5, y + 0.5, tw - 1, th - 1);\n            }\n            ctx.strokeStyle = borderStroke;\n            ctx.strokeRect(x + 0.5, y + 0.5, tw - 1, th - 1);\n            continue;\n          }\n\n          // open spring: k = 90 s^-2, zeta = 0.7, scale 0.6 → 1\n          let s = scaleArr[idx]!;\n          let sv = svelArr[idx]!;\n          if (s !== 1 || sv !== 0) {\n            sv += (SPRING_K * (1 - s) - SPRING_C * sv) * dt;\n            s += sv * dt;\n            if (Math.abs(1 - s) < 0.002 && Math.abs(sv) < 0.02) {\n              s = 1;\n              sv = 0;\n            } else {\n              animating = true;\n            }\n            scaleArr[idx] = s;\n            svelArr[idx] = sv;\n          }\n          // vertical squash 35% → 0 over 320ms, glide ease\n          const tq = (now - openAtArr[idx]!) / SQUASH_MS;\n          let squash = 1;\n          if (tq < 1) {\n            squash = 0.65 + 0.35 * glideEase(Math.max(0, tq));\n            animating = true;\n          }\n\n          // wet-glass refraction: source rect offset by the wave gradient\n          const hc = hf[idx]!;\n          const l = i > 0 ? hf[idx - 1]! : hc;\n          const r = i < cols - 1 ? hf[idx + 1]! : hc;\n          const u = j > 0 ? hf[idx - cols]! : hc;\n          const d = j < rows - 1 ? hf[idx + cols]! : hc;\n          const sx = Math.min(\n            Math.max(x * dpr + (r - l) * 0.5 * refraction * dpr, 0),\n            maxSx\n          );\n          const sy = Math.min(\n            Math.max(y * dpr + (d - u) * 0.5 * refraction * dpr, 0),\n            maxSy\n          );\n\n          if (s === 1 && squash === 1) {\n            ctx.drawImage(off, sx, sy, swD, shD, x, y, tw, th);\n          } else {\n            ctx.save();\n            ctx.translate(x + tw / 2, y + th / 2);\n            ctx.scale(s, s * squash);\n            ctx.drawImage(off, sx, sy, swD, shD, -tw / 2, -th / 2, tw, th);\n            ctx.restore();\n          }\n        }\n      }\n      return animating;\n    };\n\n    const loop = (now: number) => {\n      raf = 0;\n      if (disposed || w === 0 || total === 0) return;\n      const dt = Math.min(Math.max((now - last) / 1000, 0), DT_MAX);\n      last = now;\n      let animating = false;\n\n      if (triggered) {\n        if (pendingPointer >= 0 && pendingPointer < total) {\n          hf[pendingPointer] = Math.min(1.5, hf[pendingPointer]! + impulse);\n          pendingPointer = -1;\n        }\n        step();\n        step();\n        if (maxH > EPS || maxV > EPS) {\n          animating = true;\n        } else if (maxH > 0 || maxV > 0) {\n          // settle hard so refraction offsets are exactly zero at rest\n          hf.fill(0);\n          vf.fill(0);\n          maxH = 0;\n          maxV = 0;\n        }\n        if (openedCount < total) {\n          for (let idx = 0; idx < total; idx++) {\n            if (stateArr[idx] === 0 && Math.abs(hf[idx]!) > threshold) {\n              openTile(idx, now);\n            }\n          }\n          if (openedCount < total && maxH < threshold * 0.5) flush(now);\n          animating = true;\n        }\n      }\n\n      if (render(now, dt)) animating = true;\n      if (animating && visible) raf = requestAnimationFrame(loop);\n    };\n\n    const wake = () => {\n      if (raf === 0 && visible && !disposed && w > 0) {\n        last = performance.now();\n        raf = requestAnimationFrame(loop);\n      }\n    };\n\n    const drawOnce = () => {\n      if (w > 0 && total > 0) render(performance.now(), 0);\n    };\n\n    const repaint = () => {\n      if (reduced) drawOnce();\n      else wake();\n    };\n\n    const trigger = (ci: number, ri: number) => {\n      if (triggered || disposed) return;\n      triggered = true;\n      if (total === 0) return; // grid rebuild reveals on next real size\n      if (reduced) {\n        // reduced motion: single instant paint, zero wave distortion\n        revealAll();\n        drawOnce();\n        return;\n      }\n      trigCol = Math.min(Math.max(ci, 0), cols - 1);\n      trigRow = Math.min(Math.max(ri, 0), rows - 1);\n      hf[trigRow * cols + trigCol] = 1;\n      maxH = 1;\n      wake();\n    };\n\n    const cellAt = (e: PointerEvent): [number, number] | null => {\n      if (total === 0) return null;\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 1 || rect.height < 1) return null;\n      const ci = Math.min(\n        Math.max(Math.floor(((e.clientX - rect.left) / rect.width) * cols), 0),\n        cols - 1\n      );\n      const ri = Math.min(\n        Math.max(Math.floor(((e.clientY - rect.top) / rect.height) * rows), 0),\n        rows - 1\n      );\n      return [ci, ri];\n    };\n\n    const onPointer = (e: PointerEvent) => {\n      if (!triggered) {\n        const c = cellAt(e);\n        trigger(c ? c[0] : cols >> 1, c ? c[1] : rows >> 1);\n        return;\n      }\n      // cursor keeps perturbing the water only after full reveal;\n      // pendingPointer throttles injection to one impulse per frame\n      if (reduced || openedCount < total || total === 0) return;\n      const c = cellAt(e);\n      if (!c) return;\n      pendingPointer = c[1] * cols + c[0];\n      wake();\n    };\n\n    deriveColors();\n    resize();\n    repaint();\n\n    // scroll trigger at 0.35 visibility + offscreen pause\n    const io = new IntersectionObserver(\n      (entries) => {\n        for (const e of entries) {\n          visible = e.isIntersecting;\n          if (!triggered && e.intersectionRatio >= 0.35) {\n            trigger(cols >> 1, rows >> 1);\n          }\n        }\n        if (visible) {\n          if (!reduced) wake();\n        } else if (raf) {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { threshold: [0, 0.35] }\n    );\n    io.observe(root);\n\n    const ro = new ResizeObserver((entries) => {\n      const e = entries[0];\n      if (!e) return;\n      if (\n        Math.abs(e.contentRect.width - w) < 1 &&\n        Math.abs(e.contentRect.height - h) < 1\n      ) {\n        return;\n      }\n      resize();\n      repaint();\n    });\n    ro.observe(root);\n\n    // live theme re-derive: watch documentElement class changes\n    const mo = new MutationObserver(() => {\n      deriveColors();\n      if (!imgReady) paintOffscreen(); // artwork ink is token-derived\n      repaint();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    root.addEventListener(\"pointerenter\", onPointer);\n    root.addEventListener(\"pointermove\", onPointer);\n    root.addEventListener(\"pointerdown\", onPointer);\n\n    if (src) {\n      img = new Image();\n      img.decoding = \"async\";\n      img.onload = () => {\n        if (disposed) return;\n        imgReady = true;\n        paintOffscreen();\n        repaint();\n      };\n      img.onerror = () => {\n        // keep the generative artwork — never a blank surface\n        imgReady = false;\n      };\n      img.src = src;\n    }\n\n    return () => {\n      disposed = true;\n      if (raf) cancelAnimationFrame(raf);\n      raf = 0;\n      io.disconnect();\n      ro.disconnect();\n      mo.disconnect();\n      root.removeEventListener(\"pointerenter\", onPointer);\n      root.removeEventListener(\"pointermove\", onPointer);\n      root.removeEventListener(\"pointerdown\", onPointer);\n      if (img) {\n        img.onload = null;\n        img.onerror = null;\n      }\n    };\n  }, [src, tileSize, threshold, refraction, impulse]);\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"img\"\n      aria-label={ariaLabel}\n      className={`relative w-full select-none overflow-hidden ${className}`}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden\n        className=\"absolute inset-0 h-full w-full\"\n      />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/reveal-ripple-tiles.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "canvas",
      "media",
      "reveal",
      "wave-sim",
      "tiles",
      "refraction",
      "cursor",
      "scroll-trigger"
    ],
    "instruction": "A media surface behind a coarse tile grid on a single DPR-aware Canvas 2D (DPR clamp 2), image pre-drawn to an offscreen canvas (or a token-derived generative artwork when no src is given). A height-field wave sim runs on a grid matched to tile resolution (~28x18 cells, tile ≈ 32px at 900px width; cols derived from width/32, zero-size guarded): classic discrete wave equation — v[c] += (4-neighbor avg − h[c]) * 0.5 per step, h += v, damping ×0.985/step (×0.95/step once every tile has opened, to shorten the inaudible idle tail), 2 sim steps per frame, Neumann edges. Trigger (IntersectionObserver at 0.35 visibility, or first pointer entry) injects impulse h=1.0 at the trigger cell; a tile pops open when local |h| > 0.12 — scale 0.6→1 on a spring (k=90 s⁻², ζ=0.7) plus a rotateX-style vertical squash 35%→0 over 320ms with cubic-bezier(0.22,1,0.36,1); reveal = drawImage of that tile's source rect. Revealed tiles refract: source rect offset by (gradient of h) × 6px for a wet-glass shimmer. If the coarse wave dies below threshold before reaching the corners, stragglers are flush-scheduled outward from the trigger cell so the reveal always completes. After full reveal, pointermove injects impulse 0.35 at the cursor cell, throttled to one per frame. Pre-reveal tiles draw a border-token hairline, a faint foreground lift, and an accent-alpha shimmer proportional to |h| — every drawn color derived from --border/--ns-accent/--foreground/--surface/--ns-muted via a getComputedStyle probe at mount and re-derived live via MutationObserver on documentElement class changes, so both themes render correctly. The rAF loop is the sole writer (no React state on hot paths) and sleeps when max|h| < 0.004 AND max|v| < 0.004 and all tile springs are settled; it wakes on pointer or trigger and pauses offscreen via IntersectionObserver. IntersectionObserver, ResizeObserver (recompute grid, guard 0x0), MutationObserver, and all pointer listeners are torn down on unmount. Pure media surface: role=img with aria-label, no click behavior. Under prefers-reduced-motion tiles reveal instantly in a single paint with zero wave distortion, static image thereafter."
  },
  "type": "registry:ui"
}