{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-defrost",
  "title": "Scroll Defrost",
  "description": "Scroll-scrubbed defroster: a pinned full-bleed image behind rippled shower glass anneals from heavy frost with chromatic fringing to optical clarity, sealed by a specular sweep.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/scroll-defrost/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// FrostScrub — scroll is the defroster. A pinned full-bleed image sits behind\n// a pane of rippled shower glass: heavily frosted at progress 0 with chromatic\n// fringing on every ripple edge, annealing to optical clarity as a 300vh\n// section scrubs. Fully reversible; a specular sweep crosses the pane as\n// progress runs 0.92 → 1.0. Raw WebGL — one fullscreen quad, one fragment\n// shader, zero deps. Direct-DOM rAF loop, no React state on the hot path,\n// sleeps when |target − current| < 0.001 and wakes on scroll only.\n// ---------------------------------------------------------------------------\n\nconst NOISE_SIZE = 256;\nconst LERP = 0.12; // rendered progress chases target at 0.12/frame\nconst SETTLE = 0.001;\n\n// house sin-hash value noise on a periodic lattice, so the 256×256 normal-map\n// texture tiles seamlessly under REPEAT wrap\nfunction hashP(x: number, y: number, period: number) {\n  const xi = ((x % period) + period) % period;\n  const yi = ((y % period) + period) % period;\n  const n = Math.sin(xi * 127.1 + yi * 311.7 + period * 0.917) * 43758.5453123;\n  return n - Math.floor(n);\n}\nfunction vnoiseP(x: number, y: number, period: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = x - xi;\n  const yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  const a = hashP(xi, yi, period);\n  const b = hashP(xi + 1, yi, period);\n  const c = hashP(xi, yi + 1, period);\n  const d = hashP(xi + 1, yi + 1, period);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\n// fx, fy in [0,1) across the tile; both octave frequencies divide the tile\nfunction noise2(fx: number, fy: number) {\n  return 0.65 * vnoiseP(fx * 8, fy * 8, 8) + 0.35 * vnoiseP(fx * 16, fy * 16, 16);\n}\n\n// 256×256 RGBA: normal.xy packed in RG (central differences, wrapped),\n// ripple height in B — generated once on the CPU\nfunction buildNoiseTexture() {\n  const s = NOISE_SIZE;\n  const height = new Float32Array(s * s);\n  for (let y = 0; y < s; y++) {\n    for (let x = 0; x < s; x++) {\n      height[y * s + x] = noise2(x / s, y / s);\n    }\n  }\n  const data = new Uint8Array(s * s * 4);\n  for (let y = 0; y < s; y++) {\n    const up = ((y + s - 1) % s) * s;\n    const dn = ((y + 1) % s) * s;\n    const row = y * s;\n    for (let x = 0; x < s; x++) {\n      const l = height[row + ((x + s - 1) % s)] ?? 0;\n      const r = height[row + ((x + 1) % s)] ?? 0;\n      const t = height[up + x] ?? 0;\n      const b = height[dn + x] ?? 0;\n      const nx = Math.max(-1, Math.min(1, (l - r) * 6));\n      const ny = Math.max(-1, Math.min(1, (t - b) * 6));\n      const o = (row + x) * 4;\n      data[o] = Math.round((nx * 0.5 + 0.5) * 255);\n      data[o + 1] = Math.round((ny * 0.5 + 0.5) * 255);\n      data[o + 2] = Math.round((height[row + x] ?? 0) * 255);\n      data[o + 3] = 255;\n    }\n  }\n  return data;\n}\n\n// monochrome studio still (sphere on a lit wall, film grain) — the default\n// subject when no src is provided, so the bare component is self-contained\nfunction buildDefaultArt(): HTMLCanvasElement | null {\n  const w = 900;\n  const h = 1200;\n  const c = document.createElement(\"canvas\");\n  c.width = w;\n  c.height = h;\n  const ctx = c.getContext(\"2d\");\n  if (!ctx) return null;\n\n  const bg = ctx.createLinearGradient(0, 0, 0, h);\n  bg.addColorStop(0, \"#181818\");\n  bg.addColorStop(1, \"#090909\");\n  ctx.fillStyle = bg;\n  ctx.fillRect(0, 0, w, h);\n\n  const glow = ctx.createRadialGradient(w * 0.5, h * 0.34, 0, w * 0.5, h * 0.34, h * 0.55);\n  glow.addColorStop(0, \"rgba(255,255,255,0.12)\");\n  glow.addColorStop(1, \"rgba(255,255,255,0)\");\n  ctx.fillStyle = glow;\n  ctx.fillRect(0, 0, w, h);\n\n  const cx = w * 0.5;\n  const cy = h * 0.45;\n  const r = w * 0.3;\n\n  ctx.save();\n  ctx.translate(cx, cy + r * 1.32);\n  ctx.scale(1, 0.22);\n  const shadow = ctx.createRadialGradient(0, 0, 0, 0, 0, r * 1.15);\n  shadow.addColorStop(0, \"rgba(0,0,0,0.55)\");\n  shadow.addColorStop(1, \"rgba(0,0,0,0)\");\n  ctx.fillStyle = shadow;\n  ctx.beginPath();\n  ctx.arc(0, 0, r * 1.15, 0, Math.PI * 2);\n  ctx.fill();\n  ctx.restore();\n\n  const sphere = ctx.createRadialGradient(cx - r * 0.38, cy - r * 0.42, r * 0.08, cx, cy, r);\n  sphere.addColorStop(0, \"#f2f2f2\");\n  sphere.addColorStop(0.45, \"#8f8f8f\");\n  sphere.addColorStop(0.8, \"#2c2c2c\");\n  sphere.addColorStop(1, \"#101010\");\n  ctx.fillStyle = sphere;\n  ctx.beginPath();\n  ctx.arc(cx, cy, r, 0, Math.PI * 2);\n  ctx.fill();\n\n  const spec = ctx.createRadialGradient(cx - r * 0.42, cy - r * 0.48, 0, cx - r * 0.42, cy - r * 0.48, r * 0.22);\n  spec.addColorStop(0, \"rgba(255,255,255,0.95)\");\n  spec.addColorStop(1, \"rgba(255,255,255,0)\");\n  ctx.fillStyle = spec;\n  ctx.beginPath();\n  ctx.arc(cx - r * 0.42, cy - r * 0.48, r * 0.22, 0, Math.PI * 2);\n  ctx.fill();\n\n  // film grain — Uint8ClampedArray clamps writes, no manual bounds needed\n  const id = ctx.getImageData(0, 0, w, h);\n  const d = id.data;\n  for (let i = 0; i < d.length; i += 4) {\n    const g = (Math.random() - 0.5) * 10;\n    d[i] = (d[i] ?? 0) + g;\n    d[i + 1] = (d[i + 1] ?? 0) + g;\n    d[i + 2] = (d[i + 2] ?? 0) + g;\n  }\n  ctx.putImageData(id, 0, 0);\n  return c;\n}\n\nconst VERT = `\nattribute vec2 aPos;\nvarying vec2 vUv;\nvoid main() {\n  vUv = aPos * 0.5 + 0.5;\n  gl_Position = vec4(aPos, 0.0, 1.0);\n}\n`;\n\nconst FRAG = `\nprecision highp float;\n\nvarying vec2 vUv;\n\nuniform sampler2D uTex;     // image\nuniform sampler2D uNoise;   // tiling ripple normal map (RG = normal, B = height)\nuniform vec2 uRes;          // pane size, CSS px\nuniform vec2 uImgRes;       // image size, px\nuniform float uDpr;\nuniform float uProgress;    // 0 = frosted, 1 = annealed\nuniform float uMaxOffset;   // refraction amplitude at full frost, px\nuniform float uFrostRadius; // poisson scatter radius at full frost, px\nuniform float uDispersion;  // chromatic step between R/G/B offset scales\nuniform float uEdgeFrost;   // 1 = reduced-motion frost vignette at pane edges\n\n// object-fit: cover mapping\nvec2 coverUv(vec2 uv) {\n  float ra = uRes.x / uRes.y;\n  float ia = uImgRes.x / uImgRes.y;\n  vec2 s = ra > ia ? vec2(1.0, ia / ra) : vec2(ra / ia, 1.0);\n  return (uv - 0.5) * s + 0.5;\n}\n\n// one refracted sample with chromatic dispersion: R/G/B at offset scales\n// 1.0 / 1.0+d / 1.0+2d — fringing lives on every ripple edge\nvec3 glassTap(vec2 base, vec2 off) {\n  float r = texture2D(uTex, coverUv(base + off)).r;\n  float g = texture2D(uTex, coverUv(base + off * (1.0 + uDispersion))).g;\n  float b = texture2D(uTex, coverUv(base + off * (1.0 + 2.0 * uDispersion))).b;\n  return vec3(r, g, b);\n}\n\nvoid main() {\n  vec2 uv = vUv;\n  vec2 cssPx = gl_FragCoord.xy / uDpr;\n  vec3 n = texture2D(uNoise, cssPx / 256.0).rgb * 2.0 - 1.0;\n\n  float frost = 1.0 - uProgress;\n  // reduced motion: faint frost confined to the pane edges at p = 1\n  vec2 fromCenter = abs(uv - 0.5);\n  float edge = smoothstep(0.30, 0.5, max(fromCenter.x, fromCenter.y));\n  frost = clamp(max(frost, uEdgeFrost * edge * 0.35), 0.0, 1.0);\n\n  float amp = pow(frost, 1.6);\n  vec2 off = n.xy * amp * uMaxOffset / uRes;\n  vec2 scatter = vec2(frost * uFrostRadius) / uRes;\n\n  // 5-tap poisson frost scatter around the refracted lookup\n  vec3 col = glassTap(uv, off);\n  col += glassTap(uv + vec2(-0.94, -0.39) * scatter, off);\n  col += glassTap(uv + vec2( 0.97, -0.19) * scatter, off);\n  col += glassTap(uv + vec2(-0.37,  0.86) * scatter, off);\n  col += glassTap(uv + vec2( 0.45,  0.61) * scatter, off);\n  col *= 0.2;\n\n  // frost lifts the pane toward white; ripple height catches a little light\n  col = mix(col, vec3(0.82), amp * 0.22);\n  col += n.z * amp * 0.06;\n\n  // specular sweep: white band crosses the pane once as p runs 0.92 -> 1.0\n  float sw = clamp((uProgress - 0.92) / 0.08, 0.0, 1.0);\n  float axis = (uv.x + (1.0 - uv.y) * 0.4) / 1.4;\n  float band = smoothstep(0.08, 0.0, abs(axis - mix(-0.15, 1.15, sw)));\n  col += vec3(band * 0.35);\n\n  gl_FragColor = vec4(col, 1.0);\n}\n`;\n\nfunction createProgram(gl: WebGLRenderingContext, vert: string, frag: string) {\n  const compile = (type: number, source: string) => {\n    const sh = gl.createShader(type);\n    if (!sh) return null;\n    gl.shaderSource(sh, source);\n    gl.compileShader(sh);\n    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {\n      gl.deleteShader(sh);\n      return null;\n    }\n    return sh;\n  };\n  const vs = compile(gl.VERTEX_SHADER, vert);\n  const fs = compile(gl.FRAGMENT_SHADER, frag);\n  if (!vs || !fs) return null;\n  const program = gl.createProgram();\n  if (!program) return null;\n  gl.attachShader(program, vs);\n  gl.attachShader(program, fs);\n  gl.linkProgram(program);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    gl.deleteProgram(program);\n    return null;\n  }\n  return program;\n}\n\nexport function FrostScrub({\n  src,\n  alt = \"Image annealing from frosted glass to optical clarity\",\n  caption = \"SCROLL TO ANNEAL\",\n  maxOffset = 42,\n  frostRadius = 10,\n  dispersion = 0.012,\n  className = \"\",\n  onProgress,\n}: {\n  /** image url; omit for a generated monochrome studio still */\n  src?: string;\n  /** alt text for the image */\n  alt?: string;\n  /** mono caption on the side rail */\n  caption?: string;\n  /** px of refraction offset at full frost */\n  maxOffset?: number;\n  /** px radius of the 5-tap frost scatter at full frost */\n  frostRadius?: number;\n  /** chromatic dispersion step between R/G/B offset scales */\n  dispersion?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** rendered (lerped) progress 0–1, called from the rAF loop */\n  onProgress?: (p: number) => void;\n}) {\n  const sectionRef = useRef<HTMLElement>(null);\n  const paneRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const imgRef = useRef<HTMLImageElement>(null);\n  const barRef = useRef<HTMLSpanElement>(null);\n  const pctRef = useRef<HTMLSpanElement>(null);\n  const onProgressRef = useRef(onProgress);\n\n  useEffect(() => {\n    onProgressRef.current = onProgress;\n  }, [onProgress]);\n\n  useEffect(() => {\n    const section = sectionRef.current;\n    const pane = paneRef.current;\n    const canvas = canvasRef.current;\n    const img = imgRef.current;\n    const bar = barRef.current;\n    const pct = pctRef.current;\n    if (!section || !pane || !canvas || !img || !bar || !pct) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    let disposed = false;\n    let raf = 0;\n    let target = 0;\n    let current = 0;\n\n    const readout = (p: number) => {\n      pct.textContent = `${String(Math.round(p * 100)).padStart(3, \"0\")}%`;\n      bar.style.transform = `scaleY(${p.toFixed(4)})`;\n      onProgressRef.current?.(p);\n    };\n\n    let art: HTMLCanvasElement | null = null;\n    const getArt = () => (art ??= buildDefaultArt());\n\n    // fallback when WebGL is unavailable: CSS blur on a plain <img>\n    let usingFallback = false;\n    const enterFallback = () => {\n      usingFallback = true;\n      canvas.classList.add(\"hidden\");\n      img.classList.remove(\"hidden\");\n      if (src) {\n        img.src = src;\n      } else {\n        const a = getArt();\n        if (a) img.src = a.toDataURL(\"image/png\");\n      }\n    };\n\n    let render: ((p: number) => void) | null = null;\n    let resize: (() => void) | null = null;\n\n    const gl = canvas.getContext(\"webgl\", {\n      alpha: false,\n      antialias: false,\n      depth: false,\n      stencil: false,\n    });\n\n    const setup = (): boolean => {\n      if (!gl) return false;\n      const program = createProgram(gl, VERT, FRAG);\n      if (!program) return false;\n      gl.useProgram(program);\n\n      const buf = gl.createBuffer();\n      gl.bindBuffer(gl.ARRAY_BUFFER, buf);\n      gl.bufferData(\n        gl.ARRAY_BUFFER,\n        new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n        gl.STATIC_DRAW\n      );\n      const aPos = gl.getAttribLocation(program, \"aPos\");\n      if (aPos < 0) return false;\n      gl.enableVertexAttribArray(aPos);\n      gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);\n\n      const u = (name: string) => gl.getUniformLocation(program, name);\n      const uRes = u(\"uRes\");\n      const uImgRes = u(\"uImgRes\");\n      const uDpr = u(\"uDpr\");\n      const uProgress = u(\"uProgress\");\n      gl.uniform1f(u(\"uMaxOffset\"), maxOffset);\n      gl.uniform1f(u(\"uFrostRadius\"), frostRadius);\n      gl.uniform1f(u(\"uDispersion\"), dispersion);\n      gl.uniform1f(u(\"uEdgeFrost\"), reduced ? 1 : 0);\n      gl.uniform1i(u(\"uTex\"), 0);\n      gl.uniform1i(u(\"uNoise\"), 1);\n\n      // ripple normal map on unit 1 — REPEAT so the 256 tile wraps seamlessly\n      gl.activeTexture(gl.TEXTURE1);\n      gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());\n      gl.texImage2D(\n        gl.TEXTURE_2D,\n        0,\n        gl.RGBA,\n        NOISE_SIZE,\n        NOISE_SIZE,\n        0,\n        gl.RGBA,\n        gl.UNSIGNED_BYTE,\n        buildNoiseTexture()\n      );\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT);\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\n      // image on unit 0 — NPOT-safe params, dark placeholder until pixels land\n      gl.activeTexture(gl.TEXTURE0);\n      const tex = gl.createTexture();\n      gl.bindTexture(gl.TEXTURE_2D, tex);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, 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.texImage2D(\n        gl.TEXTURE_2D,\n        0,\n        gl.RGBA,\n        1,\n        1,\n        0,\n        gl.RGBA,\n        gl.UNSIGNED_BYTE,\n        new Uint8Array([23, 23, 23, 255])\n      );\n      gl.uniform2f(uImgRes, 1, 1);\n      gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n\n      const uploadSource = (source: TexImageSource, w: number, h: number) => {\n        gl.activeTexture(gl.TEXTURE0);\n        gl.bindTexture(gl.TEXTURE_2D, tex);\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source);\n        gl.uniform2f(uImgRes, w, h);\n      };\n\n      if (src) {\n        const image = new Image();\n        image.crossOrigin = \"anonymous\";\n        image.onload = () => {\n          if (disposed) return;\n          uploadSource(image, image.naturalWidth, image.naturalHeight);\n          render?.(current);\n        };\n        image.onerror = () => {\n          if (disposed) return;\n          console.error(`FrostScrub: failed to load src \"${src}\", falling back to default art`);\n          const a = getArt();\n          if (a) uploadSource(a, a.width, a.height);\n          render?.(current);\n        };\n        image.src = src;\n      } else {\n        const a = getArt();\n        if (a) uploadSource(a, a.width, a.height);\n      }\n\n      resize = () => {\n        const w = Math.max(1, pane.clientWidth);\n        const h = Math.max(1, pane.clientHeight);\n        // Homepage/catalog cards render this exact DOM inside a fixed\n        // 1440×900 iframe that a *parent-document* CSS transform shrinks to a\n        // ~380px-wide thumbnail — invisible to this document's own layout, so\n        // `pane.clientWidth` still reads 1440 and a dpr-only buffer would\n        // shade the full 5-tap fragment shader at full desktop resolution for\n        // pixels nobody can resolve at thumbnail size. `[data-autoplay-root]`\n        // (see autoplay-driver.tsx) is stamped only on that exact route\n        // shape, so it doubles as the \"I'm a card\" signal here: cap the\n        // buffer to what a thumbnail can actually show. The real\n        // `/preview/<name>` reference page (what scripts/verify.ts shoots)\n        // never gets this attribute, so its resolution — and look — is\n        // unchanged.\n        const isCard = !!pane.closest(\"[data-autoplay-root]\");\n        const dpr = isCard\n          ? Math.min(0.6, window.devicePixelRatio || 1)\n          : 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        gl.viewport(0, 0, canvas.width, canvas.height);\n        gl.uniform2f(uRes, w, h);\n        gl.uniform1f(uDpr, dpr);\n      };\n      render = (p: number) => {\n        gl.uniform1f(uProgress, p);\n        gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n      };\n      resize();\n      return true;\n    };\n\n    if (!setup()) enterFallback();\n\n    const drawFrame = (p: number) => {\n      if (usingFallback) {\n        img.style.filter = p >= 0.999 ? \"none\" : `blur(${(12 * (1 - p)).toFixed(2)}px)`;\n      } else {\n        render?.(p);\n      }\n      readout(p);\n    };\n\n    // reduced motion: one static frame at p = 1 — clear image, the shader's\n    // uEdgeFrost vignette keeps a faint frost border. No loop, no listener.\n    if (reduced) {\n      if (usingFallback) img.style.filter = \"none\";\n      drawFrame(1);\n      const ro = new ResizeObserver(() => {\n        resize?.();\n        drawFrame(1);\n      });\n      ro.observe(pane);\n      return () => {\n        disposed = true;\n        ro.disconnect();\n        gl?.getExtension(\"WEBGL_lose_context\")?.loseContext();\n      };\n    }\n\n    const progressFromScroll = () => {\n      const rect = section.getBoundingClientRect();\n      const span = rect.height - window.innerHeight;\n      if (span <= 1) return 1;\n      return Math.min(1, Math.max(0, -rect.top / span));\n    };\n\n    const loop = () => {\n      current += (target - current) * LERP;\n      if (Math.abs(target - current) < SETTLE) {\n        current = target;\n        drawFrame(current);\n        raf = 0; // sleep — wakes on scroll only\n        return;\n      }\n      drawFrame(current);\n      raf = requestAnimationFrame(loop);\n    };\n    const wake = () => {\n      if (!raf) raf = requestAnimationFrame(loop);\n    };\n    const onScroll = () => {\n      target = progressFromScroll();\n      wake();\n    };\n\n    target = progressFromScroll();\n    current = target;\n    drawFrame(current);\n\n    const ro = new ResizeObserver(() => {\n      resize?.();\n      target = progressFromScroll();\n      drawFrame(current);\n      wake();\n    });\n    ro.observe(pane);\n    window.addEventListener(\"scroll\", onScroll, { passive: true });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      raf = 0;\n      window.removeEventListener(\"scroll\", onScroll);\n      ro.disconnect();\n      gl?.getExtension(\"WEBGL_lose_context\")?.loseContext();\n    };\n    // onProgress intentionally excluded — delivered via onProgressRef so an\n    // unmemoized inline callback can't tear down the GL setup every render.\n  }, [src, maxOffset, frostRadius, dispersion]);\n\n  return (\n    <section\n      ref={sectionRef}\n      className={`relative h-[300vh] bg-background motion-reduce:h-screen ${className}`}\n    >\n      <div\n        ref={paneRef}\n        role=\"img\"\n        aria-label={alt}\n        className=\"sticky top-0 h-screen w-full overflow-hidden bg-background\"\n      >\n        <canvas ref={canvasRef} aria-hidden className=\"absolute inset-0 h-full w-full\" />\n        {/* fallback surface — revealed only when WebGL is unavailable */}\n        <img\n          ref={imgRef}\n          alt=\"\"\n          aria-hidden\n          className=\"absolute inset-0 hidden h-full w-full object-cover\"\n        />\n        {/* side rail: caption, progress track, live percent — direct-DOM updates */}\n        <div\n          aria-hidden\n          className=\"pointer-events-none absolute inset-y-0 right-4 z-10 flex flex-col items-center justify-center gap-4 font-mono md:right-8\"\n        >\n          <span\n            className=\"text-[10px] uppercase tracking-[0.3em] text-ns-muted\"\n            style={{ writingMode: \"vertical-rl\" }}\n          >\n            {caption}\n          </span>\n          <span className=\"relative h-24 w-px overflow-hidden bg-foreground/20\">\n            <span\n              ref={barRef}\n              className=\"absolute inset-0 origin-top bg-foreground\"\n              style={{ transform: \"scaleY(0)\" }}\n            />\n          </span>\n          <span ref={pctRef} className=\"text-xs tabular-nums text-foreground\">\n            000%\n          </span>\n        </div>\n      </div>\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/scroll-defrost.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "scroll",
      "webgl",
      "shader",
      "image",
      "refraction",
      "glass",
      "hero"
    ],
    "instruction": "Build a pinned scroll-scrub frosted-glass reveal: a 300vh section holds a position:sticky h-screen pane; behind a rippled shower-glass shader sits a full-bleed image (prop src, else a monochrome studio still — sphere, wall glow, ground shadow, film grain — generated once on an offscreen canvas so the bare component is network-free). Raw WebGL 1, zero deps: one fullscreen triangle-strip quad and a single fragment shader. A 256x256 seamlessly-tiling RGBA noise texture is generated once on the CPU from two octaves of periodic sin-hash value noise (house noise2 on a wrapped lattice); central-difference gradients pack a normal map into RG with ripple height in B, sampled in the shader at gl_FragCoord in CSS px over REPEAT wrap. Refraction offset = normal.xy * pow(1-p, 1.6) * 42px; chromatic dispersion samples R/G/B at offset scales 1.0 / 1.012 / 1.024 so fringing lives on every ripple edge; frost scatter = 5-tap Poisson blur at radius (1-p)*10px; frost also lifts the pane toward white (mix 0.22 at full frost) with ripple-height shading; a white specular band (smoothstep width 0.08 along a diagonal UV axis) crosses the pane exactly once as p runs 0.92 to 1.0. Scroll model: a passive window scroll listener writes target progress (-rect.top / (sectionHeight - viewportHeight), clamped 0-1) to a closure variable; a direct-DOM rAF loop lerps rendered progress toward it at 0.12/frame, snaps and sleeps when |target - current| < 0.001, and wakes on scroll only — no React state anywhere on the hot path, fully reversible in both directions. Overlay side rail in font-mono: vertical-rl caption SCROLL TO ANNEAL, a 1px track filled via scaleY transform, and a zero-padded live percent, all updated by textContent/style writes inside the same loop (plus an optional onProgress callback). If WebGL or shader compile is unavailable, fall back to a plain object-cover img with CSS blur(12px * (1-p)) driven by the same lerp. Under prefers-reduced-motion render a single shader frame at p=1 — clear image with a faint frost vignette confined to the pane edges via an edge-frost uniform — and collapse the section to one viewport with no listeners."
  },
  "type": "registry:ui"
}