{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sand-edge",
  "title": "Sand edge",
  "description": "A carousel whose side pictures erode into fine sand in their own colours: the grains blow off with the strip's speed and settle to a weathered edge at rest. WebGL, with a DOM fallback.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/manniche/sand-edge/sand-edge.tsx",
      "content": "import { ChevronLeft, ChevronRight } from 'lucide-react'\nimport { useCallback, useEffect, useId, useLayoutEffect, useRef, useState, type HTMLAttributes, type KeyboardEvent } from 'react'\nimport { useCarouselEngine } from '@/registry/manniche/hooks/use-carousel-engine'\nimport { glAtlas, glProgram, useGlStage, type GlAtlas, type GlImage, type GlScene } from '@/registry/manniche/hooks/use-gl-stage'\nimport { cn } from '@/lib/utils'\n\n/** A picture for the carousel. The title, if given, is shown under the pictures. */\nexport type SandEdgeImage = GlImage & { title?: string }\n\nexport type SandEdgeProps = Omit<HTMLAttributes<HTMLDivElement>, 'onChange'> & {\n  /** The pictures. A picture from another origin must send CORS headers (Access-Control-Allow-Origin) to be drawn in WebGL. */\n  images: SandEdgeImage[]\n  /** Read by screen readers, e.g. “Landscapes”. */\n  label: string\n  /** Controlled: the picture in the middle. */\n  index?: number\n  /** The picture in the middle at first, when uncontrolled. */\n  defaultIndex?: number\n  /** Called when the carousel heads for a new picture: on release, a key, a button or a click. */\n  onIndexChange?: (index: number) => void\n  /** Wrap round from the last picture to the first. */\n  loop?: boolean\n  /** Picture width over height. */\n  aspect?: number\n  /** Size of one grain of sand in CSS pixels, the same on every screen. */\n  grain?: number\n  /** How far the wind carries the sand when the strip moves; 0 keeps the grains near their edge. */\n  wind?: number\n  /** Loose grains at the edges shimmer slightly while on screen. Off under reduced motion. */\n  shimmer?: boolean\n  labels?: Partial<Record<'previous' | 'next' | 'picture' | 'of', string>>\n}\n\nconst EN = { previous: 'Previous picture', next: 'Next picture', picture: 'Picture', of: 'of' }\nconst TYPING = 'input, textarea, select, [contenteditable=\"\"], [contenteditable=\"true\"]'\n\n// How the erosion reads, in parts of the stage's half width and of the card width.\nconst FADE = 0.32 // how far past its threshold a grain is still seen, at rest\nconst GUST = 1.1 // how much longer the trail of sand gets in a full wind\nconst REACH = 0.36 // card widths a grain drifts per unit of erosion, at rest\nconst RADIUS = 14 // css px, the corners of a card\nconst MAX_GRAINS = 90_000 // per card; past this the grains get coarser\nconst PIXELS = 4_200_000 // device pixels the canvas may use; the scene draws only cards, so this can be above the stage's own cap\n\ntype Layout = { width: number; cw: number; ch: number; step: number; pad: number; height: number; z0: number }\n\n// Where everything sits for a stage `width` CSS pixels wide.\nfunction measure(width: number, aspect: number): Layout {\n  let cw = Math.min(460, Math.max(width * 0.3, Math.min(width * 0.56, 320)))\n  cw = Math.round(Math.min(cw, 560 * aspect))\n  const ch = Math.round(cw / aspect)\n  const step = cw + Math.round(Math.max(12, cw * 0.05))\n  const pad = Math.round(Math.max(24, ch * 0.09))\n  // Erosion starts a little outside the middle card's edge, and never nearer the middle than 40 % of the half width.\n  const z0 = Math.max(0.4, cw / width + 0.05)\n  return { width, cw, ch, step, pad, height: ch + pad * 2, z0 }\n}\n\n// Shared by both programs: how eroded the stage is at a device x, in units where 0 is the start of the zone and\n// 1 + FADE is the stage's edge.\nconst ERODE = `uniform vec4 uZ;\nfloat erode(float x){return (abs(x-uZ.x)*uZ.y-uZ.z)*uZ.w;}`\n\n// The solid body of a card: the picture at full resolution, with every grain cell that has come loose cut out.\nconst CARD_VS = `attribute vec2 aQ;\nuniform vec2 uRes;uniform vec2 uOrg;uniform vec2 uSize;\nvarying vec2 vL;\nvoid main(){\n  vL=aQ*uSize;\n  vec2 p=uOrg+vL;\n  gl_Position=vec4(p.x/uRes.x*2.-1.,1.-p.y/uRes.y*2.,0.,1.);\n}`\n\nconst HIGHP = `#ifdef GL_FRAGMENT_PRECISION_HIGH\nprecision highp float;\n#else\nprecision mediump float;\n#endif\n`\n\nconst CARD_FS = `${HIGHP}\nuniform sampler2D uTex;uniform sampler2D uThr;\nuniform vec2 uOrg;uniform vec2 uSize;uniform vec2 uGrid;uniform float uPitch;uniform vec4 uChan;uniform vec4 uRect;\nuniform float uRadius;uniform float uLine;uniform vec4 uBorder;\nvarying vec2 vL;\n${ERODE}\nvoid main(){\n  vec2 cell=floor(vL/uPitch);\n  float t=dot(texture2D(uThr,(cell+.5)/uGrid),uChan);\n  // A hair of overlap with the grains, so the seam between the two never shows a gap.\n  if(erode(uOrg.x+(cell.x+.5)*uPitch)-t>.004)discard;\n  vec2 q=abs(vL-uSize*.5)-(uSize*.5-uRadius);\n  float sd=length(max(q,0.))+min(max(q.x,q.y),0.)-uRadius;\n  float a=clamp(.5-sd,0.,1.);\n  vec4 c=texture2D(uTex,mix(uRect.xy,uRect.zw,vL/uSize));\n  // The family's hairline ring, in the theme's border colour.\n  float ring=clamp(uLine-abs(sd+uLine*.5)+.5,0.,1.)*uBorder.a;\n  c.rgb=mix(c.rgb,uBorder.rgb*c.a,ring);\n  gl_FragColor=c*a;\n}`\n\n// One point per grain. Past its threshold a grain leaves home: out towards the nearer edge of the stage, a little up\n// or down, farther the more eroded it is and the stronger the wind, and it fades as it goes.\nconst SAND_VS = `attribute vec2 aCell;attribute vec4 aT;attribute vec4 aR;\nuniform vec2 uRes;uniform vec2 uOrg;uniform vec2 uSize;uniform float uPitch;uniform vec4 uChan;uniform vec4 uRect;\nuniform float uRadius;\nuniform vec4 uAir;\nuniform vec3 uFlow;\nuniform float uTime;\nvarying vec2 vUv;varying float vA;varying float vShade;\n${ERODE}\nvoid main(){\n  vec2 local=(aCell+.5)*uPitch;\n  vec2 home=uOrg+local;\n  float d=erode(home.x)-dot(aT,uChan);\n  float life=d/uAir.x;\n  vec2 q=abs(local-uSize*.5)-(uSize*.5-uRadius);\n  float sd=length(max(q,0.))+min(max(q.x,q.y),0.)-uRadius;\n  if(d<-.004||life>=1.||sd>0.){gl_Position=vec4(2.,2.,2.,1.);gl_PointSize=0.;return;}\n  d=max(d,0.);life=max(life,0.);\n  float side=home.x<uZ.x?-1.:1.;\n  float loose=smoothstep(0.,.03,d);\n  float w=uAir.w;\n  // The downwind side carries farther than the side the strip comes in from.\n  float gust=1.+.5*w*max(0.,side*uFlow.x);\n  float spread=.5+aR.x;\n  vec2 off;\n  off.x=side*(uAir.y*d+uAir.z*w*d*d*gust)*spread;\n  off.y=((aR.y-.5)*2.-uFlow.y*(.35+w))*pow(d,1.3)*uAir.y*.3;\n  off+=(aR.zw-.5)*uPitch*1.3*loose;\n  off+=vec2(sin(uTime*(1.3+aR.z)+aR.w*6.283),cos(uTime*(1.1+aR.w)+aR.z*6.283))*uPitch*.55*loose*uFlow.z;\n  vec2 p=home+off;\n  float edge=smoothstep(0.,uPitch*8.,min(p.x,uRes.x-p.x))*smoothstep(0.,uPitch*6.,min(p.y,uRes.y-p.y));\n  vA=(1.-smoothstep(.25,1.,life))*edge;\n  vShade=mix(1.,.9+.2*aR.w,loose);\n  vUv=mix(uRect.xy,uRect.zw,local/uSize);\n  gl_PointSize=max(1.,floor(uPitch*mix(1.,.55+.45*aR.x,life)+.5));\n  gl_Position=vec4(p.x/uRes.x*2.-1.,1.-p.y/uRes.y*2.,0.,1.);\n}`\n\nconst SAND_FS = `precision mediump float;\nuniform sampler2D uTex;uniform float uBias;\nvarying vec2 vUv;varying float vA;varying float vShade;\nvoid main(){\n  // The point's uv is the same in all its pixels, so the bias alone picks the mip level: one grain, one average colour.\n  vec4 c=texture2D(uTex,vUv,uBias);\n  gl_FragColor=vec4(c.rgb*vShade,c.a)*vA;\n}`\n\n// A small seeded hash and value noise, for the grains' thresholds.\nfunction hash(x: number, y: number, s: number) {\n  let h = Math.imul(x, 374761393) + Math.imul(y, 668265263) + Math.imul(s, 2147483647)\n  h = Math.imul(h ^ (h >>> 13), 1274126177)\n  return ((h ^ (h >>> 16)) >>> 0) / 4294967296\n}\nfunction noise(x: number, y: number, s: number) {\n  const ix = Math.floor(x)\n  const iy = Math.floor(y)\n  const fx = x - ix\n  const fy = y - iy\n  const u = fx * fx * (3 - 2 * fx)\n  const v = fy * fy * (3 - 2 * fy)\n  const a = hash(ix, iy, s)\n  const b = hash(ix + 1, iy, s)\n  const c = hash(ix, iy + 1, s)\n  const d = hash(ix + 1, iy + 1, s)\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v\n}\n\n/**\n * Each grain's threshold, four variants per grain (one per channel), so neighbouring cards do not erode alike.\n * Broad noise, stretched sideways, sets bays and headlands; strata, thin bands that run across the card, let some\n * layers recede further like weathered sandstone; fine noise and a little white noise fray the front. The top and\n * bottom give way a little first, so the front rounds off at the corners. The values sit around 0.5 and change\n * slowly across the card, so the front is one ragged line, not holes all over.\n */\nfunction thresholds(cols: number, rows: number) {\n  const n = cols * rows\n  const out = new Uint8Array(n * 4)\n  const k = rows / cols\n  for (let s = 0; s < 4; s++) {\n    for (let j = 0; j < rows; j++) {\n      const y = (j + 0.5) / rows\n      const rim = 1 - Math.min(1, Math.min(y, 1 - y) / 0.1)\n      for (let i = 0; i < cols; i++) {\n        const x = (i + 0.5) / cols\n        const big = noise(x * 1.6, y * k * 3.2, s * 7 + 1) * 0.7 + noise(x * 3.5, y * k * 7, s * 7 + 2) * 0.3\n        const strata = noise(x * 2.2, y * k * 26, s * 7 + 3)\n        const fine = noise(x * 34, y * k * 34, s * 7 + 4)\n        const value = 0.5 + (big - 0.5) * 0.5 + (strata - 0.5) * 0.3 + (fine - 0.5) * 0.09 + (hash(i, j, s + 11) - 0.5) * 0.035 - rim * rim * 0.2\n        out[(j * cols + i) * 4 + s] = Math.round(Math.min(1, Math.max(0, value)) * 255)\n      }\n    }\n  }\n  return out\n}\n\ntype Motion = { p: number; v: number; wind: number; dir: number; at: number; raf: number }\n\n/**\n * A row of pictures whose edges blow away like sand. The picture in the middle is solid; towards the stage's left\n * and right edges each card breaks up into fine square grains in the picture's own colours, and they drift off on a\n * wind and fade. The faster the strip moves, the stronger the wind and the farther the sand flies; a card coming in\n * gathers from grains, one going out comes apart. Each card is drawn in WebGL as its full-resolution picture with the\n * loose grain cells cut out, plus one point per loose grain, with the erosion front set per grain from noise so it\n * reads as weathered stone, not a wipe. Drag or swipe with momentum, use a trackpad, the arrow keys, the buttons, or\n * click a side picture. Under reduced motion each move jumps and the eroded edges stand still. Without WebGL, or if\n * the GPU drops the context, the same carousel is drawn with plain images that fade towards the edges.\n */\nexport function SandEdge({\n  images,\n  label,\n  index,\n  defaultIndex,\n  onIndexChange,\n  loop = false,\n  aspect = 4 / 5,\n  grain = 2,\n  wind = 1,\n  shimmer = false,\n  labels = {},\n  className,\n  onKeyDown,\n  ...rest\n}: SandEdgeProps) {\n  const t = { ...EN, ...labels }\n  const n = images.length\n  const wraps = loop && n > 2\n  const id = useId()\n\n  const stage = useRef<HTMLDivElement | null>(null)\n  const cards = useRef<(HTMLDivElement | null)[]>([])\n  const tick = useRef<HTMLSpanElement | null>(null)\n  const [width, setWidth] = useState(0)\n  const L = width ? measure(width, aspect) : null\n  const layout = useRef<Layout | null>(null)\n  const motion = useRef<Motion>({ p: 0, v: 0, wind: 0, dir: 0, at: 0, raf: 0 })\n  // The latest effect settings, for the scene, which outlives a render.\n  const tune = useRef({ grain, wind, shimmer, n, wraps })\n  useLayoutEffect(() => {\n    tune.current = { grain, wind, shimmer, n, wraps }\n  })\n\n  const srcs = images.map((im) => im.src).join('\\n')\n  const gl = useGlStage(\n    stage,\n    ({ gl, canvas, ready, redraw, fail, signal }) => {\n      const card = glProgram(gl, CARD_VS, CARD_FS)\n      const sand = glProgram(gl, SAND_VS, SAND_FS)\n      if (!card || !sand) return null\n      const list = srcs.split('\\n').map((src) => ({ src, alt: '' }))\n      const loc = (p: WebGLProgram, names: string[]) => Object.fromEntries(names.map((k) => [k, gl.getUniformLocation(p, k)]))\n      const common = ['uRes', 'uOrg', 'uSize', 'uPitch', 'uChan', 'uRect', 'uRadius', 'uZ', 'uTex']\n      const uc = loc(card, [...common, 'uThr', 'uGrid', 'uLine', 'uBorder'])\n      const us = loc(sand, [...common, 'uAir', 'uFlow', 'uTime', 'uBias'])\n      const aQ = gl.getAttribLocation(card, 'aQ')\n      const aCell = gl.getAttribLocation(sand, 'aCell')\n      const aT = gl.getAttribLocation(sand, 'aT')\n      const aR = gl.getAttribLocation(sand, 'aR')\n\n      const quad = gl.createBuffer()\n      gl.bindBuffer(gl.ARRAY_BUFFER, quad)\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]), gl.STATIC_DRAW)\n      const grains = gl.createBuffer()\n      const thr = gl.createTexture()\n      let grid = '' // cols × rows the grain buffer was built for\n      let count = 0\n      let atlas: GlAtlas | null = null\n      let border: [number, number, number] = [0.5, 0.5, 0.5]\n      let dark = false\n      let W = 1\n      let H = 1\n\n      // The atlas cells match the cards' size on screen, so the middle picture is sharp.\n      const host = canvas.parentElement\n      const cssH = host ? measure(host.clientWidth || 600, aspect).ch : 400\n      const cell = cssH * Math.min(2, window.devicePixelRatio || 1) > 560 ? 1024 : 512\n      glAtlas(gl, list, { aspect, cell, limit: 4096, signal }).then((a) => {\n        if (signal.aborted) return\n        if (!a) return fail()\n        atlas = a\n        ready()\n        redraw()\n      })\n\n      // The grain grid for a card of cols × rows grains: one vertex each, with its four thresholds and four randoms.\n      const build = (cols: number, rows: number) => {\n        const key = `${cols}x${rows}`\n        if (key === grid) return\n        grid = key\n        count = cols * rows\n        const t4 = thresholds(cols, rows)\n        const data = new ArrayBuffer(count * 16)\n        const f = new Float32Array(data)\n        const b = new Uint8Array(data)\n        for (let j = 0, g = 0; j < rows; j++)\n          for (let i = 0; i < cols; i++, g++) {\n            f[g * 4] = i\n            f[g * 4 + 1] = j\n            for (let c = 0; c < 4; c++) {\n              b[g * 16 + 8 + c] = t4[g * 4 + c]\n              b[g * 16 + 12 + c] = Math.floor(hash(i, j, 101 + c) * 256)\n            }\n          }\n        gl.bindBuffer(gl.ARRAY_BUFFER, grains)\n        gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW)\n        gl.activeTexture(gl.TEXTURE1)\n        gl.bindTexture(gl.TEXTURE_2D, thr)\n        gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false)\n        gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1)\n        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, cols, rows, 0, gl.RGBA, gl.UNSIGNED_BYTE, t4)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)\n        gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)\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      }\n\n      const scene: GlScene = {\n        resize: (w, h) => {\n          W = w\n          H = h\n        },\n        theme: ([bd, bg]) => {\n          border = bd\n          dark = bg[0] * 0.3 + bg[1] * 0.6 + bg[2] * 0.1 < 0.5\n        },\n        draw: (time) => {\n          gl.clearColor(0, 0, 0, 0)\n          gl.clear(gl.COLOR_BUFFER_BIT)\n          const lay = layout.current\n          if (!atlas || !lay) return\n          const o = tune.current\n          const m = motion.current\n          const dpr = W / Math.max(1, lay.width)\n          // Grain pitch in device pixels, whole, so grains tile with no gaps; coarser if a card would need too many.\n          let pitch = Math.max(1, Math.round(o.grain * dpr))\n          while ((lay.cw * dpr * lay.ch * dpr) / (pitch * pitch) > MAX_GRAINS) pitch++\n          const cols = Math.max(2, Math.round((lay.cw * dpr) / pitch))\n          const rows = Math.max(2, Math.round((lay.ch * dpr) / pitch))\n          build(cols, rows)\n          const sw = cols * pitch\n          const sh = rows * pitch\n          const cx = W / 2\n          const half = W / 2\n          const w = m.wind\n          // In a wind the erosion reaches a little further in, and the trail of sand gets longer.\n          const z0 = lay.z0 - 0.05 * w\n          const fade = FADE * (1 + GUST * w * Math.min(1, o.wind))\n          const z1 = z0 + (1 - z0) / (1 + FADE)\n          const zone = [cx, 1 / half, z0, 1 / (z1 - z0)] as const\n          const erode = (x: number) => (Math.abs(x - cx) / half - z0) / (z1 - z0)\n          const top = Math.round(lay.pad * dpr + (lay.ch * dpr - sh) / 2)\n\n          // The cards near the visible range, farthest first. `inner` and `outer` are the erosion at a card's\n          // nearest and farthest point from the middle.\n          const shown: { i: number; x: number; inner: number; outer: number }[] = []\n          for (let i = 0; i < o.n; i++) {\n            let d = i - m.p\n            if (o.wraps) d = ((((d + o.n / 2) % o.n) + o.n) % o.n) - o.n / 2\n            const x = cx + d * lay.step * dpr - sw / 2\n            const near = Math.max(0, x - cx, cx - x - sw)\n            const far = Math.max(Math.abs(x - cx), Math.abs(x + sw - cx))\n            const inner = erode(cx + near)\n            if (near > half || inner > 1 + fade) continue\n            shown.push({ i, x, inner, outer: erode(cx + far) })\n          }\n          shown.sort((a, b) => b.inner - a.inner)\n\n          gl.enable(gl.BLEND)\n          gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)\n          gl.activeTexture(gl.TEXTURE0)\n          gl.bindTexture(gl.TEXTURE_2D, atlas.texture)\n          gl.activeTexture(gl.TEXTURE1)\n          gl.bindTexture(gl.TEXTURE_2D, thr)\n\n          const chan = (i: number) => [i % 4 === 0 ? 1 : 0, i % 4 === 1 ? 1 : 0, i % 4 === 2 ? 1 : 0, i % 4 === 3 ? 1 : 0] as const\n          const shared = (u: Record<string, WebGLUniformLocation | null>, c: (typeof shown)[number]) => {\n            gl.uniform2f(u.uRes, W, H)\n            gl.uniform2f(u.uOrg, c.x, top)\n            gl.uniform2f(u.uSize, sw, sh)\n            gl.uniform1f(u.uPitch, pitch)\n            gl.uniform4f(u.uChan, ...chan(c.i))\n            gl.uniform4f(u.uRect, ...atlas!.rects[c.i % atlas!.rects.length])\n            gl.uniform1f(u.uRadius, RADIUS * dpr)\n            gl.uniform4f(u.uZ, ...zone)\n            gl.uniform1i(u.uTex, 0)\n          }\n\n          gl.useProgram(card)\n          gl.bindBuffer(gl.ARRAY_BUFFER, quad)\n          gl.enableVertexAttribArray(aQ)\n          gl.vertexAttribPointer(aQ, 2, gl.FLOAT, false, 0, 0)\n          gl.uniform1i(uc.uThr, 1)\n          gl.uniform2f(uc.uGrid, cols, rows)\n          gl.uniform1f(uc.uLine, Math.max(1, dpr))\n          gl.uniform4f(uc.uBorder, ...border, dark ? 0.55 : 0.8)\n          for (const c of shown) {\n            if (c.inner > 1.004) continue\n            shared(uc, c)\n            gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)\n          }\n          gl.disableVertexAttribArray(aQ)\n\n          gl.useProgram(sand)\n          gl.bindBuffer(gl.ARRAY_BUFFER, grains)\n          gl.enableVertexAttribArray(aCell)\n          gl.enableVertexAttribArray(aT)\n          gl.enableVertexAttribArray(aR)\n          gl.vertexAttribPointer(aCell, 2, gl.FLOAT, false, 16, 0)\n          gl.vertexAttribPointer(aT, 4, gl.UNSIGNED_BYTE, true, 16, 8)\n          gl.vertexAttribPointer(aR, 4, gl.UNSIGNED_BYTE, true, 16, 12)\n          const reach = REACH * sw\n          gl.uniform4f(us.uAir, fade, reach, reach * 2 * o.wind, w)\n          gl.uniform3f(us.uFlow, m.dir, 0.35, o.shimmer ? 1 : 0)\n          gl.uniform1f(us.uTime, time)\n          // Texels per grain, as a mip bias, so each grain is its patch's average colour.\n          gl.uniform1f(us.uBias, Math.log2(Math.max(1, (pitch * atlas.cell[1]) / sh)))\n          for (const c of shown) {\n            // A card wholly inside the solid middle has no loose grains.\n            if (c.outer < -0.004) continue\n            shared(us, c)\n            gl.drawArrays(gl.POINTS, 0, count)\n          }\n          gl.disableVertexAttribArray(aCell)\n          gl.disableVertexAttribArray(aT)\n          gl.disableVertexAttribArray(aR)\n        },\n        dispose: () => {\n          gl.deleteBuffer(quad)\n          gl.deleteBuffer(grains)\n          gl.deleteTexture(thr)\n          if (atlas) gl.deleteTexture(atlas.texture)\n          gl.deleteProgram(card)\n          gl.deleteProgram(sand)\n        },\n      }\n      return scene\n    },\n    `${srcs}|${aspect}`,\n    {\n      maxDpr: 2,\n      maxPixels: PIXELS,\n      animate: shimmer,\n      colors: ['var(--border)', 'var(--background)'],\n      className: 'pointer-events-none absolute inset-0 size-full opacity-0 transition-opacity duration-300 ease-out-quint data-ready:opacity-100 motion-reduce:transition-none',\n    },\n  )\n  const glReady = gl.status === 'ready'\n  const redraw = gl.redraw\n\n  // The DOM layer: the visual until WebGL is ready or if it fails, and always the layer people click and read.\n  const paint = useCallback(\n    (p: number) => {\n      const lay = layout.current\n      if (!lay) return\n      for (let i = 0; i < n; i++) {\n        const el = cards.current[i]\n        if (!el) continue\n        let d = i - p\n        if (wraps) d = ((((d + n / 2) % n) + n) % n) - n / 2\n        const x = d * lay.step\n        if (Math.abs(x) - lay.cw / 2 > lay.width / 2) {\n          el.style.visibility = 'hidden'\n          continue\n        }\n        el.style.visibility = 'visible'\n        el.style.transform = `translate3d(${x.toFixed(2)}px, 0, 0)`\n      }\n      const at = wraps ? (((p % n) + n) % n) / n : (Math.min(Math.max(p, 0), n - 1) / Math.max(n - 1, 1)) * (1 - 1 / n)\n      if (tick.current) tick.current.style.transform = `translateX(${(at * 100 * n).toFixed(3)}%)`\n    },\n    [n, wraps],\n  )\n\n  // The wind follows the strip's speed at once and dies down over about a third of a second once it stops.\n  const breathe = useCallback(() => {\n    const m = motion.current\n    cancelAnimationFrame(m.raf)\n    m.raf = 0\n    const step = (now: number) => {\n      const dt = Math.min(0.05, (now - m.at) / 1000)\n      m.at = now\n      m.wind *= Math.exp(-dt / 0.32)\n      if (m.wind < 0.01) m.wind = 0\n      redraw()\n      m.raf = m.wind ? requestAnimationFrame(step) : 0\n    }\n    m.at = performance.now()\n    m.raf = requestAnimationFrame(step)\n  }, [redraw])\n\n  const onFrame = useCallback(\n    (p: number, v: number) => {\n      const m = motion.current\n      const now = performance.now()\n      const dt = Math.min(0.05, (now - (m.at || now)) / 1000)\n      m.at = now\n      m.p = p\n      m.v = v\n      if (Math.abs(v) > 0.01) {\n        cancelAnimationFrame(m.raf)\n        m.raf = 0\n        m.dir = v > 0 ? -1 : 1\n        const target = 1 - Math.exp(-Math.abs(v) / 5)\n        m.wind = target > m.wind ? m.wind + (target - m.wind) * (1 - Math.exp(-dt / 0.05)) : m.wind + (target - m.wind) * (1 - Math.exp(-dt / 0.32))\n      } else if (m.wind > 0 && !m.raf) breathe()\n      paint(p)\n      redraw()\n    },\n    [paint, redraw, breathe],\n  )\n  useEffect(() => () => cancelAnimationFrame(motion.current.raf), [])\n\n  const engine = useCarouselEngine({ count: n, loop: wraps, index, defaultIndex, onIndexChange, step: L?.step ?? 300, onFrame })\n  const current = engine.index\n\n  // The stage only exists while there are pictures, so watch it again when the first ones arrive.\n  const empty = !n\n  useLayoutEffect(() => {\n    const el = stage.current\n    if (!el) return\n    const ro = new ResizeObserver(([e]) => setWidth(Math.round(e.contentRect.width)))\n    ro.observe(el)\n    return () => ro.disconnect()\n  }, [empty])\n\n  // A new size or set of pictures: draw the current frame again.\n  const { draw } = engine\n  useLayoutEffect(() => {\n    layout.current = width ? measure(width, aspect) : null\n    draw()\n  }, [draw, width, aspect, grain, wind, glReady, n, wraps])\n\n  // If focus sat in a picture that just left the middle, keep it in the carousel.\n  useEffect(() => {\n    const el = stage.current\n    const active = document.activeElement\n    if (!el || !active || active === el || !el.contains(active)) return\n    if (!cards.current[current]?.contains(active)) el.focus({ preventScroll: true })\n  }, [current])\n\n  const keys = (e: KeyboardEvent<HTMLDivElement>) => {\n    onKeyDown?.(e)\n    if (e.defaultPrevented || (e.target as HTMLElement).closest(TYPING)) return\n    if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') engine.move(e.key === 'ArrowRight' ? 1 : -1)\n    else if (e.key === 'Home') engine.go(0)\n    else if (e.key === 'End') engine.go(n - 1)\n    else return\n    e.preventDefault()\n  }\n\n  if (!n) return null\n\n  const atStart = !wraps && current === 0\n  const atEnd = !wraps && current === n - 1\n  const title = images[current]?.title\n  const button =\n    'grid size-11 shrink-0 cursor-pointer place-items-center rounded-full bg-muted text-foreground [-webkit-tap-highlight-color:transparent] hover:bg-[color-mix(in_oklab,var(--foreground)_8%,var(--muted))] ' +\n    'transition-[opacity,transform] duration-150 ease-out-quint active:scale-[0.96] aria-disabled:cursor-default aria-disabled:opacity-35 aria-disabled:active:scale-100 motion-reduce:transition-none ' +\n    'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'\n  // The fallback fades its cards out towards the stage's edges, where the sand would be.\n  const z = L ? Math.round((1 - L.z0) * 50) : 25\n  const mask = `linear-gradient(to right, transparent, #000 ${z}%, #000 ${100 - z}%, transparent)`\n\n  return (\n    <div onKeyDown={keys} className={cn('w-full', className)} {...rest}>\n      <div\n        {...engine.bind}\n        ref={(el) => {\n          stage.current = el\n          engine.bind.ref(el)\n        }}\n        id={id}\n        role=\"group\"\n        aria-roledescription=\"carousel\"\n        aria-label={label}\n        tabIndex={0}\n        style={{ height: L?.height ?? 480 }}\n        className={cn(\n          'relative w-full touch-pan-y overflow-hidden rounded-[calc(var(--radius)*2+2px)] select-none [-webkit-tap-highlight-color:transparent]',\n          'cursor-grab data-dragging:cursor-grabbing',\n          'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',\n        )}\n      >\n        <div\n          style={{ maskImage: mask, WebkitMaskImage: mask }}\n          className={cn('absolute inset-0 transition-opacity duration-300 ease-out-quint motion-reduce:transition-none', glReady && 'opacity-0')}\n        >\n          {L &&\n            images.map((im, i) => {\n              const on = i === current\n              return (\n                <div\n                  key={i}\n                  ref={(el) => {\n                    cards.current[i] = el\n                  }}\n                  role=\"group\"\n                  aria-roledescription=\"slide\"\n                  aria-label={`${t.picture} ${i + 1} ${t.of} ${n}`}\n                  aria-hidden={!on}\n                  onClick={on ? undefined : () => engine.go(i)}\n                  style={{ width: L.cw, height: L.ch, top: L.pad, marginLeft: -L.cw / 2 }}\n                  className={cn(\n                    'absolute left-1/2 overflow-hidden rounded-[calc(var(--radius)+2px)] bg-muted will-change-transform',\n                    !on && 'invisible cursor-pointer',\n                  )}\n                >\n                  <img\n                    inert={!on}\n                    src={im.src}\n                    alt={im.alt}\n                    draggable={false}\n                    className=\"size-full object-cover [-webkit-user-drag:none]\"\n                  />\n                  <span aria-hidden className=\"pointer-events-none absolute inset-0 rounded-[inherit] shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent)]\" />\n                </div>\n              )\n            })}\n        </div>\n      </div>\n\n      {n > 1 ? (\n        <div className=\"mx-auto mt-3 flex max-w-sm items-center gap-3\">\n          {/* aria-disabled, not disabled: a button that has focus keeps it when it reaches the end. */}\n          <button type=\"button\" aria-label={t.previous} aria-controls={id} aria-disabled={atStart} onClick={() => atStart || engine.move(-1)} className={button}>\n            <ChevronLeft className=\"size-[18px]\" strokeWidth={2} aria-hidden />\n          </button>\n          <div className=\"flex min-w-0 flex-1 flex-col gap-2\">\n            <p className=\"flex items-baseline justify-between gap-3 text-[11px] leading-none\">\n              <span aria-hidden className=\"truncate text-[13px] font-medium tracking-[-0.005em] text-foreground\">\n                {title ?? String(current + 1).padStart(2, '0')}\n              </span>\n              <span aria-hidden className=\"font-mono font-medium tracking-[0.04em] text-muted-foreground tabular-nums\">\n                {String(current + 1).padStart(2, '0')}/{String(n).padStart(2, '0')}\n              </span>\n            </p>\n            {/* A hairline rule with a primary tick, one picture's share of it long, that follows the position. */}\n            <div aria-hidden className=\"relative h-1 overflow-hidden rounded-full bg-muted\">\n              <span ref={tick} style={{ width: `${100 / n}%` }} className=\"absolute inset-y-0 left-0 block rounded-full bg-primary\" />\n            </div>\n          </div>\n          <button type=\"button\" aria-label={t.next} aria-controls={id} aria-disabled={atEnd} onClick={() => atEnd || engine.move(1)} className={button}>\n            <ChevronRight className=\"size-[18px]\" strokeWidth={2} aria-hidden />\n          </button>\n        </div>\n      ) : null}\n      <p aria-live=\"polite\" className=\"sr-only\">\n        {`${t.picture} ${current + 1} ${t.of} ${n}`}\n      </p>\n    </div>\n  )\n}\n\n",
      "type": "registry:component"
    },
    {
      "path": "registry/manniche/hooks/use-carousel-engine.ts",
      "content": "import { useEffect, useLayoutEffect, useState, type MouseEvent, type PointerEvent } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\nexport type CarouselEngineOptions = {\n  /** How many slides there are. */\n  count: number\n  /** Wrap around from the last slide to the first. */\n  loop?: boolean\n  /** Controlled: the current slide. The carousel shows it, and goes back to it if `onIndexChange` does not update it. */\n  index?: number\n  /** The slide shown first, when uncontrolled. */\n  defaultIndex?: number\n  /** Called when the carousel heads for a new slide: on release, a key or a button, not when the glide ends. */\n  onIndexChange?: (index: number) => void\n  /** The drag direction. `x` also takes sideways wheel and trackpad swipes. */\n  axis?: 'x' | 'y'\n  /** Pixels of drag that move the carousel one slide. Negative turns the drag round, e.g. pull down for the next slide. */\n  step: number\n  /** Come to rest on a whole slide. Off, a fling glides to a stop anywhere. */\n  snap?: boolean\n  /** Draws a frame: the position in slides (fractional while moving) and the speed in slides per second. */\n  onFrame: (position: number, velocity: number) => void\n}\n\nexport type CarouselEngine = {\n  /** The slide the carousel is on or heading to. */\n  index: number\n  /** Go to a slide. In a loop it takes the short way round. */\n  go: (index: number) => void\n  /** Move by a number of slides from where the carousel is heading. */\n  move: (by: number) => void\n  /** Draw the current frame again, e.g. after a resize. */\n  draw: () => void\n  /** Spread on the element that takes the drag. */\n  bind: {\n    ref: (el: HTMLElement | null) => void\n    onPointerDown: (e: PointerEvent<HTMLElement>) => void\n    onPointerMove: (e: PointerEvent<HTMLElement>) => void\n    onPointerUp: (e: PointerEvent<HTMLElement>) => void\n    onPointerCancel: (e: PointerEvent<HTMLElement>) => void\n    onClickCapture: (e: MouseEvent<HTMLElement>) => void\n  }\n}\n\ntype Settings = Required<Pick<CarouselEngineOptions, 'loop' | 'axis' | 'snap' | 'step' | 'onFrame'>> &\n  Pick<CarouselEngineOptions, 'onIndexChange'> & { n: number; reduce: boolean }\n\nconst SLOP = 6 // px before a press becomes a drag\nconst THROW = 0.32 // s: how far a fling carries, as speed × this\nconst SAMPLE = 90 // ms of pointer history used for the release speed\nconst mod = (v: number, n: number) => ((v % n) + n) % n\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v))\n// Pixels per slide, kept away from zero but with its sign.\nconst per = (step: number) => (step < 0 ? -1 : 1) * Math.max(Math.abs(step), 1)\n// Past an end the carousel gives less and less, and never more than one slide.\nconst rubber = (over: number) => 1 - 1 / (over * 0.55 + 1)\n\n/**\n * The motion behind the carousels: one position, in slides, that drag, fling, wheel, keys and buttons all move.\n * It runs a frame loop only while something moves and hands each frame to `onFrame`, which writes transforms\n * straight to the DOM, so React renders only when the slide changes. Glides use ease-out-quint: 300 ms for a\n * step, and after a fling a duration that starts at the finger's speed. Under reduced motion every move jumps.\n */\nexport function useCarouselEngine(options: CarouselEngineOptions): CarouselEngine {\n  const { count, loop = false, index, defaultIndex = 0, axis = 'x', snap = true, step, onFrame, onIndexChange } = options\n  const n = Math.max(count, 1)\n  const reduce = useReducedMotion()\n  const [current, setCurrent] = useState(() => clamp(index ?? defaultIndex, 0, n - 1))\n  const [m] = useState(() => machine(current, setCurrent))\n  const [surface, setSurface] = useState<HTMLElement | null>(null)\n\n  // The latest options, for the handlers and the frame loop, which outlive a render.\n  useLayoutEffect(() => {\n    m.configure({ n, loop, axis, snap, step, onFrame, onIndexChange, reduce })\n  })\n\n  // A new count, or a loop turned on or off, puts the carousel back on a slide it has.\n  useEffect(() => m.reseat(), [n, loop, m])\n  // Controlled: follow the index from outside, and go back to it when a move was not taken up. Neither calls\n  // onIndexChange, as the parent already knows.\n  const controlled = index !== undefined\n  const target = clamp(index ?? 0, 0, n - 1)\n  useEffect(() => {\n    if (controlled && target !== m.committed) m.go(target, false)\n  })\n\n  useEffect(() => (surface && axis === 'x' ? m.listenWheel(surface) : undefined), [surface, axis, m])\n  useEffect(() => m.stop, [m])\n\n  return { index: controlled ? target : current, go: m.go, move: m.move, draw: m.draw, bind: { ref: setSurface, ...m.handlers } }\n}\n\ntype Tween = { from: number; to: number; start: number; dur: number }\ntype Drag = { id: number; x: number; y: number; p0: number; active: boolean; samples: [number, number][]; resume: number | null }\n\n// The state and behaviour, made once per carousel and kept for its lifetime.\nfunction machine(start: number, setCurrent: (i: number) => void) {\n  let want = start // where a drag or the wheel puts the carousel\n  let drawn = start // the position on screen\n  let velocity = 0\n  let tween: Tween | null = null\n  let drag: Drag | null = null\n  let raf = 0\n  let last = 0\n  let swallowClick = false\n  let wheelTimer = 0\n\n  const m = {\n    set: null as Settings | null,\n    committed: start,\n    configure: (settings: Settings) => {\n      m.set = settings\n    },\n    go,\n    move,\n    reseat,\n    draw,\n    stop,\n    listenWheel,\n    handlers: {\n      onPointerDown,\n      onPointerMove,\n      onPointerUp: (e: PointerEvent<HTMLElement>) => release(e, false),\n      onPointerCancel: (e: PointerEvent<HTMLElement>) => release(e, true),\n      onClickCapture,\n    },\n  }\n\n  function draw() {\n    m.set?.onFrame(drawn, velocity)\n  }\n\n  function tick(now: number) {\n    raf = 0\n    const o = m.set\n    if (!o) return\n    let p = want\n    if (tween) {\n      const u = clamp((now - tween.start) / tween.dur, 0, 1)\n      p = tween.from + (tween.to - tween.from) * (1 - (1 - u) ** 5)\n      if (u >= 1) tween = null\n    }\n    const dt = Math.max(now - last, 1) / 1000\n    last = now\n    // Smoothed over about 60 ms, so a layout can lean into the speed without jitter.\n    velocity = o.reduce ? 0 : velocity + ((p - drawn) / dt - velocity) * (1 - Math.exp(-dt / 0.06))\n    drawn = p\n    const moving = tween || drag?.active || Math.abs(velocity) > 0.01\n    if (!moving) velocity = 0\n    o.onFrame(p, velocity)\n    if (moving) raf = requestAnimationFrame(tick)\n  }\n\n  function run() {\n    if (raf) return\n    last = performance.now()\n    raf = requestAnimationFrame(tick)\n  }\n\n  function commit(to: number, notify = true) {\n    const o = m.set\n    if (!o) return\n    const i = mod(Math.round(to), o.n)\n    if (i === m.committed) return\n    m.committed = i\n    setCurrent(i)\n    if (notify) o.onIndexChange?.(i)\n  }\n\n  // Glide to a position. With a release speed (slides/s) in the same direction, the glide starts at that speed.\n  function glide(to: number, speed = 0) {\n    want = to\n    const dist = to - drawn\n    if (m.set?.reduce || Math.abs(dist) < 1e-4) tween = null\n    else {\n      const dur = speed && Math.sign(speed) === Math.sign(dist) ? clamp(((5 * Math.abs(dist)) / Math.abs(speed)) * 1000, 220, 900) : 300\n      tween = { from: drawn, to, start: performance.now(), dur }\n    }\n    run()\n  }\n\n  function go(i: number, notify = true) {\n    const o = m.set\n    if (!o || mod(i, o.n) === m.committed) return\n    let to: number\n    if (o.loop) {\n      const at = Math.round(tween?.to ?? want)\n      to = at + mod(i - at + o.n / 2, o.n) - o.n / 2\n      // An even count puts the far slide exactly half way round: go forwards.\n      if (to < at && mod(i - at, o.n) === o.n / 2) to = at + o.n / 2\n    } else to = clamp(i, 0, o.n - 1)\n    glide(to)\n    commit(to, notify)\n  }\n\n  // After the count or the loop changes: jump to the current slide, or the last one if it is gone. A position\n  // wound past the ends of a loop means nothing in the new count, so it is unwound too.\n  function reseat() {\n    const o = m.set\n    if (!o) return\n    const i = clamp(m.committed, 0, o.n - 1)\n    if (want === i && drawn === i && !tween) return\n    tween = null\n    velocity = 0\n    want = drawn = i\n    commit(i)\n    draw()\n  }\n\n  function move(by: number) {\n    const o = m.set\n    if (!o) return\n    let to = Math.round(tween?.to ?? want) + by\n    if (!o.loop) to = clamp(to, 0, o.n - 1)\n    glide(to)\n    commit(to)\n  }\n\n  function stop() {\n    cancelAnimationFrame(raf)\n    clearTimeout(wheelTimer)\n    raf = 0\n    tween = null\n    drag = null\n    velocity = 0\n    // Land where it was heading, so a remount starts settled.\n    drawn = want\n  }\n\n  // Where a position lands once the ends of a carousel that does not loop push back.\n  function give(raw: number) {\n    const o = m.set!\n    if (o.loop) return raw\n    if (raw < 0) return -rubber(-raw)\n    if (raw > o.n - 1) return o.n - 1 + rubber(raw - (o.n - 1))\n    return raw\n  }\n\n  function settle(speed: number, from: number) {\n    const o = m.set!\n    let to = drawn + speed * THROW\n    if (o.snap) {\n      to = Math.round(to)\n      // A quick flick always moves at least one slide.\n      if (Math.abs(speed) > 0.8 && to === Math.round(from)) to += Math.sign(speed)\n    }\n    if (!o.loop) to = clamp(to, 0, o.n - 1)\n    glide(to, speed)\n    commit(to)\n  }\n\n  function onPointerDown(e: PointerEvent<HTMLElement>) {\n    swallowClick = false\n    // A press that left the carousel before it became a drag never got its pointerup.\n    if (drag && !drag.active) drop(drag)\n    if (!e.isPrimary || e.button !== 0 || drag || !m.set || m.set.n < 2) return\n    const resume = tween ? tween.to : null\n    // Catch a moving carousel where it is.\n    if (tween) {\n      tween = null\n      want = drawn\n    }\n    drag = { id: e.pointerId, x: e.clientX, y: e.clientY, p0: drawn, active: false, samples: [[e.timeStamp, drawn]], resume }\n  }\n\n  function onPointerMove(e: PointerEvent<HTMLElement>) {\n    const d = drag\n    const o = m.set\n    if (!d || !o || e.pointerId !== d.id) return\n    // The mouse came back with its button up, so it was let go outside the carousel.\n    if (!d.active && !(e.buttons & 1)) return drop(d)\n    const dx = e.clientX - d.x\n    const dy = e.clientY - d.y\n    const along = o.axis === 'x' ? dx : dy\n    const across = o.axis === 'x' ? dy : dx\n    if (!d.active) {\n      if (Math.abs(along) < SLOP && Math.abs(across) < SLOP) return\n      // Moving across the carousel: leave it to the page.\n      if (Math.abs(across) >= Math.abs(along)) return drop(d)\n      d.active = true\n      // Count from here, so the slop does not make the slides jump.\n      if (o.axis === 'x') d.x += Math.sign(dx) * SLOP\n      else d.y += Math.sign(dy) * SLOP\n      e.currentTarget.setPointerCapture(e.pointerId)\n      e.currentTarget.dataset.dragging = ''\n    }\n    const p = give(d.p0 - (o.axis === 'x' ? e.clientX - d.x : e.clientY - d.y) / per(o.step))\n    want = p\n    d.samples.push([e.timeStamp, p])\n    while (d.samples.length > 2 && e.timeStamp - d.samples[0][0] > SAMPLE) d.samples.shift()\n    run()\n  }\n\n  function release(e: PointerEvent<HTMLElement>, cancelled: boolean) {\n    const d = drag\n    if (!d || e.pointerId !== d.id) return\n    delete e.currentTarget.dataset.dragging\n    if (!d.active) return drop(d)\n    drag = null\n    swallowClick = true\n    let speed = 0\n    const [t0, p0] = d.samples[0]\n    const [t1, p1] = d.samples[d.samples.length - 1]\n    // A finger that stopped before letting go throws nothing.\n    if (!cancelled && t1 > t0 && e.timeStamp - t1 < 60) speed = ((p1 - p0) / (t1 - t0)) * 1000\n    settle(speed, d.p0)\n  }\n\n  // Let go of a press that never became a drag, and finish any glide it caught.\n  function drop(d: Drag) {\n    drag = null\n    if (d.resume !== null) glide(d.resume)\n  }\n\n  // A drag ends with a click on whatever was under the pointer; that click is not meant.\n  function onClickCapture(e: MouseEvent<HTMLElement>) {\n    if (!swallowClick) return\n    swallowClick = false\n    e.preventDefault()\n    e.stopPropagation()\n  }\n\n  // Sideways wheel and trackpad swipes. A vertical wheel is left to the page.\n  function listenWheel(el: HTMLElement) {\n    let base: number | null = null\n    const onWheel = (e: WheelEvent) => {\n      const o = m.set\n      const dx = e.deltaX || (e.shiftKey ? e.deltaY : 0)\n      if (!o || o.n < 2 || Math.abs(dx) <= Math.abs(e.shiftKey ? 0 : e.deltaY)) return\n      e.preventDefault()\n      if (drag?.active) return\n      tween = null\n      base = (base ?? drawn) + (dx * (e.deltaMode === 1 ? 16 : 1)) / per(o.step)\n      if (!o.loop) base = clamp(base, -1.5, o.n + 0.5)\n      want = give(base)\n      run()\n      clearTimeout(wheelTimer)\n      wheelTimer = window.setTimeout(() => {\n        base = null\n        settle(0, drawn)\n      }, 140)\n    }\n    el.addEventListener('wheel', onWheel, { passive: false })\n    return () => {\n      el.removeEventListener('wheel', onWheel)\n      clearTimeout(wheelTimer)\n    }\n  }\n\n  return m\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "registry/manniche/hooks/use-gl-stage.ts",
      "content": "import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from 'react'\nimport { useReducedMotion } from '@/registry/manniche/hooks/use-reduced-motion'\n\n/** A picture for a WebGL carousel. The alt text is what screen readers read; the canvas itself is hidden from them. */\nexport type GlImage = { src: string; alt: string }\n\nexport type Rgb = [number, number, number]\n\nexport type GlSceneContext = {\n  gl: WebGLRenderingContext\n  canvas: HTMLCanvasElement\n  /** Call once the scene has what it needs for its first frame, e.g. after its pictures load. The canvas fades in. */\n  ready: () => void\n  /** Draw again, e.g. once a texture has loaded. */\n  redraw: () => void\n  /** Call when the scene cannot run after all, e.g. its pictures could not be uploaded. The stage reports `failed`. */\n  fail: () => void\n  /** The theme's colours when the scene is built, in the order of `colors`, e.g. for a texture's empty cells. */\n  colors: Rgb[]\n  /** Set when the stage is torn down, so work that finishes later can stop. */\n  signal: AbortSignal\n}\n\nexport type GlScene = {\n  /** Draws a frame. `time` is in seconds; it only moves while the stage animates on its own and stands still under reduced motion. */\n  draw: (time: number) => void\n  /** The canvas has a new size in device pixels. The viewport is already set. */\n  resize?: (width: number, height: number, dpr: number) => void\n  /** The theme's colours, in the order of `colors`, as 0–1 RGB. Called before the first frame and on every theme change. */\n  theme?: (colors: Rgb[]) => void\n  /** Frees what the scene made. The context is released right after. */\n  dispose?: () => void\n}\n\nexport type GlStageOptions = {\n  /** Upper bound on device pixels per CSS pixel. */\n  maxDpr?: number\n  /** Upper bound on the canvas's device pixels; past it the canvas renders smaller and the browser scales it up. */\n  maxPixels?: number\n  /** A frame loop of its own while on screen, for motion that does not come from the carousel. Off under reduced motion. */\n  animate?: boolean\n  /** CSS colours the scene needs, e.g. `var(--background)`. Read from the host, so they follow the theme. */\n  colors?: string[]\n  /** Classes for the canvas, which goes first in the host. */\n  className?: string\n  /** Context attributes on top of the defaults (no antialias, low power). */\n  attributes?: WebGLContextAttributes\n}\n\nexport type GlStage = {\n  /** `pending` until the scene is ready, `ready` once it has drawn, `failed` without WebGL or after the GPU drops the context. */\n  status: 'pending' | 'ready' | 'failed'\n  /** Draw a frame now, e.g. from a carousel's `onFrame`. Does nothing before the scene exists. */\n  redraw: () => void\n}\n\n// Seconds of animation that a still frame shows under reduced motion, so it is not the plain first frame.\nconst STILL = 4\n\n/**\n * Runs a WebGL scene in a canvas inside `host`: a fresh canvas each run, sized to the host with a cap on pixels,\n * the theme's colours read live, its own frame loop only while on screen, and the context released on teardown or\n * failure, so a page never holds more than it uses. `build` makes the scene; it runs again when `key` changes, so put\n * everything the scene is built from in it, e.g. the pictures' addresses. Return null from `build` when the scene cannot\n * run, and the stage reports `failed`.\n */\nexport function useGlStage(\n  host: RefObject<HTMLElement | null>,\n  build: (ctx: GlSceneContext) => GlScene | null,\n  key: string,\n  { maxDpr = 2, maxPixels = 2_400_000, animate = false, colors = [], className = '', attributes }: GlStageOptions = {},\n): GlStage {\n  const reduce = useReducedMotion()\n  const [status, setStatus] = useState<GlStage['status']>('pending')\n  // The live scene's draw, for redraw(); null between runs.\n  const live = useRef<(() => void) | null>(null)\n  const palette = colors.join('|')\n  const attrs = JSON.stringify(attributes ?? {})\n  // The latest build, so a new function each render does not rebuild the scene; `key` does.\n  const make = useRef(build)\n  useLayoutEffect(() => {\n    make.current = build\n  })\n\n  useEffect(() => {\n    const wrap = host.current\n    if (!wrap) return\n    const abort = new AbortController()\n    // Reported after the effect, and only while this run is the live one.\n    const report = (s: GlStage['status']) => queueMicrotask(() => abort.signal.aborted || setStatus(s))\n    report('pending')\n    // A fresh canvas per run: a canvas whose context was lost cannot hand out a new one.\n    const el = document.createElement('canvas')\n    el.setAttribute('aria-hidden', 'true')\n    el.className = className\n    wrap.prepend(el)\n\n    const options: WebGLContextAttributes = { antialias: false, alpha: true, premultipliedAlpha: true, powerPreference: 'low-power', ...JSON.parse(attrs) }\n    const gl = el.getContext('webgl', options)\n    if (!gl) {\n      el.remove()\n      report('failed')\n      return () => abort.abort()\n    }\n\n    let scene: GlScene | null = null\n    let dead = false\n    let shown = false\n    let clock = STILL\n    let raf = 0\n    let last = 0\n    let visible = false\n    const looping = animate && !reduce\n\n    const frame = () => {\n      if (!dead && scene) scene.draw(clock)\n    }\n    let released = false\n    const release = () => {\n      if (released) return\n      released = true\n      dead = true\n      live.current = null\n      cancelAnimationFrame(raf)\n      abort.abort()\n      try {\n        scene?.dispose?.()\n      } catch {\n        // The context may already be gone; there is nothing left to free.\n      }\n      gl.getExtension('WEBGL_lose_context')?.loseContext()\n      el.remove()\n    }\n\n    // The theme's colours, read through a 1 px canvas so any CSS colour (oklch, color-mix, var) comes out as RGB.\n    const probe = document.createElement('canvas').getContext('2d', { willReadFrequently: true })\n    const wanted = palette ? palette.split('|') : []\n    const read = () => {\n      const style = getComputedStyle(wrap)\n      return wanted.map((c): Rgb => {\n        const value = c.startsWith('var(') ? style.getPropertyValue(c.slice(4, -1).split(',')[0].trim()).trim() || '#808080' : c\n        if (!probe) return [0.5, 0.5, 0.5]\n        probe.clearRect(0, 0, 1, 1)\n        probe.fillStyle = '#808080'\n        probe.fillStyle = value\n        probe.fillRect(0, 0, 1, 1)\n        const [r, g, b] = probe.getImageData(0, 0, 1, 1).data\n        return [r / 255, g / 255, b / 255]\n      })\n    }\n\n    const ctx: GlSceneContext = {\n      gl,\n      canvas: el,\n      signal: abort.signal,\n      colors: read(),\n      fail: () => {\n        if (dead) return\n        release()\n        queueMicrotask(() => setStatus('failed'))\n      },\n      redraw: () => {\n        // A running loop draws on its next frame anyway.\n        if (!(looping && visible)) frame()\n      },\n      ready: () => {\n        if (dead || shown) return\n        shown = true\n        frame()\n        // Wait a frame so the canvas fades in instead of popping.\n        requestAnimationFrame(() => {\n          if (dead) return\n          el.dataset.ready = ''\n          report('ready')\n        })\n      },\n    }\n\n    try {\n      scene = make.current(ctx)\n    } catch {\n      scene = null\n    }\n    // Free the context straight away, so a scene that cannot run does not hold one of the page's few.\n    if (!scene) {\n      release()\n      queueMicrotask(() => setStatus('failed'))\n      return\n    }\n    live.current = ctx.redraw\n\n    const theme = () => {\n      if (dead || !scene?.theme || !wanted.length) return\n      scene.theme(read())\n      ctx.redraw()\n    }\n    theme()\n    // The theme changes through a class or inline variables on <html>, or the system's colour scheme.\n    const mo = new MutationObserver(theme)\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] })\n    const scheme = window.matchMedia('(prefers-color-scheme: dark)')\n    scheme.addEventListener('change', theme)\n\n    const resize = () => {\n      if (dead) return\n      const w = el.clientWidth\n      const h = el.clientHeight\n      let dpr = Math.min(maxDpr, window.devicePixelRatio || 1)\n      if (w * h * dpr * dpr > maxPixels) dpr = Math.sqrt(maxPixels / Math.max(1, w * h))\n      el.width = Math.max(1, Math.round(w * dpr))\n      el.height = Math.max(1, Math.round(h * dpr))\n      gl.viewport(0, 0, el.width, el.height)\n      scene?.resize?.(el.width, el.height, dpr)\n      frame()\n    }\n    const ro = new ResizeObserver(resize)\n    ro.observe(el)\n    resize()\n\n    const loop = (now: number) => {\n      // Cap the step so a stalled tab does not jump the motion ahead.\n      clock += Math.min(50, now - (last || now)) / 1000\n      last = now\n      frame()\n      raf = requestAnimationFrame(loop)\n    }\n    // Only animate while on screen.\n    const io = new IntersectionObserver(([entry]) => {\n      if (dead || !looping || entry.isIntersecting === visible) return\n      visible = entry.isIntersecting\n      last = 0\n      if (visible) raf = requestAnimationFrame(loop)\n      else cancelAnimationFrame(raf)\n    })\n    io.observe(el)\n\n    // If the GPU drops the context, the component shows its fallback again.\n    const lost = (e: Event) => {\n      e.preventDefault()\n      dead = true\n      live.current = null\n      cancelAnimationFrame(raf)\n      delete el.dataset.ready\n      report('failed')\n    }\n    el.addEventListener('webglcontextlost', lost)\n\n    return () => {\n      ro.disconnect()\n      io.disconnect()\n      mo.disconnect()\n      scheme.removeEventListener('change', theme)\n      el.removeEventListener('webglcontextlost', lost)\n      release()\n    }\n  }, [host, key, reduce, animate, maxDpr, maxPixels, palette, className, attrs])\n\n  const redraw = useCallback(() => live.current?.(), [])\n  return { status, redraw }\n}\n\n/**\n * Compiles and links a program, or returns null so the caller can fall back; the reason goes to the console unless the\n * context was lost. A uniform used in both shaders must have the same precision in each, or linking fails.\n */\nexport function glProgram(gl: WebGLRenderingContext, vertex: string, fragment: string) {\n  const program = gl.createProgram()\n  if (!program) return null\n  const log: string[] = []\n  for (const [type, src] of [\n    [gl.VERTEX_SHADER, vertex],\n    [gl.FRAGMENT_SHADER, fragment],\n  ] as const) {\n    const s = gl.createShader(type)\n    if (!s) {\n      gl.deleteProgram(program)\n      return null\n    }\n    gl.shaderSource(s, src)\n    gl.compileShader(s)\n    if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) log.push(gl.getShaderInfoLog(s) ?? '')\n    gl.attachShader(program, s)\n    // Flagged for deletion; it lives until the program goes.\n    gl.deleteShader(s)\n  }\n  gl.linkProgram(program)\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    if (!gl.isContextLost()) console.warn('WebGL program failed:', log.join('\\n') || gl.getProgramInfoLog(program))\n    gl.deleteProgram(program)\n    return null\n  }\n  return program\n}\n\nexport type GlAtlas = {\n  /** The texture, power-of-two sized, with mipmaps. */\n  texture: WebGLTexture\n  /** Each picture's corner in the atlas as [u0, v0, u1, v1], with v growing downwards (the texture is not flipped). */\n  rects: [number, number, number, number][]\n  /** False for a picture that did not load; its cell shows `empty` instead. */\n  loaded: boolean[]\n  /** A cell's width and height in texels, without its gutter. */\n  cell: [number, number]\n  /** The texture's width and height in texels. */\n  size: [number, number]\n}\n\nconst pow2 = (v: number) => 2 ** Math.ceil(Math.log2(Math.max(1, v)))\n\nfunction load(src: string, signal: AbortSignal) {\n  return new Promise<HTMLImageElement | null>((resolve) => {\n    const img = new Image()\n    // A picture from another origin must send CORS headers to be drawn into WebGL; without them it shows as empty.\n    if (!/^(data|blob):/.test(src)) img.crossOrigin = 'anonymous'\n    img.decoding = 'async'\n    img.onload = () => resolve(img)\n    img.onerror = () => resolve(null)\n    signal.addEventListener('abort', () => resolve(null), { once: true })\n    img.src = src\n  })\n}\n\nexport type GlAtlasOptions = {\n  /** The pictures' width over height; each is cropped from the middle to fill a cell of this shape. */\n  aspect: number\n  /** The longer side of a cell in texels, at most. Cells shrink to keep the texture within `limit`. */\n  cell?: number\n  /** The texture's largest side in texels. A 2048 atlas with mipmaps takes about 22 MB of GPU memory, a 4096 one four times that. */\n  limit?: number\n  /** The colour of a cell whose picture did not load. */\n  empty?: Rgb\n  signal: AbortSignal\n}\n\n/**\n * Loads pictures and paints them, cropped to fill a cell of `aspect` (width over height), into one texture. Each cell\n * has a gutter of its own edge pixels, so mipmaps and filtering never bleed one picture into the next. Resolves to\n * null if the stage was torn down meanwhile, or if the pictures could not be uploaded.\n */\nexport async function glAtlas(\n  gl: WebGLRenderingContext,\n  images: GlImage[],\n  { aspect, cell = 1024, limit = 2048, empty = [0.5, 0.5, 0.5], signal }: GlAtlasOptions,\n): Promise<GlAtlas | null> {\n  const n = Math.max(images.length, 1)\n  const cols = Math.ceil(Math.sqrt(n * aspect) / aspect) || 1\n  const rows = Math.ceil(n / cols)\n  const gutter = 4\n  // The largest cell in the picture's shape that fits the grid within the limit; the limit is a power of two, so the\n  // texture never rounds up past it.\n  const max = Math.min(pow2(limit), gl.getParameter(gl.MAX_TEXTURE_SIZE) as number)\n  const w = aspect >= 1 ? 1 : aspect\n  const h = aspect >= 1 ? 1 / aspect : 1\n  const size = Math.max(16, Math.floor(Math.min(cell, (max / cols - gutter * 2) / w, (max / rows - gutter * 2) / h)))\n  const cw = Math.max(1, Math.floor(size * w))\n  const ch = Math.max(1, Math.floor(size * h))\n  const W = pow2((cw + gutter * 2) * cols)\n  const H = pow2((ch + gutter * 2) * rows)\n\n  const pictures = await Promise.all(images.map((im) => load(im.src, signal)))\n  if (signal.aborted) return null\n\n  const canvas = document.createElement('canvas')\n  canvas.width = W\n  canvas.height = H\n  const c = canvas.getContext('2d')\n  if (!c) return null\n  c.imageSmoothingQuality = 'high'\n  const fill = `rgb(${empty.map((v) => Math.round(v * 255)).join(' ')})`\n  const rects: GlAtlas['rects'] = []\n  const loaded: boolean[] = []\n\n  pictures.forEach((img, i) => {\n    const x = (i % cols) * (cw + gutter * 2) + gutter\n    const y = Math.floor(i / cols) * (ch + gutter * 2) + gutter\n    rects.push([x / W, y / H, (x + cw) / W, (y + ch) / H])\n    const ok = !!img && img.naturalWidth > 0\n    loaded.push(ok)\n    if (!ok) {\n      c.fillStyle = fill\n      c.fillRect(x - gutter, y - gutter, cw + gutter * 2, ch + gutter * 2)\n      return\n    }\n    // Crop to fill the cell, from the middle.\n    const k = Math.max(cw / img.naturalWidth, ch / img.naturalHeight)\n    const sw = cw / k\n    const sh = ch / k\n    const sx = (img.naturalWidth - sw) / 2\n    const sy = (img.naturalHeight - sh) / 2\n    c.drawImage(img, sx, sy, sw, sh, x, y, cw, ch)\n    // The gutter repeats the cell's outermost pixels.\n    c.drawImage(canvas, x, y, cw, 1, x, y - gutter, cw, gutter)\n    c.drawImage(canvas, x, y + ch - 1, cw, 1, x, y + ch, cw, gutter)\n    c.drawImage(canvas, x, y - gutter, 1, ch + gutter * 2, x - gutter, y - gutter, gutter, ch + gutter * 2)\n    c.drawImage(canvas, x + cw - 1, y - gutter, 1, ch + gutter * 2, x + cw, y - gutter, gutter, ch + gutter * 2)\n  })\n\n  const texture = gl.createTexture()\n  if (!texture || gl.isContextLost()) return null\n  gl.bindTexture(gl.TEXTURE_2D, texture)\n  gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true)\n  try {\n    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas)\n  } catch {\n    // A picture tainted the canvas; nothing can be uploaded.\n    gl.deleteTexture(texture)\n    return null\n  }\n  gl.generateMipmap(gl.TEXTURE_2D)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR)\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)\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  const aniso = gl.getExtension('EXT_texture_filter_anisotropic')\n  if (aniso) gl.texParameterf(gl.TEXTURE_2D, aniso.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(8, gl.getParameter(aniso.MAX_TEXTURE_MAX_ANISOTROPY_EXT)))\n  return { texture, rects, loaded, cell: [cw, ch], size: [W, H] }\n}\n",
      "type": "registry:hook"
    },
    {
      "path": "registry/manniche/hooks/use-reduced-motion.ts",
      "content": "import { useSyncExternalStore } from 'react'\n\nconst QUERY = '(prefers-reduced-motion: reduce)'\n\nfunction subscribe(onChange: () => void) {\n  const mq = window.matchMedia(QUERY)\n  mq.addEventListener('change', onChange)\n  return () => mq.removeEventListener('change', onChange)\n}\n\n/** True when the visitor has asked the system for less motion. */\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(QUERY).matches,\n    () => false,\n  )\n}\n",
      "type": "registry:hook"
    }
  ],
  "categories": [
    "widgets"
  ],
  "type": "registry:component"
}