{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ambient-field",
  "title": "Ambient Field",
  "author": "Alex Chih (https://alexchih.com)",
  "description": "Viewport-fixed tri-color light field the page scrolls over — WebGL glows with per-pixel dither, CSS fallback for first paint, film-grain overlay. Reduced-motion safe.",
  "registryDependencies": [
    "https://washiveil.alexchih.com/r/theme.json"
  ],
  "files": [
    {
      "path": "registry/washiveil/ui/ambient-field.tsx",
      "content": "'use client';\n\n// Viewport-fixed tri-color ambient light field. Three engines:\n// - WebGL canvas (primary): renders three glows in float precision with\n//   per-pixel dither — no 8-bit contour rings at any zoom. Adaptive loop:\n//   full-rate only while scroll inertia settles, ~15fps for the idle wander,\n//   paused on hidden tabs; reduced-motion users get a static render. Colors\n//   read from @theme tokens at render time — palette stays live.\n// - CSS divs (fallback): the pre-JS FIRST PAINT — server-rendered, visible\n//   the instant HTML arrives, swapped invisibly once the canvas renders. This\n//   is why it stays: without it every navigation would flash a bare ground\n//   before the glows pop in. no-JS / no-WebGL / context-loss coverage is a\n//   bonus. Geometry: the shader anchors to these sizes at phone/desktop\n//   widths and interpolates continuously in the tablet band.\n// - Film-grain overlay div with SVG turbulence data-URI background and a\n//   dark-mode radial mask.\n//\n// The inset-0 clip keeps negative offsets from widening the document\n// (scrollWidth bug). Note: full-page screenshot tools paint fixed layers at the\n// first viewport only — capture artifact, not a bug.\n\nimport { useEffect, useRef } from 'react';\nimport { cn } from '@/lib/utils';\n\nconst VERT = 'attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}';\nconst FRAG = `\nprecision highp float;\nuniform vec2 u_res;\nuniform vec4 u_g0;uniform vec4 u_g1;uniform vec4 u_g2;\nuniform vec3 u_s;\nuniform vec3 u_c0;uniform vec3 u_c1;uniform vec3 u_c2;\nuniform vec3 u_a;\nfloat hash(vec2 p){return fract(sin(dot(p,vec2(12.9898,78.233)))*43758.5453);}\nfloat glow(vec2 p,vec4 g,float s){\n  vec2 q=(p-g.xy)/g.zw;\n  float d=(length(q)-1.0)*min(g.z,g.w);\n  return 1.0-smoothstep(-1.2*s,1.6*s,d);\n}\nvoid main(){\n  vec2 p=vec2(gl_FragCoord.x,u_res.y-gl_FragCoord.y);\n  float c0=glow(p,u_g0,u_s.x);\n  float c1=glow(p,u_g1,u_s.y);\n  float c2=glow(p,u_g2,u_s.z);\n  vec3 rgb=vec3(0.0);float a=0.0;float ai;\n  ai=u_a.x*c0;rgb+=(1.0-a)*ai*u_c0;a+=(1.0-a)*ai;\n  ai=u_a.y*c1;rgb+=(1.0-a)*ai*u_c1;a+=(1.0-a)*ai;\n  ai=u_a.z*c2;rgb+=(1.0-a)*ai*u_c2;a+=(1.0-a)*ai;\n  // Full-strength dither wherever ANY coverage exists (the faint tails are\n  // exactly where 8-bit steps live) — alpha-scaled dither was a bug that\n  // switched the cure off where the disease is. Bare ground stays clean.\n  float dn=(hash(gl_FragCoord.xy)-0.5)*(2.5/255.0)*smoothstep(0.0,0.005,max(c0,max(c1,c2)));\n  rgb=max(rgb+vec3(dn),vec3(0.0));\n  a=clamp(a+dn,0.0,1.0);\n  gl_FragColor=vec4(rgb,a);\n}`;\n\n/** Parse a --color-* CSS custom property to normalized [r, g, b]. */\nconst hex = (name: string): [number, number, number] => {\n  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n  const n = parseInt(v.slice(1), 16);\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n};\n\nexport function AmbientField({ className }: { className?: string }) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const fallbackRef = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    const fallback = fallbackRef.current;\n    if (!canvas || !fallback) return;\n\n    const drift = matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 1;\n\n    let gl: WebGLRenderingContext | null = null;\n    let raf = 0;\n    let resizeTimer: ReturnType<typeof setTimeout>;\n    let contextLost = false;\n    // GL resource handles — retained for cleanup and context-restore rebuild.\n    let glProgram: WebGLProgram | null = null;\n    let glVertShader: WebGLShader | null = null;\n    let glFragShader: WebGLShader | null = null;\n    let glBuffer: WebGLBuffer | null = null;\n    // Handles accumulated during init — all cleaned up on unmount.\n    let visibilityHandler: (() => void) | null = null;\n    let contextLostHandler: ((e: Event) => void) | null = null;\n    let contextRestoredHandler: (() => void) | null = null;\n    let mutationObserver: MutationObserver | null = null;\n    let resizeHandler: ((this: Window, ev: Event) => void) | undefined;\n\n    const init = () => {\n      gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false });\n      if (!gl) return;\n\n      const sh = (type: number, src: string) => {\n        const s = gl!.createShader(type)!;\n        gl!.shaderSource(s, src);\n        gl!.compileShader(s);\n        if (!gl!.getShaderParameter(s, gl!.COMPILE_STATUS)) throw new Error(gl!.getShaderInfoLog(s) ?? 'shader');\n        return s;\n      };\n      glVertShader = sh(gl.VERTEX_SHADER, VERT);\n      glFragShader = sh(gl.FRAGMENT_SHADER, FRAG);\n      const prog = gl.createProgram()!;\n      glProgram = prog;\n      gl.attachShader(prog, glVertShader);\n      gl.attachShader(prog, glFragShader);\n      gl.linkProgram(prog);\n      if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) throw new Error('link');\n      gl.useProgram(prog);\n      glBuffer = gl.createBuffer();\n      gl.bindBuffer(gl.ARRAY_BUFFER, glBuffer);\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);\n      const loc = gl.getAttribLocation(prog, 'p');\n      gl.enableVertexAttribArray(loc);\n      gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);\n      const u = (n: string) => gl!.getUniformLocation(prog, n);\n      let sScroll = window.scrollY || 0;\n\n      const render = (now: number = performance.now()) => {\n        if (!gl || contextLost) return;\n        const dpr = Math.min(devicePixelRatio || 1, 2);\n        const vw = innerWidth;\n        const vh = innerHeight;\n        const W = Math.round(vw * dpr);\n        const H = Math.round(vh * dpr);\n        if (canvas.width !== W || canvas.height !== H) {\n          canvas.width = W;\n          canvas.height = H;\n        }\n        gl.viewport(0, 0, W, H);\n\n        // Geometry anchors to the CSS fallback at phone (<=640) and desktop\n        // (>=1024) widths, and interpolates continuously between them — no size\n        // pop while resizing, better-fitted glows on tablets. (The CSS fallback\n        // stays two-step; it only shows pre-JS, so the divergence window is the\n        // tablet band for ~100ms.) rem-based, so 4K root-font scaling carries.\n        const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);\n        const t = Math.min(Math.max((vw - 640) / (1024 - 640), 0), 1);\n        const lerp = (a: number, b: number) => a + (b - a) * t;\n        const geo = (w: number, h: number, cx: number, cy: number): [number, number, number, number] => [\n          cx * dpr,\n          cy * dpr,\n          ((w * rem) / 2) * dpr,\n          ((h * rem) / 2) * dpr,\n        ];\n        const w1 = lerp(22, 32.5);\n        const h1 = lerp(18, 26.25);\n        const w2 = lerp(18, 27.5);\n        const h2 = lerp(16, 23.75);\n        const w3 = lerp(20, 30);\n        const h3 = lerp(15, 22.5);\n        // Motion, all gated by prefers-reduced-motion: scroll drift with\n        // inertia (sScroll eases toward scrollY in the loop) plus a subliminal\n        // idle wander — the 404 orbs' narrative at \"stare ten seconds to be\n        // sure\" amplitude. Knobs: drift factors, WANDER, per-orb periods\n        // (co-prime seconds so the paths don't visibly repeat).\n        // Drift is a whisper, not a journey — long pages must not relocate the lights.\n        const syMax = (8 / 0.07) * rem; // largest drift factor pins the cap\n        const sy = drift * Math.max(-syMax, Math.min(syMax, sScroll));\n        const tm = (drift * now) / 1000;\n        const WANDER = drift * 0.6 * rem;\n        const wob = (px: number, py: number, ph: number): [number, number] => [\n          WANDER * Math.sin((tm / px) * 2 * Math.PI + ph),\n          WANDER * Math.sin((tm / py) * 2 * Math.PI + ph * 1.7),\n        ];\n        const [ax, ay] = wob(53, 37, 0);\n        const [bx, by] = wob(61, 43, 2.1);\n        const [cx, cy] = wob(47, 31, 4.2);\n        const g0 = geo(\n          w1,\n          h1,\n          -7.5 * rem + (w1 * rem) / 2 + 0.02 * sy + ax,\n          -10 * rem + (h1 * rem) / 2 + 0.05 * sy + ay,\n        );\n        const g1 = geo(\n          w2,\n          h2,\n          vw + 10 * rem - (w2 * rem) / 2 - 0.02 * sy + bx,\n          0.35 * vh + (h2 * rem) / 2 - 0.07 * sy + by,\n        );\n        const g2 = geo(\n          w3,\n          h3,\n          -6.25 * rem + (w3 * rem) / 2 + 0.03 * sy + cx,\n          vh + 8.75 * rem - (h3 * rem) / 2 - 0.04 * sy + cy,\n        );\n\n        const dark = document.documentElement.classList.contains('dark');\n        const cols = dark\n          ? [hex('--color-ruri'), hex('--color-sumire-soft'), hex('--color-korozen-soft')]\n          : [hex('--color-ruri'), hex('--color-sumire'), hex('--color-korozen')];\n        const alphas = dark ? [0.4, 0.36, 0.24] : [0.26, 0.28, 0.21];\n\n        gl.uniform2f(u('u_res'), canvas.width, canvas.height);\n        gl.uniform4fv(u('u_g0'), g0);\n        gl.uniform4fv(u('u_g1'), g1);\n        gl.uniform4fv(u('u_g2'), g2);\n        gl.uniform3f(u('u_s'), 6.25 * rem * dpr, 6.875 * rem * dpr, 7.5 * rem * dpr);\n        gl.uniform3fv(u('u_c0'), cols[0]);\n        gl.uniform3fv(u('u_c1'), cols[1]);\n        gl.uniform3fv(u('u_c2'), cols[2]);\n        gl.uniform3f(u('u_a'), alphas[0], alphas[1], alphas[2]);\n        gl.clearColor(0, 0, 0, 0);\n        gl.clear(gl.COLOR_BUFFER_BIT);\n        gl.drawArrays(gl.TRIANGLES, 0, 3);\n        canvas.hidden = false;\n        fallback.hidden = true;\n      };\n\n      render();\n\n      const _resizeHandler = () => {\n        if (contextLost) return;\n        clearTimeout(resizeTimer);\n        resizeTimer = setTimeout(() => render(), 150);\n      };\n      window.addEventListener('resize', _resizeHandler);\n      resizeHandler = _resizeHandler;\n\n      if (drift) {\n        // One adaptive loop: full-rate while scroll inertia settles, ~15fps for\n        // the idle wander, paused entirely while the tab is hidden.\n        let last = 0;\n        let activeUntil = 0;\n        const tick = (now: number) => {\n          if (contextLost) return;\n          const target = window.scrollY || 0;\n          if (Math.abs(target - sScroll) > 0.5) activeUntil = now + 300;\n          sScroll += (target - sScroll) * 0.1;\n          if (now < activeUntil || now - last >= 66) {\n            render(now);\n            last = now;\n          }\n          raf = requestAnimationFrame(tick);\n        };\n        raf = requestAnimationFrame(tick);\n        visibilityHandler = () => {\n          cancelAnimationFrame(raf);\n          if (!document.hidden && !contextLost) raf = requestAnimationFrame(tick);\n        };\n        document.addEventListener('visibilitychange', visibilityHandler);\n\n        // Rebuild GL and restart the loop on context restore. init() re-registers\n        // every listener below, so tear the current set down first — otherwise\n        // each loss/restore cycle stacks another resize/visibility/observer set.\n        contextRestoredHandler = () => {\n          contextLost = false;\n          deleteGLResources();\n          cancelAnimationFrame(raf);\n          if (resizeHandler) {\n            window.removeEventListener('resize', resizeHandler);\n            resizeHandler = undefined;\n          }\n          if (visibilityHandler) {\n            document.removeEventListener('visibilitychange', visibilityHandler);\n            visibilityHandler = null;\n          }\n          if (contextRestoredHandler) canvas.removeEventListener('webglcontextrestored', contextRestoredHandler);\n          if (mutationObserver) {\n            mutationObserver.disconnect();\n            mutationObserver = null;\n          }\n          try {\n            init();\n          } catch {\n            // rebuild failed — fallback stays visible\n          }\n        };\n        canvas.addEventListener('webglcontextrestored', contextRestoredHandler);\n      }\n\n      // Re-render when dark mode toggles (html class change).\n      mutationObserver = new MutationObserver(() => {\n        if (!contextLost) render();\n      });\n      mutationObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });\n    };\n\n    /** Delete retained GL resources (program, shaders, buffer). */\n    const deleteGLResources = () => {\n      if (gl) {\n        if (glProgram) gl.deleteProgram(glProgram);\n        if (glVertShader) gl.deleteShader(glVertShader);\n        if (glFragShader) gl.deleteShader(glFragShader);\n        if (glBuffer) gl.deleteBuffer(glBuffer);\n      }\n      glProgram = null;\n      glVertShader = null;\n      glFragShader = null;\n      glBuffer = null;\n    };\n\n    // Context-lost handler: prevent default (allows restore), cancel the loop,\n    // set the lost flag, and show the CSS fallback.\n    contextLostHandler = (e: Event) => {\n      e.preventDefault();\n      contextLost = true;\n      cancelAnimationFrame(raf);\n      canvas.hidden = true;\n      fallback.hidden = false;\n    };\n    canvas.addEventListener('webglcontextlost', contextLostHandler);\n\n    try {\n      init();\n    } catch {\n      // init failed — fallback divs stay visible.\n    }\n\n    // FULL cleanup on unmount (Astro source never unmounts; React must).\n    return () => {\n      cancelAnimationFrame(raf);\n      clearTimeout(resizeTimer);\n      deleteGLResources();\n      if (resizeHandler) window.removeEventListener('resize', resizeHandler);\n      if (visibilityHandler) document.removeEventListener('visibilitychange', visibilityHandler);\n      if (contextLostHandler) canvas.removeEventListener('webglcontextlost', contextLostHandler);\n      if (contextRestoredHandler) canvas.removeEventListener('webglcontextrestored', contextRestoredHandler);\n      if (mutationObserver) mutationObserver.disconnect();\n    };\n  }, []);\n\n  return (\n    <div className={cn('pointer-events-none fixed inset-0 -z-10 isolate overflow-hidden', className)} aria-hidden=\"true\">\n      <canvas ref={canvasRef} className=\"absolute inset-0 h-full w-full\" hidden />\n      <div ref={fallbackRef}>\n        <div className=\"absolute top-[-10rem] left-[-7.5rem] h-[18rem] w-[22rem] rounded-full bg-ruri/26 blur-[6.25rem] sm:h-[26.25rem] sm:w-[32.5rem] dark:bg-ruri/40\" />\n        <div className=\"absolute top-[35%] right-[-10rem] h-[16rem] w-[18rem] rounded-full bg-sumire/28 blur-[6.875rem] sm:h-[23.75rem] sm:w-[27.5rem] dark:bg-sumire-soft/36\" />\n        <div className=\"absolute bottom-[-8.75rem] left-[-6.25rem] h-[15rem] w-[20rem] rounded-full bg-korozen/21 blur-[7.5rem] sm:h-[22.5rem] sm:w-[30rem] dark:bg-korozen-soft/24\" />\n      </div>\n      {/* Film-grain dither: breaks the 8-bit quantization rings the blurred glows\n          show on the dark ground. Monochrome SVG turbulence, soft-light blend. */}\n      <div className=\"wv-grain absolute inset-0 opacity-[0.14] dark:opacity-[0.15]\" />\n      <style>{`\n        .wv-grain {\n          background-image: url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='128' height='128'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='128' height='128' filter='url(%23n)'/%3E%3C/svg%3E\");\n          background-size: 128px 128px;\n          mix-blend-mode: soft-light;\n        }\n        /* Dark: true black can't read as paper (soft-light's darken half has nothing\n           to darken, leaving lighten-only speckle = static). So the ground gets NO\n           grain at all — the dither is masked to the three glow regions, the only\n           place banding exists. Light keeps the full-field paper feel. */\n        html.dark .wv-grain {\n          mask-image:\n            radial-gradient(40rem 32rem at 9rem 3rem, black 35%, transparent 72%),\n            radial-gradient(46rem 40rem at calc(100% + 4rem) 45%, black 40%, transparent 78%),\n            radial-gradient(40rem 30rem at 9rem 100%, black 35%, transparent 72%);\n        }\n      `}</style>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "Render <AmbientField /> once at the app root, before your content. It is viewport-fixed; everything scrolls over it.",
  "type": "registry:ui"
}