{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"caustic-surface","title":"Caustic Surface","description":"The net of light on a pool floor, lit by conservation of light rather than a texture: brightness is 1/|det(I + D(1 − 1/n)H)| of the refraction map, so the lines are where the surface folds the light. Pointer ripples bend the net. WebGL2.","dependencies":[],"files":[{"path":"src/components/CausticSurface/index.js","content":"export { default } from './CausticSurface.jsx'\n","type":"registry:component","target":"components/caustic-surface/index.js"},{"path":"src/components/CausticSurface/CausticSurface.jsx","content":"'use client'\n\nimport { useEffect, useRef } from 'react'\nimport { createGL, createLoop, linearRGB } from '../gl-core/gl.js'\nimport './CausticSurface.css'\n\n/* =========================================================\n   CausticSurface\n   The net of light on the floor of a swimming pool, as a\n   backdrop. Moving the pointer drops ripples into the water\n   and the net reorganises around them.\n\n   The usual caustic is a scrolling noise texture, and it\n   reads wrong because nothing about it is light: it does not\n   sharpen as the water deepens, and a ripple cannot bend it.\n   The bright lines are not painted; they are where refraction\n   crowds light together.\n\n   Sunlight comes straight down and bends at the surface by\n   Snell's law. For slopes this small the bend linearises, and\n   a ray entering at surface point p lands on a floor D below at\n\n     q = p + D·(1 − 1/n)·∇h(p),    n = 1.333\n\n   Light is conserved, so the brightness at q is the area a\n   patch of surface had over the area it lands on:\n\n     E = 1 / |det(∂q/∂p)| = 1 / |det(I + D·(1 − 1/n)·H)|\n\n   with H the Hessian of the height field. Where the surface\n   focuses, the patch collapses, det → 0 and E spikes — those\n   spikes are the lines. The shader works backwards from each\n   floor pixel: a few fixed-point steps of p = q − D·(1−1/n)·∇h\n   find the surface point that lights it, then E is evaluated\n   there. Deeper water means a larger D, which is why pools\n   show sharper, busier nets in the deep end.\n\n   The surface is a sum of travelling waves with deep-water\n   dispersion ω = √(g·k), so long waves outrun short ones.\n   Gerstner waves would also displace the surface sideways,\n   which matters for drawing the water itself; the floor only\n   sees slopes, and to first order in amplitude the two agree.\n   A ripple is a circular packet that spreads its energy over a\n   growing ring, so its height falls as 1/√r and with age.\n\n   Only ∇h and H are ever needed, and for sines and ring\n   packets both can be written down, so the shader does: no\n   finite differences, which would sample the whole surface\n   four times per step and nine more for the Hessian. That is\n   what lets a dozen ripples cost less than the bare water did,\n   so the frame rate does not sag the moment the water is\n   touched. Each wave carries its own phase, advanced by ω·dt:\n   computed as ω·t, a wavelength change would multiply against\n   all the time that has passed and scramble the net; this way\n   it only changes how fast the phase turns from here on.\n\n   What the pointer gives the water follows the hand. A pointer\n   that starts to move is answered within a few pixels; after\n   that it sheds a ripple every so often along its path, and\n   how hard depends on how fast it is going — measured over the\n   last 70 ms of travel, not one event — so a slow drag barely\n   stirs the net and a flick tears a wake through it. A press\n   drops the full thing: the primary button, a pen's tip, each\n   finger that lands. On a touch screen that press is the tap;\n   a finger that drags is scrolling the page, and the water\n   does not take the gesture from it.\n\n   The keyboard reaches the water where the pointer would\n   have. Focus that a key brings onto something on the surface\n   stirs the water under it, as a pointer coming onto it does,\n   and a key that activates it drops what a press drops.\n   Focus that a click brought, and the click itself, were\n   answered when the pointer went down, and are not answered\n   twice.\n\n   No ripple appears from nothing, and none ends in a cut. The\n   push that raises one takes a few hundredths of a second, so\n   the first frame already answers but the light never jumps.\n   The faintness at which one is let go is taken off the whole\n   of its life, so it arrives at nothing: stopped just short, a\n   twentieth of a pixel of height still shifts every line the\n   ring crosses by a quarter of a pixel, all in one frame, and\n   that shows as a tick. There are only so many ripples, and\n   under a scribbling hand the oldest must make room: a few\n   places are kept empty or emptying, and a ripple asked to\n   leave bows out over a quarter of a second, which is over\n   before its place is needed at the pace a pointer sheds them.\n   Presses are not paced: drummed faster than about ten a\n   second for a second on end, they do cut the oldest short.\n\n   Ripples and eases run on elapsed time, capped per frame, so\n   they are the same at 60 Hz and 120 Hz and nothing leaps\n   after the tab was away. A changed prop — depth, colour, even\n   speed — eases to its new value instead of cutting, and a\n   changed wavelength rescales the net about the middle of the\n   surface, not out of its corner. When nothing can change\n   (speed, amplitude or depth at 0, or reduced motion, with no\n   ripple left) the loop stops, and wakes for the next ripple\n   or prop. Under reduced\n   motion the water holds one still frame, and neither pointer\n   nor key disturbs it.\n   ========================================================= */\n\nconst WAVE_COUNT = 5\nconst MAX_RIPPLES = 12\n\nconst G = 380               // px/s²: the longest default wave drifts at a calm ~110px/s\nconst TAU = Math.PI * 2\n\n/* Wavelength ratio, heading (rad), amplitude ratio. Headings spread across the\n   half-circle so the cells come out round rather than combed one way. Amplitude\n   goes as λ², because curvature — what focuses light — grows as A·k²: this way\n   every component focuses equally and no single heading dominates the net. */\nconst WAVES = [\n  [1.00, 0.35, 1.000],\n  [0.74, 1.58, 0.548],\n  [0.55, 2.71, 0.303],\n  [0.43, -1.92, 0.185],\n  [0.33, -0.66, 0.109],\n]\n\nconst RING_SPEED = 150      // px/s, how fast a ripple's ring opens\nconst RISE = 0.06           // s, the push that raises a ripple\nconst LIFE = 1.4            // s, e-folding of a ripple's height\nconst FAINT = 0.006         // of a press: a ripple this faint is past seeing, and ends there\n\nconst WINDOW = 70           // ms of travel the pointer's speed is measured over\nconst START = 8             // px a pointer that was at rest moves before the water answers\nconst SPACING = 48          // px of travel between the ripples it sheds after that\nconst GAP = 90              // ms, and never more often than this\nconst FLICK = 1600          // px/s at which a moving pointer gives all it can…\nconst WAKE = 0.75           // …which is this much of a press\nconst ARRIVE = 0.5          // of a press: what focus arriving by key stirs, a pointer coming in briskly\n\nconst SPARE = 3             // places kept empty, or emptying, for the ripples about to come\nconst HANDOVER = 0.24       // s a ripple takes to bow out of one: just inside SPARE × GAP\n\nconst GLIDE = 0.12          // s, time constant a changed prop eases in over\nconst STILL_TIME = 14       // s, the moment reduced motion holds\n\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 vUv;\nout vec4 outColor;\n\nuniform vec2 uSize;               // CSS px\nuniform float uDepth;             // px of water between surface and floor\nuniform float uIntensity;\nuniform vec3 uLight;              // linear\nuniform vec3 uFloor;              // linear\nuniform vec4 uWaves[${WAVE_COUNT}];            // wave vector x, y (rad/px), phase (rad), height (px)\nuniform vec4 uRipples[${MAX_RIPPLES}];         // centre x, y (px), ring radius (px), height (px)\n\nconst float BEND = 1.0 - 1.0 / 1.333;\nconst float KAPPA = 0.075;        // rad/px: an 84px ripple…\nconst float RING = 900.0;         // …in a packet 30px wide across the ring (σ², px²)\nconst float CORE = 100.0;         // px²: rounds the centre, where a bare ring profile is a cone\n\n/* A ripple's profile across its ring, h = A·exp(−x²/2σ²)·cos(κx) with x the\n   distance outside the ring, as its first and second derivative in x. */\nvec2 ring(float x, float height) {\n  float c = cos(KAPPA * x);\n  float s = sin(KAPPA * x);\n  return height * exp(-x * x / (2.0 * RING)) * vec2(\n    -x / RING * c - KAPPA * s,\n    (x * x / (RING * RING) - 1.0 / RING - KAPPA * KAPPA) * c + 2.0 * KAPPA * x / RING * s);\n}\n\n/* ∇h. */\nvec2 slope(vec2 p) {\n  vec2 g = vec2(0.0);\n  for (int i = 0; i < ${WAVE_COUNT}; i++) {\n    vec4 w = uWaves[i];\n    g += w.w * cos(dot(w.xy, p) - w.z) * w.xy;\n  }\n  for (int i = 0; i < ${MAX_RIPPLES}; i++) {\n    vec4 r = uRipples[i];\n    if (r.w == 0.0) continue;\n    vec2 v = p - r.xy;\n    float d = sqrt(dot(v, v) + CORE);\n    float x = d - r.z;\n    if (abs(x) > 120.0) continue;                         // four σ out: nothing left of the packet\n    g += ring(x, r.w).x * v / d;\n  }\n  return g;\n}\n\n/* The Hessian of h, as (hxx, hyy, hxy). */\nvec3 curvature(vec2 p) {\n  vec3 H = vec3(0.0);\n  for (int i = 0; i < ${WAVE_COUNT}; i++) {\n    vec4 w = uWaves[i];\n    H -= w.w * sin(dot(w.xy, p) - w.z) * vec3(w.x * w.x, w.y * w.y, w.x * w.y);\n  }\n  for (int i = 0; i < ${MAX_RIPPLES}; i++) {\n    vec4 r = uRipples[i];\n    if (r.w == 0.0) continue;\n    vec2 v = p - r.xy;\n    float d = sqrt(dot(v, v) + CORE);\n    float x = d - r.z;\n    if (abs(x) > 120.0) continue;\n    vec2 f = ring(x, r.w);\n    vec2 n = v / d;\n    float around = f.x / d;                               // a ring also curves around its centre\n    H += vec3(around, around, 0.0) + (f.y - around) * vec3(n.x * n.x, n.y * n.y, n.x * n.y);\n  }\n  return H;\n}\n\nvoid main() {\n  vec2 q = vec2(vUv.x, 1.0 - vUv.y) * uSize;\n  float k = uDepth * BEND;\n\n  /* The surface point whose refracted ray lands here. Relaxed, because past a\n     fold several surface points light the same floor pixel and a bare\n     iteration flips between them, which shows as hairline stripes. */\n  vec2 p = q;\n  for (int i = 0; i < 7; i++) p = mix(p, q - k * slope(p), 0.55);\n\n  vec3 H = curvature(p);\n  float det = (1.0 + k * H.x) * (1.0 + k * H.y) - k * k * H.z * H.z;\n  float E = 1.0 / max(abs(det), 0.06);\n\n  // The floor keeps its own colour; light is added where it is focused past\n  // the mean, so the unfocused water between the lines stays dark.\n  float focus = max(E - 0.75, 0.0);\n  vec3 c = uFloor * (0.7 + 0.3 * min(E, 1.5)) + uLight * uIntensity * 0.2 * focus * sqrt(focus);\n  c = c / (1.0 + c * 0.6);\n  outColor = vec4(pow(c, vec3(1.0 / 2.2)), 1.0);\n}`\n\nconst finite = (v, fallback) => (Number.isFinite(v) ? v : fallback)\n\nexport default function CausticSurface({\n  depth = 260,          // px of water above the floor; deeper is a sharper, busier net\n  amplitude = 11,       // px, height of the longest wave\n  wavelength = 200,     // px, of the longest wave\n  speed = 1,            // time scale of the waves; 0 stills them\n  ripple = 1,           // strength of a press's ripple, and the scale of a moving pointer's or arriving focus's; 0 turns them off\n  intensity = 1,        // brightness of the focused light\n  color = '#d6f3ff',    // the light\n  background = '#03202a', // the pool floor\n  resolution = 1.5,     // device-pixel-ratio cap\n  children,\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const canvasRef = useRef(null)\n\n  const live = useRef(null)\n  const wake = useRef(null)\n  // Synced after every render and before the effect below, so the loop reads\n  // current props without the GL context being rebuilt — and is woken for\n  // them, since it sleeps whenever the picture has nothing left to change.\n  useEffect(() => {\n    live.current = { depth, amplitude, wavelength, speed, ripple, intensity, color, background, resolution }\n    wake.current?.()\n  })\n\n  useEffect(() => {\n    const root = rootRef.current\n    const canvas = canvasRef.current\n    const glx = root && canvas && createGL(canvas, { onRestore: () => loop.kick() })\n    if (!glx) return\n\n    const motion = window.matchMedia('(prefers-reduced-motion: reduce)')\n    let still = motion.matches\n    const program = glx.program(FRAGMENT)\n\n    const waves = new Float32Array(WAVE_COUNT * 4)\n    const phase = new Float64Array(WAVE_COUNT)      // ω·t so far, per wave\n    const wavenumber = new Float64Array(WAVE_COUNT) // k as last drawn, per wave\n    const ripples = new Float32Array(MAX_RIPPLES * 4)\n    // strength 0 is an empty place; `left` is how much of a ripple remains.\n    const pool = Array.from({ length: MAX_RIPPLES }, () => (\n      { x: 0, y: 0, age: 0, strength: 0, left: 0, fade: 1, leaving: false }\n    ))\n\n    // depth, amplitude, wavelength, speed, intensity, then light and floor as linear RGB\n    const target = new Float64Array(11)\n    const shown = new Float64Array(11)\n    let started = false\n    let lightKey, floorKey\n    let size = { w: 1, h: 1 }\n    let density = live.current.resolution\n    let last = 0\n\n    const paint = (dt) => {\n      const p = live.current\n      if (p.resolution !== density) {\n        density = p.resolution\n        glx.resize(size.w, size.h, density)\n      }\n\n      target[0] = Math.max(0, finite(p.depth, 260))\n      target[1] = Math.max(0, finite(p.amplitude, 11))\n      target[2] = Math.max(8, finite(p.wavelength, 200))\n      target[3] = finite(p.speed, 1)\n      target[4] = Math.max(0, finite(p.intensity, 1))\n      if (p.color !== lightKey) target.set(linearRGB(lightKey = p.color, [0.7, 0.9, 1]), 5)\n      if (p.background !== floorKey) target.set(linearRGB(floorKey = p.background, [0, 0.02, 0.03]), 8)\n\n      // Every setting eases to where it was put, and lands exactly. The last\n      // step is a thousandth of the value — or, for a colour headed to black,\n      // a ten-thousandth of white, which is below one step of an 8-bit screen.\n      const ease = 1 - Math.exp(-dt / GLIDE)\n      let gliding = false\n      for (let i = 0; i < target.length; i++) {\n        const gap = target[i] - shown[i]\n        if (!started || still || Math.abs(gap) < 1e-4 + 1e-3 * Math.abs(target[i])) {\n          shown[i] = target[i]\n        } else {\n          shown[i] += gap * ease\n          gliding = true\n        }\n      }\n      const [depthNow, height, length, rate, glow] = shown\n\n      for (let i = 0; i < WAVE_COUNT; i++) {\n        const [ratio, heading, share] = WAVES[i]\n        const k = TAU / (length * ratio)\n        const omega = Math.sqrt(G * k)\n        const cos = Math.cos(heading)\n        const sin = Math.sin(heading)\n        if (still) {\n          phase[i] = omega * STILL_TIME\n        } else {\n          // A wavelength on its way to a new value also turns each phase by what\n          // the change does at the middle of the surface, which holds the net\n          // still there: it grows or tightens about the centre, not a corner.\n          const pivot = started ? (k - wavenumber[i]) * (cos * size.w + sin * size.h) / 2 : 0\n          phase[i] = (phase[i] + omega * rate * dt + pivot) % TAU\n        }\n        wavenumber[i] = k\n        waves[i * 4] = k * cos\n        waves[i * 4 + 1] = k * sin\n        waves[i * 4 + 2] = phase[i] - i * 1.7\n        waves[i * 4 + 3] = height * share\n      }\n      started = true\n\n      let rippling = false\n      const corner = Math.hypot(size.w, size.h)\n      for (let i = 0; i < MAX_RIPPLES; i++) {\n        const r = pool[i]\n        if (r.strength > 0) {\n          r.age += dt\n          if (r.leaving) r.fade = Math.max(0, r.fade - dt / HANDOVER)\n          const radius = RING_SPEED * r.age\n          const bow = r.fade * r.fade * (3 - 2 * r.fade)\n          // FAINT comes off the whole of its life, so a ripple arrives at\n          // nothing instead of being cut off just short of it.\n          r.left = r.strength * bow * Math.exp(-r.age / LIFE) / Math.sqrt(1 + radius / 30) - FAINT\n          // Spent once it has faded past seeing or its ring has left the surface.\n          if (r.left <= 0 || radius > corner + 120) r.strength = r.left = 0\n        }\n        rippling ||= r.strength > 0\n        ripples[i * 4] = r.x\n        ripples[i * 4 + 1] = r.y\n        ripples[i * 4 + 2] = RING_SPEED * r.age\n        ripples[i * 4 + 3] = r.strength > 0 ? 0.8 * height * r.left * (1 - Math.exp(-r.age / RISE)) : 0\n      }\n\n      program.set({\n        uSize: [size.w, size.h],\n        uDepth: depthNow,\n        uIntensity: glow,\n        uLight: shown.subarray(5, 8),\n        uFloor: shown.subarray(8, 11),\n        uWaves: waves,\n        uRipples: ripples,\n      })\n      glx.draw()\n      // Still water, flat water, and water with no depth to focus through all\n      // draw the same frame again; only a glide or a ripple needs another.\n      return gliding || rippling || (!still && rate !== 0 && height > 0 && depthNow > 0)\n    }\n\n    // Elapsed time, capped so a frame after a stall is one long step, not a\n    // leap. The frame that wakes a sleeping loop counts as an ordinary one, so\n    // what woke it shows at once.\n    const tick = (now) => {\n      const dt = last ? Math.min(0.05, Math.max(0, (now - last) / 1000)) : 1 / 60\n      const busy = paint(dt)\n      last = busy ? now : 0\n      return busy\n    }\n\n    const loop = createLoop(root, tick)\n    wake.current = loop.kick\n\n    /* Takes the place with the least left in it — an empty one while there is\n       one. Then, if fewer than SPARE places are empty or emptying, the faintest\n       of the rest start bowing out now, so that a place is silent by the time\n       a ripple needs it. */\n    const drop = (x, y, strength) => {\n      // Past seeing from the start, or on water too flat or too shallow to\n      // show it: not worth a place, nor waking the loop for.\n      if (!(strength > FAINT) || !(shown[0] > 0 && shown[1] > 0)) return\n      let place = pool[0]\n      for (const r of pool) if (r.left < place.left) place = r\n      Object.assign(place, { x, y, age: 0, strength, left: strength, fade: 1, leaving: false })\n\n      let spare = 0\n      for (const r of pool) if (r.strength === 0 || r.leaving) spare++\n      for (; spare < SPARE; spare++) {\n        let faintest = null\n        for (const r of pool) {\n          if (r !== place && !r.leaving && r.strength > 0 && (!faintest || r.left < faintest.left)) faintest = r\n        }\n        if (!faintest) break\n        faintest.leaving = true\n      }\n      loop.kick()\n    }\n\n    const local = (e) => {\n      const b = root.getBoundingClientRect()\n      return [e.clientX - b.left, e.clientY - b.top]\n    }\n\n    const trail = []                // the last WINDOW ms of the pointer: [ms, x, y]\n    let holder = -1                 // the pointer the trail belongs to\n    let travel = 0                  // px moved since the last ripple\n    let fresh = true                // the pointer was at rest, or away, and has shed nothing since\n    let shedAt = -Infinity\n\n    const onMove = (e) => {\n      const share = finite(live.current.ripple, 1)\n      if (still || !e.isPrimary || !(share > 0)) return\n      if (e.pointerId !== holder) {\n        holder = e.pointerId\n        trail.length = 0\n      }\n      const t = e.timeStamp\n      const prev = trail[trail.length - 1]\n      trail.push([t, e.clientX, e.clientY])\n      while (t - trail[0][0] > WINDOW) trail.shift()\n      if (trail.length < 2) {       // first sample after a pause: no speed to speak of yet\n        travel = 0\n        fresh = true\n        return\n      }\n      travel += Math.hypot(e.clientX - prev[1], e.clientY - prev[2])\n      if (travel < (fresh ? START : SPACING) || t - shedAt < GAP) return\n\n      let path = 0\n      for (let i = 1; i < trail.length; i++) {\n        path += Math.hypot(trail[i][1] - trail[i - 1][1], trail[i][2] - trail[i - 1][2])\n      }\n      const pace = path / (Math.max(16, t - trail[0][0]) / 1000)    // px/s\n      travel = 0\n      fresh = false\n      shedAt = t\n      drop(...local(e), share * WAKE * Math.min(1, pace / FLICK) ** 0.6)\n    }\n\n    // A press is its own ripple, and the wake after it starts from there. Any\n    // finger may press; the button that opens a menu is not pressing the water.\n    const onDown = (e) => {\n      const share = finite(live.current.ripple, 1)\n      if (still || e.button !== 0 || !(share > 0)) return\n      if (e.isPrimary) {\n        holder = e.pointerId\n        trail.length = 0\n        trail.push([e.timeStamp, e.clientX, e.clientY])\n        travel = 0\n        fresh = false\n        shedAt = e.timeStamp\n      }\n      drop(...local(e), share)\n    }\n\n    // Where it comes back in is no measure of how fast it was going.\n    const onLeave = () => {\n      trail.length = 0\n    }\n\n    /* The keyboard, at the centre of what it acts on. Which hand moved last is\n       asked of the whole document: the Tab that brings focus in is pressed\n       while focus is still outside. A held key repeats faster than any hand\n       presses, so it keeps the wake's pace — which also answers once a key\n       that both moves focus and activates, as an arrow in a radio group does. */\n    let keyed = false\n    const onKey = () => { keyed = true }\n    const onPointer = () => { keyed = false }\n\n    const under = (e, part) => {\n      const share = finite(live.current.ripple, 1)\n      if (still || !keyed || e.timeStamp - shedAt < GAP || !(share > 0)) return\n      shedAt = e.timeStamp\n      const b = root.getBoundingClientRect()\n      const at = e.target.getBoundingClientRect()\n      drop(at.left + at.width / 2 - b.left, at.top + at.height / 2 - b.top, share * part)\n    }\n\n    const onFocus = (e) => under(e, ARRIVE)\n\n    // A pointer's click counts its presses; a key's counts none. Heard at the\n    // document: a click listener on the surface itself would make all of it a\n    // tap target to a phone's browser, which flashes its highlight over one.\n    const onClick = (e) => {\n      if (e.detail === 0 && root.contains(e.target)) under(e, 1)\n    }\n\n    const onMotion = () => {\n      still = motion.matches\n      for (const r of pool) r.strength = r.left = 0\n      loop.kick()\n    }\n\n    // Sizing the canvas clears it, and this runs after the frame's draw: paint\n    // again here, or every step of a resize shows one blank frame.\n    const ro = new ResizeObserver(() => {\n      const b = root.getBoundingClientRect()\n      size = { w: Math.max(1, b.width), h: Math.max(1, b.height) }\n      glx.resize(size.w, size.h, density)\n      paint(0)\n      loop.kick()\n    })\n    ro.observe(root)\n\n    root.addEventListener('pointermove', onMove, { passive: true })\n    root.addEventListener('pointerdown', onDown, { passive: true })\n    root.addEventListener('pointerleave', onLeave)\n    root.addEventListener('focusin', onFocus)\n    const page = root.ownerDocument\n    page.addEventListener('keydown', onKey, { capture: true, passive: true })\n    page.addEventListener('pointerdown', onPointer, { capture: true, passive: true })\n    page.addEventListener('click', onClick, { capture: true, passive: true })\n    motion.addEventListener('change', onMotion)\n\n    return () => {\n      wake.current = null\n      ro.disconnect()\n      loop.stop()\n      root.removeEventListener('pointermove', onMove)\n      root.removeEventListener('pointerdown', onDown)\n      root.removeEventListener('pointerleave', onLeave)\n      root.removeEventListener('focusin', onFocus)\n      page.removeEventListener('keydown', onKey, { capture: true })\n      page.removeEventListener('pointerdown', onPointer, { capture: true })\n      page.removeEventListener('click', onClick, { capture: true })\n      motion.removeEventListener('change', onMotion)\n      glx.dispose()\n    }\n  }, [])\n\n  return (\n    <div\n      ref={rootRef}\n      className={`caustic-surface ${className}`.trim()}\n      style={{ '--caustic-floor': background, ...style }}\n      {...rest}\n    >\n      <canvas ref={canvasRef} className=\"caustic-surface__canvas\" aria-hidden=\"true\" />\n      <div className=\"caustic-surface__content\">{children}</div>\n    </div>\n  )\n}\n","type":"registry:component","target":"components/caustic-surface/CausticSurface.jsx"},{"path":"src/components/CausticSurface/CausticSurface.css","content":"/* CausticSurface — the canvas is the backdrop and never takes the pointer;\n   children sit above it as ordinary content. The floor colour stands in\n   wherever WebGL2 is unavailable.\n\n   touch-action is left alone on purpose: a finger that drags here scrolls the\n   page, and the water only takes the tap. Where the surface is the whole\n   screen and nothing scrolls, the drag already reaches it; to give it the\n   drag on a page that does scroll, set touch-action: none on the root. */\n\n.caustic-surface {\n  position: relative;\n  overflow: hidden;\n  background: var(--caustic-floor, #03202a);\n  isolation: isolate;\n}\n\n.caustic-surface__canvas {\n  position: absolute;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  display: block;\n  pointer-events: none;\n  z-index: -1;\n}\n\n.caustic-surface__content {\n  position: relative;\n  height: 100%;\n}\n","type":"registry:file","target":"components/caustic-surface/CausticSurface.css"},{"path":"src/components/gl-core/gl.js","content":"/* =========================================================\n   gl-core\n   The WebGL2 layer under Caustic Surface, Halftone Screen,\n   Focus Peaking and Brushed Metal. Each of those is one\n   fragment shader over a rectangle; everything that is not\n   the shader lives here, once.\n\n   It is deliberately not a renderer. It owns the four places\n   raw WebGL goes quietly wrong, and nothing else.\n\n   1  Geometry. The picture is one triangle, (−1,−1) (3,−1)\n      (−1,3), clipped to the viewport — not a two-triangle\n      quad, whose diagonal seam is shaded twice. vUv runs\n      0..1 over the visible part, (0,0) bottom-left.\n\n   2  Uniforms. Which gl.uniform* call a value needs is decided\n      by the shader, not the value: [1,0] is a vec2 or an ivec2\n      depending on the declaration. So types come from the\n      linked program's own active-uniform table, and set(name,\n      value) cannot pick the wrong call. Samplers get fixed\n      texture units at link time; left alone they all default\n      to unit 0 and read whichever texture was bound last.\n\n   3  Size. A drawing buffer is device pixels, a layout box is\n      CSS pixels, and on a very large canvas the browser may\n      allocate less than was asked for without saying so.\n      resize() caps the density and takes the viewport from\n      what was actually allocated.\n\n   4  Context loss. The GPU can be taken away at any moment —\n      a driver reset, memory pressure on a phone, too many\n      contexts on one page. The browser only offers the context\n      back if the loss event was preventDefault()ed. Here it is,\n      and every handle keeps what it needs to rebuild itself,\n      so a component's references survive a loss and all it\n      does is draw again.\n\n   dispose() frees what was created but does not force the\n   context lost: StrictMode remounts an effect on the same\n   canvas, and getContext() hands back the same context — lose\n   it on the first unmount and the second mount inherits a\n   dead one.\n   ========================================================= */\n\n/* Clip-space triangle in, vUv out. */\nexport const FULLSCREEN_VERTEX = `#version 300 es\nlayout(location = 0) in vec2 position;\nout vec2 vUv;\nvoid main() {\n  vUv = position * 0.5 + 0.5;\n  gl_Position = vec4(position, 0.0, 1.0);\n}`\n\nconst EMPTY = new Uint8Array(4)\n\n/** Returns null when WebGL2 is unavailable, so the caller can render a fallback. */\nexport function createGL(canvas, { onLost, onRestore, ...attributes } = {}) {\n  let gl = null\n  try {\n    gl = canvas.getContext('webgl2', {\n      alpha: true, antialias: false, depth: false, stencil: false,\n      premultipliedAlpha: true, ...attributes,\n    })\n  } catch {\n    gl = null\n  }\n  if (!gl) return null\n\n  const resources = new Set()     // { build, free } behind every handle handed out\n  let lost = gl.isContextLost()\n  let dead = false\n  let vao = null\n  let vbo = null\n\n  const live = () => !lost && !dead\n\n  const SETTERS = new Map([\n    [gl.FLOAT, '1f'], [gl.FLOAT_VEC2, '2f'], [gl.FLOAT_VEC3, '3f'], [gl.FLOAT_VEC4, '4f'],\n    [gl.INT, '1i'], [gl.BOOL, '1i'], [gl.INT_VEC2, '2i'], [gl.INT_VEC3, '3i'],\n    [gl.FLOAT_MAT3, 'Matrix3f'], [gl.FLOAT_MAT4, 'Matrix4f'],\n  ])\n\n  const buildShared = () => {\n    vao = gl.createVertexArray()\n    vbo = gl.createBuffer()\n    gl.bindVertexArray(vao)\n    gl.bindBuffer(gl.ARRAY_BUFFER, vbo)\n    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)\n    gl.enableVertexAttribArray(0)\n    gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)\n    gl.bindVertexArray(null)\n  }\n\n  const onContextLost = (e) => {\n    e.preventDefault()            // the only thing that makes 'restored' ever fire\n    lost = true\n    onLost?.()\n  }\n  const onContextRestored = () => {\n    if (dead) return\n    lost = false\n    buildShared()\n    for (const r of resources) r.build()\n    onRestore?.()\n  }\n  canvas.addEventListener('webglcontextlost', onContextLost)\n  canvas.addEventListener('webglcontextrestored', onContextRestored)\n  if (!lost) buildShared()\n\n  // Registers a handle's GL side: built now, or on restore if the context is down.\n  const own = (entry) => {\n    resources.add(entry)\n    if (live()) entry.build()\n    return () => {\n      if (resources.delete(entry) && !dead) entry.free()\n    }\n  }\n\n  /* ---------------- programs ---------------- */\n\n  const compileError = (shader, label) =>\n    gl.getShaderParameter(shader, gl.COMPILE_STATUS)\n      ? ''\n      : `${label} shader: ${(gl.getShaderInfoLog(shader) || '').trim()}`\n\n  function program(fragment, vertex = FULLSCREEN_VERTEX) {\n    // '#version' must be the very first characters, and template literals rarely start there.\n    const fs = fragment.trimStart()\n    const vs = vertex.trimStart()\n    const uniforms = new Map()\n\n    const h = {\n      program: null,\n      /** Sets one uniform, or several from an object. Unknown names are ignored:\n          a uniform the linker optimised away is not an error. */\n      set(name, value) {\n        if (!live() || !h.program) return h\n        gl.useProgram(h.program)\n        if (typeof name === 'object') {\n          for (const k in name) uniforms.get(k)?.(name[k])\n        } else {\n          uniforms.get(name)?.(value)\n        }\n        return h\n      },\n      dispose: () => release(),\n    }\n\n    const build = () => {\n      const p = gl.createProgram()\n      const v = gl.createShader(gl.VERTEX_SHADER)\n      const f = gl.createShader(gl.FRAGMENT_SHADER)\n      gl.shaderSource(v, vs)\n      gl.shaderSource(f, fs)\n      gl.compileShader(v)\n      gl.compileShader(f)\n      gl.attachShader(p, v)\n      gl.attachShader(p, f)\n      gl.linkProgram(p)\n      // Status is only queried after a failed link; asking up front forces a\n      // synchronous driver round-trip per shader even when nothing is wrong.\n      const why = gl.getProgramParameter(p, gl.LINK_STATUS) || gl.isContextLost()\n        ? ''\n        : compileError(v, 'vertex') || compileError(f, 'fragment') || gl.getProgramInfoLog(p)\n      gl.deleteShader(v)            // the program keeps them alive while attached\n      gl.deleteShader(f)\n      if (why) {\n        gl.deleteProgram(p)\n        throw new Error(`gl-core: ${why}`)\n      }\n\n      h.program = p\n      uniforms.clear()\n      gl.useProgram(p)\n      let unit = 0\n      const count = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS) || 0\n      for (let i = 0; i < count; i++) {\n        const info = gl.getActiveUniform(p, i)\n        const loc = info && gl.getUniformLocation(p, info.name)\n        if (!loc) continue\n\n        const name = info.name.replace(/\\[0\\]$/, '')\n        if (info.type === gl.SAMPLER_2D) {\n          const u = unit++\n          gl.uniform1i(loc, u)\n          uniforms.set(name, (tex) => {\n            gl.activeTexture(gl.TEXTURE0 + u)\n            gl.bindTexture(gl.TEXTURE_2D, tex?.texture ?? null)\n          })\n          continue\n        }\n        const suffix = SETTERS.get(info.type)\n        if (!suffix) continue\n        const vec = gl[`uniform${suffix}v`].bind(gl)\n        if (suffix.startsWith('Matrix')) uniforms.set(name, (m) => vec(loc, false, m))\n        else if (suffix[0] === '1' && info.size === 1) uniforms.set(name, (n) => gl[`uniform${suffix}`](loc, +n))\n        else uniforms.set(name, (list) => vec(loc, list))\n      }\n    }\n\n    const release = own({\n      build,\n      free: () => { gl.deleteProgram(h.program); h.program = null; uniforms.clear() },\n    })\n    return h\n  }\n\n  /* ---------------- textures ---------------- */\n\n  /* An image, canvas, bitmap or video frame. Until the source has pixels — or\n     if it is cross-origin without CORS, and so not ours to read — a transparent\n     1×1 stands in, so a sampler never reads an incomplete texture, which GL\n     defines as black and some drivers as garbage. */\n  function texture(source = null, { flipY = true, repeat = false } = {}) {\n    let src = source\n    const h = {\n      texture: null,\n      width: 0,\n      height: 0,\n      ready: false,\n      update(next) {\n        if (next !== undefined) src = next\n        if (live() && h.texture) upload()\n        return h.ready\n      },\n      dispose: () => release(),\n    }\n\n    const upload = () => {\n      // On a unit no sampler uses: binding to upload replaces what the active\n      // unit held, which would silently swap out a texture a sampler reads.\n      gl.activeTexture(gl.TEXTURE0 + gl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS) - 1)\n      gl.bindTexture(gl.TEXTURE_2D, h.texture)\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, flipY)\n      const w = src ? src.naturalWidth || src.videoWidth || src.width || 0 : 0\n      const ht = src ? src.naturalHeight || src.videoHeight || src.height || 0 : 0\n      let ok = false\n      if (w > 0 && ht > 0 && src.complete !== false) {\n        try {\n          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, gl.RGBA, gl.UNSIGNED_BYTE, src)\n          ok = true\n        } catch {\n          ok = false\n        }\n      }\n      if (!ok) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, EMPTY)\n\n      const wrap = repeat ? gl.REPEAT : gl.CLAMP_TO_EDGE\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrap)\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, wrap)\n      h.width = ok ? w : 1\n      h.height = ok ? ht : 1\n      h.ready = ok\n    }\n\n    const release = own({\n      build: () => { h.texture = gl.createTexture(); upload() },\n      free: () => { gl.deleteTexture(h.texture); h.texture = null; h.ready = false },\n    })\n    return h\n  }\n\n  return {\n    gl,\n    get lost() { return lost || dead },\n    program,\n    texture,\n\n    /** Draws the triangle with the program whose set() ran last. */\n    draw() {\n      if (!live() || !vao) return\n      gl.bindFramebuffer(gl.FRAMEBUFFER, null)\n      gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)\n      gl.bindVertexArray(vao)\n      gl.drawArrays(gl.TRIANGLES, 0, 3)\n      gl.bindVertexArray(null)\n    },\n\n    /* CSS size in, device-pixel size out. Assigning canvas.width clears the\n       buffer even to the same value, so it is only written on a change. */\n    resize(width, height, dprCap = 2) {\n      const dpr = Math.min(window.devicePixelRatio || 1, dprCap > 0 ? dprCap : 2)\n      const px = (n) => Math.max(1, Math.round((Number.isFinite(n) && n > 0 ? n : 0) * dpr))\n      const w = px(width)\n      const ht = px(height)\n      if (canvas.width !== w) canvas.width = w\n      if (canvas.height !== ht) canvas.height = ht\n      // Lost, the buffer reports 0×0; the requested size is the best answer then.\n      return { width: gl.drawingBufferWidth || w, height: gl.drawingBufferHeight || ht, dpr }\n    },\n\n    dispose() {\n      if (dead) return\n      for (const r of resources) r.free()\n      resources.clear()\n      gl.deleteVertexArray(vao)\n      gl.deleteBuffer(vbo)\n      vao = vbo = null\n      canvas.removeEventListener('webglcontextlost', onContextLost)\n      canvas.removeEventListener('webglcontextrestored', onContextRestored)\n      dead = true\n    },\n  }\n}\n\n/* Runs `frame(now)` on animation frames while the element is on screen and the\n   caller says there is more to draw. `frame` returns true to keep going; kick()\n   restarts a settled loop. Shared because all four GL components need exactly\n   this, and each hand-rolled copy is another place to forget the observer. */\nexport function createLoop(element, frame) {\n  let raf = 0\n  let onScreen = true\n  const tick = (now) => {\n    raf = 0\n    if (onScreen && frame(now)) raf = requestAnimationFrame(tick)\n  }\n  const io = new IntersectionObserver(([entry]) => {\n    onScreen = entry.isIntersecting\n    if (onScreen) kick()\n    else if (raf) { cancelAnimationFrame(raf); raf = 0 }\n  })\n  io.observe(element)\n  function kick() {\n    if (!raf && onScreen) raf = requestAnimationFrame(tick)\n  }\n  return {\n    kick,\n    stop() {\n      io.disconnect()\n      if (raf) cancelAnimationFrame(raf)\n      raf = 0\n    },\n  }\n}\n\n/* Any CSS colour to linear RGB, 0..1. Shaders light in linear space; a hex\n   string is sRGB, and lighting it without decoding darkens every midtone. The\n   2D context normalises whatever CSS accepts — names, rgb(), hsl() — to hex, or\n   to rgba() when it is not opaque.\n\n   A string the context cannot parse is ignored, and fillStyle keeps what it\n   held, so reading it back after one assignment cannot tell a typo from a real\n   black. Assigning from two different starting points can: only an unparseable\n   string gives two answers, and it takes the fallback. A translucent colour\n   gives its rgb, as a shader has nothing to blend with; a fully transparent\n   one has no colour to give and takes the fallback too. */\nlet probe = null\nexport function linearRGB(color, fallback = [0, 0, 0]) {\n  probe ??= document.createElement('canvas').getContext('2d')\n  if (!probe || typeof color !== 'string') return fallback\n  probe.fillStyle = '#000'\n  probe.fillStyle = color\n  const fromBlack = probe.fillStyle\n  probe.fillStyle = '#fff'\n  probe.fillStyle = color\n  if (probe.fillStyle !== fromBlack) return fallback\n  let rgb\n  const hex = /^#([0-9a-f]{6})$/i.exec(fromBlack)\n  if (hex) {\n    const n = parseInt(hex[1], 16)\n    rgb = [n >> 16, (n >> 8) & 255, n & 255]\n  } else {\n    const rgba = /^rgba\\(\\s*(\\d+),\\s*(\\d+),\\s*(\\d+),\\s*([\\d.]+)\\s*\\)$/i.exec(fromBlack)\n    if (!rgba || Number(rgba[4]) === 0) return fallback\n    rgb = [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])]\n  }\n  return rgb.map((c) => {\n    const v = c / 255\n    return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4\n  })\n}\n\n/* Loads an image whose pixels will be uploaded. A cross-origin URL is asked\n   for with CORS, because without it the pixels are not the page's to read and\n   the upload throws. Returns a cancel function, so a load that finishes after\n   the src changed — or after unmount — never lands. */\nexport function loadImage(src, done) {\n  let cancelled = false\n  if (!src) {\n    done(null)\n    return () => {}\n  }\n  const img = new Image()\n  try {\n    if (new URL(src, window.location.href).origin !== window.location.origin) img.crossOrigin = 'anonymous'\n  } catch {\n    // Not a URL the page can resolve; let the browser report it through onerror.\n  }\n  img.decoding = 'async'\n  img.onload = () => { if (!cancelled) done(img) }\n  img.onerror = () => { if (!cancelled) done(null) }\n  img.src = src\n  return () => { cancelled = true }\n}\n","type":"registry:component","target":"components/gl-core/gl.js"},{"path":"src/components/CausticSurface/CausticSurface.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement, ReactNode } from 'react'\n\nexport interface CausticSurfaceProps extends ComponentPropsWithoutRef<'div'> {\n  /** Water between surface and floor, px. Deeper focuses the light into a sharper, busier net. */\n  depth?: number\n  /** Height of the longest wave, px. */\n  amplitude?: number\n  /** Length of the longest wave, px. The other four are fixed fractions of it. A change rescales the net about the centre of the surface. */\n  wavelength?: number\n  /** Time scale of the waves. 0 stills them; ripples still run. */\n  speed?: number\n  /** Strength of the ripple a press or tap drops, or a key that activates something on the surface. A moving pointer sheds weaker ones, scaled by its speed up to three quarters of this; focus arriving by keyboard stirs half of it under the focused element. 0 turns them off. */\n  ripple?: number\n  /** Brightness of the focused light. */\n  intensity?: number\n  /** Colour of the light. Any CSS colour. */\n  color?: string\n  /** Colour of the pool floor, and the fallback where WebGL2 is unavailable. */\n  background?: string\n  /** Device-pixel-ratio cap for the canvas. Lower is cheaper. */\n  resolution?: number\n  /** Content laid over the water. It stays real DOM above the canvas. */\n  children?: ReactNode\n}\n\ndeclare const CausticSurface: (props: CausticSurfaceProps) => ReactElement\nexport default CausticSurface\n","type":"registry:lib","target":"components/caustic-surface/CausticSurface.d.ts"}],"type":"registry:component"}