{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-ascii-caustics",
  "title": "Background ASCII Caustics",
  "description": "Ambient water-caustics rendered in ASCII ink density: three rotated wave grids combined multiplicatively and sharpened into thin bright filaments, the way overlapping light wave-fronts trace a caustic web, with the pointer acting as a lens the pattern visibly focuses toward.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/background-ascii-caustics/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CausticVeil — an ambient ASCII water-caustics background rendered in ink\n// density. Three periodic wave grids, each rotated to its own angle and\n// drifting at its own phase speed, are combined MULTIPLICATIVELY (not summed,\n// which is background-ascii-plasma's technique) — sin(a) * sin(b) * sin(c) —\n// and the product's distance from zero is sharpened by a power curve, which\n// is exactly how two or three overlapping wave-fronts of light produce thin,\n// bright, web-like caustic lines rather than smooth blobby luminance: the\n// bright filaments trace where the waves cross in near-perfect\n// anti/constructive alignment. The pointer acts as a LENS, not a stamp or an\n// outward push: samples within its radius are pulled INWARD toward the\n// cursor (the opposite sign of plasma's warp), so the caustic web visibly\n// contracts and focuses toward the pointer like light converging through a\n// magnifier, and relaxes back outward once the pointer leaves.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\";\nconst ALPHA_BUCKETS = 6;\nconst LAYERS = [\n  { angle: 0.0, freq: 0.07, speed: 0.22 },\n  { angle: 1.15, freq: 0.09, speed: -0.17 },\n  { angle: 2.3, freq: 0.05, speed: 0.13 },\n] as const;\nconst CAUSTIC_POW = 5.5; // higher = thinner, sharper filaments\nconst LENS_TAU = 0.55; // s — lens strength relax time constant\nconst LENS_RADIUS = 10; // grid cells\nconst LENS_MAX = 3.4; // grid cells max inward pull\nconst DT_MAX = 0.05;\n\nfunction caustic(x: number, y: number, t: number): number {\n  let product = 1;\n  for (let i = 0; i < LAYERS.length; i++) {\n    const l = LAYERS[i]!;\n    const rx = x * Math.cos(l.angle) + y * Math.sin(l.angle);\n    product *= Math.sin(rx * l.freq + t * l.speed);\n  }\n  const v = Math.max(0, 1 - Math.abs(product));\n  return Math.pow(v, CAUSTIC_POW);\n}\n\nexport interface CausticVeilProps {\n  /** grid cell size in px */\n  cellSize?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function CausticVeil({ cellSize = 12, className = \"\" }: CausticVeilProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n\n    let fg = \"currentColor\";\n    let cellW = cellSize;\n    let cellH = cellSize;\n    let cols = 0;\n    let rows = 0;\n    let dpr = 1;\n    let sized = false;\n    let ready = false;\n    let disposed = false;\n\n    let charBuf = new Uint8Array(0);\n    const bucketLists: number[][] = Array.from({ length: ALPHA_BUCKETS }, () => []);\n\n    const readTokens = () => {\n      fg = getComputedStyle(canvas).color;\n    };\n\n    const measureCell = (fontFamily: string) => {\n      const off = document.createElement(\"canvas\");\n      const octx = off.getContext(\"2d\");\n      if (!octx) return;\n      octx.font = `${cellSize}px ${fontFamily}`;\n      cellW = Math.max(4, octx.measureText(\"MMMMMMMMMM\").width / 10);\n      cellH = cellSize;\n    };\n\n    const resize = () => {\n      const { width, height } = canvas.getBoundingClientRect();\n      if (width < 2 || height < 2) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      const fontFamily = getComputedStyle(canvas).fontFamily;\n      measureCell(fontFamily);\n      ctx.font = `${cellSize}px ${fontFamily}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      cols = Math.max(4, Math.ceil(width / cellW));\n      rows = Math.max(4, Math.ceil(height / cellH));\n      charBuf = new Uint8Array(cols * rows);\n      sized = true;\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (reduced) draw(0, -1e5, -1e5, 0);\n      }, 150);\n    };\n\n    const draw = (t: number, lensGx: number, lensGy: number, lensStrength: number) => {\n      if (!sized) return;\n      const w = cols * cellW;\n      const h = rows * cellH;\n      ctx.clearRect(0, 0, w, h);\n\n      for (let b = 0; b < ALPHA_BUCKETS; b++) bucketLists[b]!.length = 0;\n\n      const r2 = LENS_RADIUS * LENS_RADIUS;\n      let i = 0;\n      for (let gy = 0; gy < rows; gy++) {\n        for (let gx = 0; gx < cols; gx++, i++) {\n          let sx = gx;\n          let sy = gy;\n          if (lensStrength > 0.01) {\n            const dx = lensGx - gx;\n            const dy = lensGy - gy;\n            const d2 = dx * dx + dy * dy;\n            if (d2 < r2 * 4) {\n              const dist = Math.sqrt(d2) || 1e-4;\n              const falloff = Math.exp(-d2 / r2);\n              const pull = lensStrength * falloff * LENS_MAX;\n              // INWARD: sample coordinate moves toward the lens center, the\n              // opposite sign of an outward push — the field contracts here\n              sx = gx + (dx / dist) * pull;\n              sy = gy + (dy / dist) * pull;\n            }\n          }\n          const v = caustic(sx, sy, t);\n          const bucket = Math.min(ALPHA_BUCKETS - 1, Math.floor(v * ALPHA_BUCKETS));\n          const ci = Math.floor(v * (RAMP.length - 1));\n          charBuf[i] = ci;\n          if (ci !== 0) bucketLists[bucket]!.push(i);\n        }\n      }\n\n      ctx.fillStyle = fg;\n      for (let b = 0; b < ALPHA_BUCKETS; b++) {\n        const list = bucketLists[b]!;\n        ctx.globalAlpha = 0.1 + (b / (ALPHA_BUCKETS - 1)) * 0.9;\n        for (let k = 0; k < list.length; k++) {\n          const idx = list[k]!;\n          const gx = idx % cols;\n          const gy = (idx - gx) / cols;\n          ctx.fillText(\n            RAMP[charBuf[idx]!]!,\n            gx * cellW + cellW / 2,\n            gy * cellH + cellH / 2\n          );\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // -- hot-path state -------------------------------------------------------\n    let raf = 0;\n    let last = 0;\n    let t = 0;\n    const lens = { gx: -1e5, gy: -1e5, has: false, strength: 0 };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(DT_MAX, (now - last) / 1000) : 1 / 60;\n      last = now;\n      t += dt;\n      const target = lens.has ? 1 : 0;\n      lens.strength += (target - lens.strength) * Math.min(1, dt / LENS_TAU);\n      draw(t, lens.gx, lens.gy, lens.strength);\n      if (!document.hidden) raf = requestAnimationFrame(loop);\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      lens.gx = (e.clientX - rect.left) / cellW;\n      lens.gy = (e.clientY - rect.top) / cellH;\n      lens.has = true;\n    };\n    const onPointerLeave = () => {\n      lens.has = false;\n    };\n\n    const onVis = () => {\n      if (!document.hidden && !reduced && ready) {\n        last = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw(t, -1e5, -1e5, 0);\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n      ready = true;\n      if (reduced) {\n        draw(0, -1e5, -1e5, 0);\n      } else {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    window.addEventListener(\"resize\", onResize);\n    if (!reduced) {\n      canvas.addEventListener(\"pointermove\", onPointerMove);\n      canvas.addEventListener(\"pointerleave\", onPointerLeave);\n    }\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      mo.disconnect();\n      window.removeEventListener(\"resize\", onResize);\n      canvas.removeEventListener(\"pointermove\", onPointerMove);\n      canvas.removeEventListener(\"pointerleave\", onPointerLeave);\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [cellSize]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden\n      className={`block h-full w-full font-mono text-foreground ${className}`}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-ascii-caustics.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "background",
      "ascii",
      "canvas",
      "cursor",
      "caustics",
      "water",
      "light"
    ],
    "instruction": "Build <CausticVeil cellSize? className?> as a full-bleed <canvas>. FIELD: three periodic wave layers, each rotated to its own fixed angle (0, 1.15, 2.3 rad) and drifting at its own phase speed (0.22, -0.17, 0.13 rad/s), sampled as sin(rotatedX * freq + t * speed) per layer and combined MULTIPLICATIVELY — product = wave1 * wave2 * wave3 — never summed (that is background-ascii-plasma's technique). The rendered luminance is pow(max(0, 1 - abs(product)), 5.5): a steep power curve that turns the near-zero-product contours (where the three waves cross in close alignment) into thin bright filaments against a mostly dark field, which is the actual optical mechanism behind a caustic net rather than a cosmetic recolor of a summed field. LENS: the pointer maintains a 0..1 'active' scalar that eases toward 1 while over the canvas and back to 0 once it leaves (time constant ~0.55s), and any grid cell within a 10-cell radius of the pointer has its SAMPLE coordinate pulled INWARD toward the pointer (gaussian falloff, max pull 3.4 cells) before the caustic field is evaluated there — the opposite sign of an outward push, so the web visibly contracts and focuses toward the cursor like light converging through a magnifying lens, and relaxes back to its undisturbed shape once the pointer leaves. Direct-DOM rAF, zero React state on the hot path. Rendering is two-pass for cost control: pass one evaluates every cell into a Uint8Array ramp-index buffer and buckets its index by luminance into one of 6 alpha buckets; pass two sets ctx.globalAlpha once per bucket and draws only that bucket's cells, from the shared ' .:-=+*#%@' density ramp — the same bucketing discipline background-ascii-plasma uses, adapted to this field's multiplicative math. Ink is read once via getComputedStyle(canvas).color and re-derived on a documentElement class MutationObserver for live theme flips. Mono cell measured via an offscreen canvas's measureText. prefers-reduced-motion renders exactly one static frame at t=0 with the lens fully relaxed, and skips the rAF loop and pointer listeners entirely. Loop pauses on document.hidden, resumes on visibilitychange. Props: cellSize (grid cell px, default 12), className."
  },
  "type": "registry:ui"
}