{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"watcher-iris","type":"registry:ui","title":"Watcher Iris","description":"A giant smoldering eye with a churning iris and slit pupil that tracks the cursor and blinks now and then. WebGL.","dependencies":[],"registryDependencies":[],"files":[{"path":"registry/ui/watcher-iris.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nexport interface WatcherIrisProps {\n  /** Glow color of the iris and halo. */\n  color?: string;\n  /** Eye size multiplier. */\n  scale?: number;\n  /** Iris churn speed multiplier; 0 freezes it. */\n  speed?: number;\n  /** Blink every few seconds. */\n  blink?: boolean;\n  /** The pupil follows the cursor. */\n  follow?: boolean;\n  className?: string;\n}\n\nconst VERT = \"attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}\";\n\nconst FRAG = `\nprecision highp float;\nuniform vec2 uRes;\nuniform float uTime;\nuniform vec2 uLook;\nuniform float uOpen;\nuniform vec3 uColor;\nuniform float uScale;\n\nfloat hash(vec2 p) {\n  p = fract(p * vec2(123.34, 456.21));\n  p += dot(p, p + 45.32);\n  return fract(p.x * p.y);\n}\nfloat noise(vec2 p) {\n  vec2 i = floor(p), f = fract(p);\n  vec2 u = f * f * (3.0 - 2.0 * f);\n  return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x), mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);\n}\nfloat fbm(vec2 p) {\n  float v = 0.0, a = 0.5;\n  mat2 m = mat2(1.6, 1.2, -1.2, 1.6);\n  for (int i = 0; i < 5; i++) { v += a * noise(p); p = m * p; a *= 0.5; }\n  return v;\n}\n\nvoid main() {\n  vec2 p = (gl_FragCoord.xy - 0.5 * uRes) / uRes.y / uScale;\n  float px = 1.5 / uRes.y / uScale;\n  float t = uTime;\n\n  // Almond: the intersection of two large circles. Blinking flattens it.\n  float W = 0.6;\n  float H = max(0.27 * uOpen, 0.003);\n  float c = (W * W - H * H) / (2.0 * H);\n  float R = c + H;\n  float dUp = length(p - vec2(0.0, -c)) - R;\n  float dLo = length(p - vec2(0.0, c)) - R;\n  float dEye = max(dUp, dLo);\n\n  // Iris, shifted toward the cursor.\n  vec2 ic = uLook * vec2(0.26, 0.09);\n  float ri = 0.235;\n  vec2 ip = p - ic;\n  float ir = length(ip) / ri;\n  vec2 dir = ip / max(length(ip), 1e-4);\n\n  float fib = fbm(dir * 4.0 + vec2(ir * 1.6 - t * 0.25));\n  fib = 0.55 * fib + 0.45 * fbm(dir * 13.0 + vec2(ir * 2.5 + t * 0.12, -ir));\n  vec3 hot = mix(uColor, vec3(1.0, 0.95, 0.75), 0.65);\n  vec3 iris = mix(hot, uColor, smoothstep(0.2, 0.6, ir));\n  iris = mix(iris, uColor * 0.2, smoothstep(0.6, 1.0, ir));\n  iris *= 0.45 + 1.1 * fib;\n\n  // Slit pupil that breathes a little.\n  float dil = 0.15 + 0.035 * sin(t * 0.6);\n  float pd = length((ip / ri) / vec2(dil, 0.78));\n  float pupil = smoothstep(1.0 + px * 12.0, 1.0 - px * 12.0, pd);\n  iris += hot * exp(-max(pd - 1.0, 0.0) * 5.0) * 0.5 * (1.0 - pupil);\n  iris *= smoothstep(1.03, 0.86, ir);\n  iris = mix(iris, vec3(0.0), pupil);\n  float irisMask = smoothstep(1.0 + px / ri, 1.0 - px / ri, ir);\n\n  // Dark, veined sclera.\n  float veins = smoothstep(0.018, 0.0, abs(fbm(p * vec2(4.0, 9.0) + 3.0) - 0.5)) * smoothstep(0.12, 0.5, abs(p.x));\n  vec3 sclera = mix(vec3(0.05, 0.012, 0.012), uColor * 0.16, smoothstep(0.65, 0.0, length(p)));\n  sclera += uColor * veins * 0.22;\n  vec3 eye = mix(sclera, iris, irisMask);\n\n  // Lid shadow and wet highlights.\n  eye *= mix(0.25, 1.0, smoothstep(0.0, 0.1, -dUp));\n  eye *= mix(0.6, 1.0, smoothstep(0.0, 0.05, -dLo));\n  vec2 hl = p - ic - vec2(-0.075, 0.08);\n  eye += vec3(1.0) * 0.75 * smoothstep(0.03, 0.012, length(hl * vec2(1.0, 1.4)));\n  eye += vec3(1.0) * 0.25 * smoothstep(0.018, 0.006, length(p - ic - vec2(0.07, -0.07)));\n\n  float inside = smoothstep(px, -px, dEye);\n\n  // Smoky dark surroundings, a halo and a burning lid line.\n  vec3 bg = vec3(0.012, 0.006, 0.008);\n  bg += uColor * 0.07 * fbm(p * 2.5 + vec2(t * 0.05, -t * 0.03)) * (1.0 - smoothstep(0.2, 1.2, length(p)));\n  float halo = exp(-max(dEye, 0.0) * 7.0);\n  bg += uColor * halo * (0.28 + 0.2 * uOpen);\n  vec3 col = mix(bg, eye, inside);\n  col += mix(uColor, vec3(1.0, 0.9, 0.7), 0.3) * exp(-abs(dEye) / (px * 3.0)) * 0.7;\n\n  vec2 v = gl_FragCoord.xy / uRes - 0.5;\n  col *= 1.0 - dot(v, v) * 1.2;\n  gl_FragColor = vec4(col, 1.0);\n}\n`;\n\nfunction rgb(hex: string) {\n  let h = hex.replace(\"#\", \"\");\n  if (h.length === 3) h = h.replace(/./g, \"$&$&\");\n  const n = parseInt(h.padEnd(6, \"0\").slice(0, 6), 16) || 0;\n  return [((n >> 16) & 255) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];\n}\n\n/** Compiles a full-screen-triangle program and returns a cached uniform lookup, or null. */\nfunction fullscreen(gl: WebGLRenderingContext, frag: string) {\n  const prog = gl.createProgram();\n  if (!prog) return null;\n  for (const [type, src] of [\n    [gl.VERTEX_SHADER, VERT],\n    [gl.FRAGMENT_SHADER, frag],\n  ] as const) {\n    const sh = gl.createShader(type);\n    if (!sh) return null;\n    gl.shaderSource(sh, src);\n    gl.compileShader(sh);\n    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) console.warn(gl.getShaderInfoLog(sh));\n    gl.attachShader(prog, sh);\n  }\n  gl.bindAttribLocation(prog, 0, \"p\");\n  gl.linkProgram(prog);\n  if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) return null;\n  gl.useProgram(prog);\n  gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());\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  const cache = new Map<string, WebGLUniformLocation | null>();\n  return (name: string) => {\n    if (!cache.has(name)) cache.set(name, gl.getUniformLocation(prog, name));\n    return cache.get(name) ?? null;\n  };\n}\n\nexport function WatcherIris({\n  color = \"#ff4d1a\",\n  scale = 1,\n  speed = 1,\n  blink = true,\n  follow = true,\n  className,\n}: WatcherIrisProps) {\n  const host = useRef<HTMLDivElement>(null);\n  const opts = useRef({ color, scale, speed, blink, follow });\n  const redraw = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    opts.current = { color, scale, speed, blink, follow };\n    redraw.current?.();\n  }, [color, scale, speed, blink, follow]);\n\n  useEffect(() => {\n    const el = host.current!;\n    const canvas = document.createElement(\"canvas\");\n    canvas.style.cssText = \"position:absolute;inset:0;width:100%;height:100%;display:block\";\n    el.appendChild(canvas);\n    const gl = canvas.getContext(\"webgl\", { antialias: false, powerPreference: \"low-power\" });\n    const u = gl && fullscreen(gl, FRAG);\n    if (!gl || !u) {\n      canvas.remove();\n      return;\n    }\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    // look: smoothed gaze in [-1, 1]; target is set from the pointer.\n    const look = { x: 0, y: 0, tx: 0, ty: 0 };\n    let raf = 0;\n    let visible = true;\n    let time = 3;\n    let clock = 0;\n    let last = 0;\n    let open = 1;\n    let nextBlink = 2.5;\n\n    const draw = () => {\n      const o = opts.current;\n      gl.uniform2f(u(\"uRes\"), canvas.width, canvas.height);\n      gl.uniform1f(u(\"uTime\"), time);\n      gl.uniform2f(u(\"uLook\"), o.follow ? look.x : 0, o.follow ? look.y : 0);\n      gl.uniform1f(u(\"uOpen\"), open);\n      gl.uniform3fv(u(\"uColor\"), rgb(o.color));\n      gl.uniform1f(u(\"uScale\"), Math.max(o.scale, 0.1));\n      gl.drawArrays(gl.TRIANGLES, 0, 3);\n    };\n    const loop = (now: number) => {\n      const dt = Math.min(now - last, 50) / 1000;\n      last = now;\n      time += dt * opts.current.speed;\n      clock += dt;\n      look.x += (look.tx - look.x) * 0.08;\n      look.y += (look.ty - look.y) * 0.08;\n      // Blink: a quick close and a slightly slower open, every 3 to 7 seconds.\n      const b = clock - nextBlink;\n      if (!opts.current.blink || b < 0) open = 1;\n      else if (b < 0.09) open = 1 - b / 0.09;\n      else if (b < 0.3) open = (b - 0.09) / 0.21;\n      else {\n        open = 1;\n        nextBlink = clock + 3 + Math.random() * 4;\n      }\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n    const play = () => {\n      if (raf || !visible || reduced) return;\n      last = performance.now();\n      raf = requestAnimationFrame(loop);\n    };\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(el.clientWidth * dpr));\n      canvas.height = Math.max(1, Math.round(el.clientHeight * dpr));\n      gl.viewport(0, 0, canvas.width, canvas.height);\n      draw();\n    };\n    const onMove = (e: PointerEvent) => {\n      const r = el.getBoundingClientRect();\n      const x = ((e.clientX - r.left) / r.width) * 2 - 1;\n      const y = 1 - ((e.clientY - r.top) / r.height) * 2;\n      const len = Math.hypot(x, y);\n      look.tx = len > 1 ? x / len : x;\n      look.ty = len > 1 ? y / len : y;\n    };\n    const onLeave = () => {\n      look.tx = 0;\n      look.ty = 0;\n    };\n\n    const target = el.parentElement ?? el;\n    target.addEventListener(\"pointermove\", onMove, { passive: true });\n    target.addEventListener(\"pointerleave\", onLeave);\n    const ro = new ResizeObserver(resize);\n    ro.observe(el);\n    const io = new IntersectionObserver(([entry]) => {\n      visible = entry.isIntersecting;\n      if (visible) play();\n      else {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    });\n    io.observe(el);\n    redraw.current = draw;\n\n    return () => {\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      io.disconnect();\n      target.removeEventListener(\"pointermove\", onMove);\n      target.removeEventListener(\"pointerleave\", onLeave);\n      redraw.current = null;\n      gl.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      canvas.remove();\n    };\n  }, []);\n\n  return (\n    <div\n      ref={host}\n      aria-hidden\n      className={className}\n      style={{ position: \"absolute\", inset: 0, overflow: \"hidden\", pointerEvents: \"none\" }}\n    />\n  );\n}\n"}]}