Skip to content

Darkroom Stream

Photos stream out from the center and develop like prints.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/darkroom-stream.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/gallery/gallery-a11y.tsx
    import { useEffect, useRef } from "react";
    import type { GalleryItem } from "@/lib/gallery/types";
    
    export interface GalleryA11yProps {
      items: GalleryItem[];
      /** Active item. */
      index: number;
      /** Names the list, e.g. "Album covers". */
      label: string;
      /** Focusing an item's button (Tab, screen reader cursor) moves the gallery to it. */
      onFocusItem?: (index: number) => void;
      /** Clicking an item's button or pressing Enter on it. */
      onActivate?: (index: number) => void;
      /** Announce index changes politely. Turn off while autoplay runs so it doesn't talk over the user. @default true */
      live?: boolean;
    }
    
    const describe = (item: GalleryItem, index: number, total: number) =>
      [`${index + 1} of ${total}`, item.title, item.subtitle, item.alt].filter(Boolean).join(". ");
    
    /**
     * The accessible side of a canvas gallery: a visually hidden list of buttons, one per item, kept
     * in sync with the active index (roving tabindex, focus follows arrow keys), plus a polite live
     * region that names the active item. The canvas itself stays aria-hidden.
     */
    export function GalleryA11y({ items, index, label, onFocusItem, onActivate, live = true }: GalleryA11yProps) {
      const list = useRef<HTMLUListElement>(null);
    
      // When the active item changes while focus is in the list (arrow keys), move focus with it.
      useEffect(() => {
        const element = list.current;
        if (!element || !element.contains(document.activeElement)) return;
        element.querySelectorAll<HTMLButtonElement>("button")[index]?.focus({ preventScroll: true });
      }, [index]);
    
      const active = items[index];
      return (
        <div className="sr-only">
          <ul ref={list} aria-label={label}>
            {items.map((item, i) => (
              <li key={`${item.src}-${i}`}>
                <button
                  type="button"
                  tabIndex={i === index ? 0 : -1}
                  aria-current={i === index ? "true" : undefined}
                  onFocus={() => {
                    if (i !== index) onFocusItem?.(i);
                  }}
                  onClick={() => onActivate?.(i)}
                >
                  {describe(item, i, items.length)}
                </button>
              </li>
            ))}
          </ul>
          <p aria-live={live ? "polite" : "off"} aria-atomic="true">
            {active ? `${active.title ?? active.alt}, ${index + 1} of ${items.length}` : ""}
          </p>
        </div>
      );
    }
    lib/gallery/gallery-chrome.tsx
    import { useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronLeft, ChevronRight } from "lucide-react";
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import type { GalleryItem } from "@/lib/gallery/types";
    
    /** "light" = a light (or transparent) background that needs dark text; "dark" = a dark one. */
    export type ChromeScheme = "light" | "dark";
    
    /**
     * Picks caption and control colors for a background. Understands hex and rgb()/rgba(); anything
     * else (named colors, var()) and transparent backgrounds count as light, like the page itself.
     */
    export function chromeScheme(background?: string): ChromeScheme {
      if (!background) return "light";
      const value = background.trim().toLowerCase();
      let rgb: number[] | null = null;
      let alpha = 1;
      const hex = value.match(/^#([0-9a-f]{3,8})$/);
      if (hex) {
        const h = hex[1];
        const full = h.length <= 4 ? [...h].map((c) => c + c).join("") : h;
        rgb = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
        if (full.length === 8) alpha = parseInt(full.slice(6, 8), 16) / 255;
      }
      const fn = value.match(/^rgba?\(([^)]+)\)$/);
      if (fn) {
        const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map(parseFloat);
        rgb = parts.slice(0, 3);
        if (parts.length > 3) alpha = parts[3] > 1 ? parts[3] / 100 : parts[3];
      }
      if (!rgb || alpha < 0.5) return "light";
      const [r, g, b] = rgb.map((c) => {
        const s = c / 255;
        return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.25 ? "dark" : "light";
    }
    
    export interface GalleryCaptionProps {
      item: GalleryItem | undefined;
      index: number;
      /** Item count, so wrapping from last to first slides the right way. */
      count: number;
      scheme?: ChromeScheme;
      className?: string;
    }
    
    /**
     * Title and subtitle of the active item. It slides in from the direction of travel. Screen readers
     * get the same text from the gallery's live region, so this copy is aria-hidden.
     */
    export function GalleryCaption({ item, index, count, scheme = "light", className }: GalleryCaptionProps) {
      const reduceMotion = useReducedMotion();
      const [previous, setPrevious] = useState(index);
      const [direction, setDirection] = useState(1);
      if (previous !== index) {
        let delta = index - previous;
        if (count > 2 && Math.abs(delta) > count / 2) delta -= Math.sign(delta) * count;
        setDirection(delta >= 0 ? 1 : -1);
        setPrevious(index);
      }
      const shift = reduceMotion ? 0 : 14;
      return (
        <div aria-hidden="true" className={cn("relative h-10 min-w-0 overflow-hidden", className)}>
          <AnimatePresence initial={false} custom={direction} mode="popLayout">
            <motion.div
              key={index}
              custom={direction}
              variants={{
                enter: (d: number) => ({ opacity: 0, x: shift * d, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }),
                center: { opacity: 1, x: 0, filter: "blur(0px)" },
                exit: (d: number) => ({ opacity: 0, x: -shift * d, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }),
              }}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ duration: duration.base, ease: ease.out }}
              className="absolute inset-0"
            >
              <p className={cn("truncate text-sm font-medium", scheme === "dark" ? "text-white" : "text-foreground")}>{item?.title ?? item?.alt}</p>
              {item?.subtitle ? <p className={cn("truncate text-[13px]", scheme === "dark" ? "text-white/65" : "text-muted-foreground")}>{item.subtitle}</p> : null}
            </motion.div>
          </AnimatePresence>
        </div>
      );
    }
    
    export interface GalleryControlsProps {
      index: number;
      count: number;
      onPrevious: () => void;
      onNext: () => void;
      scheme?: ChromeScheme;
      /** Disable the buttons at the ends of a gallery that doesn't loop. @default false */
      bounded?: boolean;
      className?: string;
    }
    
    /** Previous / next buttons with an "n / total" counter. */
    export function GalleryControls({ index, count, onPrevious, onNext, scheme = "light", bounded = false, className }: GalleryControlsProps) {
      const button = cn(
        "grid size-10 cursor-pointer place-items-center rounded-full border transition-[background-color,opacity,transform] duration-150 outline-none active:scale-[0.94] disabled:pointer-events-none disabled:opacity-35 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
        scheme === "dark" ? "border-white/20 bg-white/10 text-white hover:bg-white/20" : "border-border bg-background/80 text-foreground hover:bg-surface-2",
      );
      return (
        <div data-gallery-control className={cn("flex shrink-0 items-center gap-1.5", className)}>
          <button type="button" data-gallery-control aria-label="Previous" onClick={onPrevious} disabled={bounded && index <= 0} className={button}>
            <ChevronLeft className="size-4" aria-hidden="true" />
          </button>
          <span aria-hidden="true" className={cn("min-w-14 text-center font-mono text-[13px] tabular-nums", scheme === "dark" ? "text-white/75" : "text-muted-foreground")}>
            {index + 1} / {count}
          </span>
          <button type="button" data-gallery-control aria-label="Next" onClick={onNext} disabled={bounded && index >= count - 1} className={button}>
            <ChevronRight className="size-4" aria-hidden="true" />
          </button>
        </div>
      );
    }
    lib/gallery/gallery-fallback.tsx
    import { cn } from "@/lib/utils";
    import type { GalleryItem } from "@/lib/gallery/types";
    
    export interface GalleryFallbackProps {
      items: GalleryItem[];
      /** Names the list. */
      label: string;
      /** CSS aspect ratio of each image. @default "4 / 5" */
      aspect?: string;
      onSelect?: (item: GalleryItem, index: number) => void;
      className?: string;
    }
    
    /** What a gallery shows without WebGL: the same items as a plain, swipeable scroll-snap list. */
    export function GalleryFallback({ items, label, aspect = "4 / 5", onSelect, className }: GalleryFallbackProps) {
      return (
        <ul aria-label={label} className={cn("flex size-full snap-x snap-mandatory items-center gap-4 overflow-x-auto px-6 py-6 [scrollbar-width:thin]", className)}>
          {items.map((item, i) => (
            <li key={`${item.src}-${i}`} className="w-[min(70%,15rem)] shrink-0 snap-center">
              <figure>
                {onSelect ? (
                  <button type="button" onClick={() => onSelect(item, i)} className="block w-full cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring">
                    {/* eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic */}
                    <img src={item.src} alt={item.alt} loading="lazy" decoding="async" className="w-full rounded-xl object-cover shadow-md" style={{ aspectRatio: aspect }} />
                  </button>
                ) : (
                  // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic
                  <img src={item.src} alt={item.alt} loading="lazy" decoding="async" className="w-full rounded-xl object-cover shadow-md" style={{ aspectRatio: aspect }} />
                )}
                {item.title ? (
                  <figcaption className="mt-2 text-sm">
                    <span className="font-medium text-foreground">{item.title}</span>
                    {item.subtitle ? <span className="block text-muted-foreground">{item.subtitle}</span> : null}
                  </figcaption>
                ) : null}
              </figure>
            </li>
          ))}
        </ul>
      );
    }
    lib/gallery/math.ts
    /** Column-major 4×4 matrices for the gallery renderer (no dependencies). */
    export type Mat4 = Float32Array;
    
    export const createMat4 = (): Mat4 => {
      const m = new Float32Array(16);
      m[0] = m[5] = m[10] = m[15] = 1;
      return m;
    };
    
    /** out = a × b */
    export function multiply(out: Mat4, a: Mat4, b: Mat4) {
      for (let col = 0; col < 4; col++) {
        const b0 = b[col * 4];
        const b1 = b[col * 4 + 1];
        const b2 = b[col * 4 + 2];
        const b3 = b[col * 4 + 3];
        out[col * 4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
        out[col * 4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
        out[col * 4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
        out[col * 4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
      }
      return out;
    }
    
    export function perspective(out: Mat4, fovy: number, aspect: number, near: number, far: number) {
      const f = 1 / Math.tan(fovy / 2);
      out.fill(0);
      out[0] = f / aspect;
      out[5] = f;
      out[10] = (far + near) / (near - far);
      out[11] = -1;
      out[14] = (2 * far * near) / (near - far);
      return out;
    }
    
    export function lookAt(out: Mat4, eye: readonly number[], target: readonly number[], up: readonly number[] = [0, 1, 0]) {
      let zx = eye[0] - target[0];
      let zy = eye[1] - target[1];
      let zz = eye[2] - target[2];
      let len = Math.hypot(zx, zy, zz) || 1;
      zx /= len;
      zy /= len;
      zz /= len;
      let xx = up[1] * zz - up[2] * zy;
      let xy = up[2] * zx - up[0] * zz;
      let xz = up[0] * zy - up[1] * zx;
      len = Math.hypot(xx, xy, xz) || 1;
      xx /= len;
      xy /= len;
      xz /= len;
      const yx = zy * xz - zz * xy;
      const yy = zz * xx - zx * xz;
      const yz = zx * xy - zy * xx;
      out[0] = xx;
      out[1] = yx;
      out[2] = zx;
      out[3] = 0;
      out[4] = xy;
      out[5] = yy;
      out[6] = zy;
      out[7] = 0;
      out[8] = xz;
      out[9] = yz;
      out[10] = zz;
      out[11] = 0;
      out[12] = -(xx * eye[0] + xy * eye[1] + xz * eye[2]);
      out[13] = -(yx * eye[0] + yy * eye[1] + yz * eye[2]);
      out[14] = -(zx * eye[0] + zy * eye[1] + zz * eye[2]);
      out[15] = 1;
      return out;
    }
    
    /**
     * Model matrix: translate × rotateY × rotateX × rotateZ × flip × uniform scale. Angles in radians.
     * `flip` turns the card around its own vertical axis before the other rotations (turning a leaning card over).
     */
    export function compose(out: Mat4, x: number, y: number, z: number, rx: number, ry: number, rz: number, s: number, flip = 0) {
      const cx = Math.cos(rx);
      const sx = Math.sin(rx);
      const cy = Math.cos(ry);
      const sy = Math.sin(ry);
      const cz = Math.cos(rz);
      const sz = Math.sin(rz);
      out[0] = (cy * cz + sy * sx * sz) * s;
      out[1] = cx * sz * s;
      out[2] = (-sy * cz + cy * sx * sz) * s;
      out[3] = 0;
      out[4] = (-cy * sz + sy * sx * cz) * s;
      out[5] = cx * cz * s;
      out[6] = (sy * sz + cy * sx * cz) * s;
      out[7] = 0;
      out[8] = sy * cx * s;
      out[9] = -sx * s;
      out[10] = cy * cx * s;
      out[11] = 0;
      if (flip) {
        // Right-multiply by a rotation about local y: column 0 and column 2 mix.
        const cf = Math.cos(flip);
        const sf = Math.sin(flip);
        for (let row = 0; row < 3; row++) {
          const c0 = out[row];
          const c2 = out[8 + row];
          out[row] = c0 * cf - c2 * sf;
          out[8 + row] = c0 * sf + c2 * cf;
        }
      }
      out[12] = x;
      out[13] = y;
      out[14] = z;
      out[15] = 1;
      return out;
    }
    
    /** Inverts m into out. Returns false (leaving out untouched) when m is singular. */
    export function invert(out: Mat4, m: Mat4) {
      const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
      const b00 = a00 * a11 - a01 * a10;
      const b01 = a00 * a12 - a02 * a10;
      const b02 = a00 * a13 - a03 * a10;
      const b03 = a01 * a12 - a02 * a11;
      const b04 = a01 * a13 - a03 * a11;
      const b05 = a02 * a13 - a03 * a12;
      const b06 = a20 * a31 - a21 * a30;
      const b07 = a20 * a32 - a22 * a30;
      const b08 = a20 * a33 - a23 * a30;
      const b09 = a21 * a32 - a22 * a31;
      const b10 = a21 * a33 - a23 * a31;
      const b11 = a22 * a33 - a23 * a32;
      let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
      if (!det) return false;
      det = 1 / det;
      out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
      out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
      out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
      out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
      out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
      out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
      out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
      out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
      out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
      out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
      out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
      out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
      out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;
      out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
      out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;
      out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
      return true;
    }
    
    /** m × (x, y, z, w) */
    export function transform(m: Mat4, x: number, y: number, z: number, w = 1): [number, number, number, number] {
      return [
        m[0] * x + m[4] * y + m[8] * z + m[12] * w,
        m[1] * x + m[5] * y + m[9] * z + m[13] * w,
        m[2] * x + m[6] * y + m[10] * z + m[14] * w,
        m[3] * x + m[7] * y + m[11] * z + m[15] * w,
      ];
    }
    
    export const clamp = (value: number, min: number, max: number) => (value < min ? min : value > max ? max : value);
    export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
    export const smoothstep = (edge0: number, edge1: number, x: number) => {
      const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
      return t * t * (3 - 2 * t);
    };
    /** Positive modulo, e.g. for wrapping indexes. */
    export const wrap = (value: number, length: number) => ((value % length) + length) % length;
    lib/gallery/renderer.ts
    import { clamp, compose, createMat4, invert, lookAt, multiply, perspective, transform, type Mat4 } from "@/lib/gallery/math";
    
    /**
     * One textured card in world space. Units are CSS pixels: with the default camera, a card at
     * z = 0 that is 200 wide covers 200 CSS pixels on screen. Angles are radians; y points up.
     */
    export interface GalleryCard {
      /** Index into the renderer's image list, or -1 for an untextured card. */
      image: number;
      x: number;
      y: number;
      z: number;
      rotateX?: number;
      rotateY?: number;
      rotateZ?: number;
      /** Uniform scale. @default 1 */
      scale?: number;
      /** Turn around the card's own vertical axis, applied before the other rotations (radians). @default 0 */
      flip?: number;
      width: number;
      height: number;
      /** Corner radius in px. @default 12 */
      radius?: number;
      /** 0 keeps `radius`, 1 rounds fully (circle for square cards). @default 0 */
      round?: number;
      /** Fades the card toward the background. @default 1 */
      opacity?: number;
      /** Darkens the card (0–1). @default 0 */
      dim?: number;
      /** 0 = monochrome and low contrast, 1 = full color (develop effect). @default 1 */
      tone?: number;
      /** Soft drop shadow strength (0–1). @default 0 */
      shadow?: number;
      /** Floor reflection strength (0–1); needs `floorY` in the scene. @default 0 */
      reflection?: number;
      /** Accent ring strength (0–1). @default 0 */
      ring?: number;
      /** Paper border in px around the image (print look). @default 0 */
      border?: number;
      /** Shifts the cover crop, -1 to 1 per axis (parallax drift inside the frame). @default 0 */
      panX?: number;
      panY?: number;
      /** Flat color mixed over the card, [r, g, b, amount] in 0–1 (e.g. a card's back). */
      fill?: readonly [number, number, number, number];
      /** Returned by `pick`. @default the card's position in the list */
      id?: number;
    }
    
    export interface GalleryScene {
      /** Vertical field of view in degrees. @default 30 */
      fov?: number;
      /** Camera position and target. The default camera sits on +z so that z = 0 maps 1:1 to CSS px. */
      camera?: { x?: number; y?: number; z?: number; targetX?: number; targetY?: number; targetZ?: number };
      /** World y of the reflection floor. */
      floorY?: number;
      /** Distance below the floor over which reflections fade out, px. @default 160 */
      reflectionFade?: number;
      /** Shadow offset (down) and blur in px. @default 14 and 22 */
      shadowOffset?: number;
      shadowBlur?: number;
      /** Shadow opacity at strength 1. @default 0.28 */
      shadowOpacity?: number;
      /** Fades everything toward the left/right and top/bottom edges, as a fraction of the size. */
      edgeFade?: readonly [number, number];
      /** Colors as [r, g, b] in 0–1. */
      placeholder?: readonly [number, number, number];
      paper?: readonly [number, number, number];
      ring?: readonly [number, number, number];
    }
    
    export interface PickResult {
      id: number;
      /** Hit point in the card's local space, 0–1 from the bottom-left corner. */
      u: number;
      v: number;
    }
    
    const FLOATS = 40;
    const KIND_CARD = 0;
    const KIND_SHADOW = 1;
    const KIND_REFLECTION = 2;
    
    const VERTEX = /* glsl */ `#version 300 es
    layout(location = 0) in vec2 a_corner;
    layout(location = 1) in mat4 a_model;
    layout(location = 5) in vec4 a_size;
    layout(location = 6) in vec4 a_look;
    layout(location = 7) in vec4 a_extra;
    layout(location = 8) in vec4 a_meta;
    layout(location = 9) in vec4 a_uv;
    layout(location = 10) in vec4 a_fill;
    uniform mat4 u_viewProj;
    uniform float u_floorY;
    uniform float u_shadowOffset;
    uniform float u_shadowBlur;
    out vec2 v_local;
    out vec2 v_uv;
    out float v_worldY;
    flat out vec4 v_size;
    flat out vec4 v_look;
    flat out vec4 v_extra;
    flat out vec4 v_meta;
    flat out vec4 v_uvRect;
    flat out vec4 v_fill;
    void main() {
      float kind = a_meta.x;
      bool shadow = kind > 0.5 && kind < 1.5;
      // Grow the quad past the card so edges antialias fully and shadows have room to blur.
      float margin = shadow ? u_shadowBlur * 2.0 : 2.0;
      vec2 local = a_corner * (a_size.xy + margin * 2.0);
      vec4 world = a_model * vec4(local, 0.0, 1.0);
      if (shadow) { world.y -= u_shadowOffset; world.z -= 0.5; }
      if (kind > 1.5) world.y = 2.0 * u_floorY - world.y;
      v_worldY = world.y;
      v_local = local;
      v_uv = local / a_size.xy + 0.5;
      v_size = a_size;
      v_look = a_look;
      v_extra = a_extra;
      v_meta = a_meta;
      v_uvRect = a_uv;
      v_fill = a_fill;
      gl_Position = u_viewProj * world;
    }`;
    
    const FRAGMENT = /* glsl */ `#version 300 es
    precision highp float;
    precision highp sampler2DArray;
    uniform sampler2DArray u_images;
    uniform vec2 u_resolution;
    uniform vec2 u_edgeFade;
    uniform vec3 u_placeholder;
    uniform vec3 u_paper;
    uniform vec3 u_ring;
    uniform float u_floorY;
    uniform float u_reflectFade;
    uniform float u_shadowBlur;
    uniform float u_shadowOpacity;
    in vec2 v_local;
    in vec2 v_uv;
    in float v_worldY;
    flat in vec4 v_size;   // width, height, radius, round
    flat in vec4 v_look;   // opacity, dim, tone, ring
    flat in vec4 v_extra;  // layer, loaded, border, shadow
    flat in vec4 v_meta;   // kind, reflection
    flat in vec4 v_uvRect; // u0, v0, u1, v1 inside the texture layer
    flat in vec4 v_fill;   // r, g, b, amount
    out vec4 fragColor;
    
    float roundRect(vec2 p, vec2 halfSize, float r) {
      vec2 q = abs(p) - halfSize + r;
      return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
    }
    
    void main() {
      vec2 halfSize = v_size.xy * 0.5;
      float maxRadius = min(halfSize.x, halfSize.y);
      float radius = mix(min(v_size.z, maxRadius), maxRadius, v_size.w);
      float d = roundRect(v_local, halfSize, radius);
      float kind = v_meta.x;
    
      vec2 screen = gl_FragCoord.xy / u_resolution;
      float fade = 1.0;
      if (u_edgeFade.x > 0.0) fade *= smoothstep(0.0, u_edgeFade.x, screen.x) * smoothstep(0.0, u_edgeFade.x, 1.0 - screen.x);
      if (u_edgeFade.y > 0.0) fade *= smoothstep(0.0, u_edgeFade.y, screen.y) * smoothstep(0.0, u_edgeFade.y, 1.0 - screen.y);
    
      if (kind > 0.5 && kind < 1.5) {
        float blur = max(u_shadowBlur, 1.0);
        float a = 1.0 - smoothstep(-blur, blur, d);
        a *= a * v_extra.w * u_shadowOpacity * v_look.x * fade;
        fragColor = vec4(0.0, 0.0, 0.0, a);
        return;
      }
    
      float aa = max(fwidth(d), 1e-3);
      float coverage = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, d);
      if (coverage <= 0.0) discard;
    
      float border = v_extra.z;
      vec2 inner = max(halfSize - border, vec2(1.0));
      bool paper = border > 0.0 && (abs(v_local.x) > inner.x || abs(v_local.y) > inner.y);
      vec3 color = u_placeholder;
      float texAlpha = 1.0;
      if (v_extra.x >= 0.0 && !paper) {
        vec2 uv = border > 0.0 ? v_local / (inner * 2.0) + 0.5 : v_uv;
        // Layers are premultiplied; transparent images (overlays, cut-outs) keep their alpha.
        vec4 texel = texture(u_images, vec3(mix(v_uvRect.xy, v_uvRect.zw, uv), v_extra.x));
        vec3 tex = texel.a > 0.001 ? texel.rgb / texel.a : vec3(0.0);
        color = mix(u_placeholder, tex, v_extra.y);
        texAlpha = mix(1.0, texel.a, v_extra.y);
      }
      if (paper) color = u_paper;
    
      float tone = v_look.z;
      if (tone < 1.0 && !paper) {
        float gray = dot(color, vec3(0.299, 0.587, 0.114));
        if (border > 0.0) {
          // A print develops out of its paper: blank, then faint monochrome, then full color.
          vec3 mono = mix(u_paper, vec3(gray), smoothstep(0.0, 0.6, tone));
          color = mix(mono, color, smoothstep(0.45, 1.0, tone));
        } else {
          color = mix(vec3(mix(0.55, gray, 0.5)), color, tone);
        }
      }
      color = mix(color, v_fill.rgb, v_fill.a);
      color *= 1.0 - v_look.y;
      if (v_look.w > 0.0) color = mix(color, u_ring, smoothstep(-2.0 - aa, -2.0, d) * v_look.w);
    
      float alpha = coverage * v_look.x * fade * texAlpha;
      if (kind > 1.5) {
        float below = u_floorY - v_worldY;
        alpha *= below < 0.0 ? 0.0 : v_meta.y * (1.0 - smoothstep(0.0, u_reflectFade, below));
      }
      fragColor = vec4(color * alpha, alpha);
    }`;
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) return null;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
      if (process.env.NODE_ENV !== "production") console.error(`GalleryRenderer: shader failed to compile\n${gl.getShaderInfoLog(shader)}`);
      gl.deleteShader(shader);
      return null;
    }
    
    function createProgram(gl: WebGL2RenderingContext) {
      const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
      const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
      const program = vertex && fragment ? gl.createProgram() : null;
      if (program && vertex && fragment) {
        gl.attachShader(program, vertex);
        gl.attachShader(program, fragment);
        gl.linkProgram(program);
      }
      if (vertex) gl.deleteShader(vertex);
      if (fragment) gl.deleteShader(fragment);
      if (program && gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
      if (program) gl.deleteProgram(program);
      return null;
    }
    
    interface TextureEntry {
      src: string;
      state: "loading" | "decoded" | "ready" | "error";
      image: HTMLImageElement | null;
      layer: number;
      aspect: number;
      /** Content rect inside the layer (images are fitted, not cropped): u0, v0, u1, v1. */
      content: [number, number, number, number];
      loadedAt: number;
      lastUsed: number;
    }
    
    /**
     * WebGL2 renderer for textured cards. Every card, its soft shadow and its floor reflection go
     * into one instanced draw over a texture array, sorted back to front for correct blending.
     * Images load once (crossOrigin "anonymous"), are fitted into fixed-size mipmapped layers and
     * recycled least-recently-used when there are more images than layers.
     */
    export class GalleryRenderer {
      readonly gl: WebGL2RenderingContext;
      readonly software: boolean;
      private program: WebGLProgram;
      private vao: WebGLVertexArrayObject;
      private quad: WebGLBuffer;
      private instances: WebGLBuffer;
      private texture: WebGLTexture;
      private uniforms: Record<string, WebGLUniformLocation | null> = {};
      private data = new Float32Array(FLOATS * 64);
      private capacity = 64;
      private entries = new Map<string, TextureEntry>();
      private freeLayers: number[] = [];
      private scratch: HTMLCanvasElement;
      private mipsDirty = false;
      private frame = 0;
      private images: string[] = [];
      private view = createMat4();
      private projection = createMat4();
      private viewProj = createMat4();
      private inverseViewProj = createMat4();
      private picks: { id: number; model: Mat4; width: number; height: number }[] = [];
      private models: Mat4[] = [];
      private cssWidth = 1;
      private cssHeight = 1;
      /** Called when an image finishes loading, so the owner can draw a new frame. */
      onImageLoad: () => void = () => {};
    
      private constructor(
        gl: WebGL2RenderingContext,
        readonly textureSize: number,
        readonly layers: number,
        software: boolean,
      ) {
        this.gl = gl;
        this.software = software;
        const program = createProgram(gl);
        const vao = gl.createVertexArray();
        const quad = gl.createBuffer();
        const instances = gl.createBuffer();
        const texture = gl.createTexture();
        if (!program || !vao || !quad || !instances || !texture) throw new Error("GalleryRenderer: WebGL setup failed");
        this.program = program;
        this.vao = vao;
        this.quad = quad;
        this.instances = instances;
        this.texture = texture;
        this.scratch = document.createElement("canvas");
        this.scratch.width = this.scratch.height = textureSize;
        this.setup();
      }
    
      /** Creates a renderer on `canvas`, or returns null when WebGL2 or the shaders are unavailable. */
      static create(canvas: HTMLCanvasElement, { textureSize = 512, layers = 32 }: { textureSize?: number; layers?: number } = {}) {
        const gl = canvas.getContext("webgl2", { alpha: true, antialias: false, depth: false, stencil: false, premultipliedAlpha: true });
        if (!gl) return null;
        const info = gl.getExtension("WEBGL_debug_renderer_info");
        const name = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
        const software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(name);
        const maxLayers = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number;
        try {
          return new GalleryRenderer(gl, textureSize, clamp(layers, 1, maxLayers), software);
        } catch {
          return null;
        }
      }
    
      private setup() {
        const { gl } = this;
        for (const name of ["u_viewProj", "u_floorY", "u_shadowOffset", "u_shadowBlur", "u_shadowOpacity", "u_images", "u_resolution", "u_edgeFade", "u_placeholder", "u_paper", "u_ring", "u_reflectFade"]) {
          this.uniforms[name] = gl.getUniformLocation(this.program, name);
        }
        gl.bindVertexArray(this.vao);
        gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]), gl.STATIC_DRAW);
        gl.enableVertexAttribArray(0);
        gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
        gl.bindBuffer(gl.ARRAY_BUFFER, this.instances);
        gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
        const stride = FLOATS * 4;
        // mat4 model (locations 1–4), then six vec4s (5–10).
        for (let i = 0; i < 10; i++) {
          const location = i + 1;
          gl.enableVertexAttribArray(location);
          gl.vertexAttribPointer(location, 4, gl.FLOAT, false, stride, i * 16);
          gl.vertexAttribDivisor(location, 1);
        }
        gl.bindVertexArray(null);
    
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
        const levels = Math.floor(Math.log2(this.textureSize)) + 1;
        gl.texStorage3D(gl.TEXTURE_2D_ARRAY, levels, gl.RGBA8, this.textureSize, this.textureSize, this.layers);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        const anisotropic = gl.getExtension("EXT_texture_filter_anisotropic");
        if (anisotropic) gl.texParameterf(gl.TEXTURE_2D_ARRAY, anisotropic.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(anisotropic.MAX_TEXTURE_MAX_ANISOTROPY_EXT) as number));
        this.freeLayers = Array.from({ length: this.layers }, (_, i) => i);
        this.entries.forEach((entry) => {
          entry.layer = -1;
          if (entry.state === "ready") entry.state = "decoded";
        });
      }
    
      /** Rebuilds GPU resources after a lost WebGL context is restored. Images re-upload from memory. */
      restore() {
        const { gl } = this;
        const vao = gl.createVertexArray();
        const quad = gl.createBuffer();
        const instances = gl.createBuffer();
        const texture = gl.createTexture();
        if (!vao || !quad || !instances || !texture) return false;
        this.vao = vao;
        this.quad = quad;
        this.instances = instances;
        this.texture = texture;
        const program = createProgram(gl);
        if (!program) return false;
        this.program = program;
        this.capacity = this.data.length / FLOATS;
        this.setup();
        return true;
      }
    
      /** Image URLs that cards refer to by index. Unchanged URLs keep their textures. */
      setImages(images: string[]) {
        this.images = images;
      }
    
      resize(cssWidth: number, cssHeight: number, dpr: number) {
        const canvas = this.gl.canvas as HTMLCanvasElement;
        const width = Math.max(1, Math.round(cssWidth * dpr));
        const height = Math.max(1, Math.round(cssHeight * dpr));
        if (canvas.width !== width || canvas.height !== height) {
          canvas.width = width;
          canvas.height = height;
        }
        this.cssWidth = Math.max(1, cssWidth);
        this.cssHeight = Math.max(1, cssHeight);
      }
    
      private request(index: number) {
        const src = this.images[index];
        if (!src) return null;
        let entry = this.entries.get(src);
        if (!entry) {
          const created: TextureEntry = { src, state: "loading", image: null, layer: -1, aspect: 1, content: [0, 0, 1, 1], loadedAt: 0, lastUsed: 0 };
          entry = created;
          this.entries.set(src, created);
          const image = new Image();
          image.crossOrigin = "anonymous";
          image.decoding = "async";
          image.src = src;
          image.decode().then(
            () => {
              created.image = image;
              created.aspect = image.naturalWidth / Math.max(1, image.naturalHeight);
              created.state = "decoded";
              this.onImageLoad();
            },
            () => {
              created.state = "error";
            },
          );
        }
        entry.lastUsed = this.frame;
        return entry;
      }
    
      private upload(entry: TextureEntry) {
        if (!entry.image) return;
        let layer = this.freeLayers.pop();
        if (layer === undefined) {
          // Recycle the least recently used layer that no card showed this frame.
          let oldest: TextureEntry | null = null;
          this.entries.forEach((candidate) => {
            if (candidate.layer >= 0 && candidate.lastUsed < this.frame && (!oldest || candidate.lastUsed < oldest.lastUsed)) oldest = candidate;
          });
          const victim = oldest as TextureEntry | null;
          if (!victim) return;
          layer = victim.layer;
          victim.layer = -1;
          victim.state = "decoded";
        }
        const size = this.textureSize;
        const ctx = this.scratch.getContext("2d");
        if (!ctx) return;
        const { naturalWidth: w, naturalHeight: h } = entry.image;
        const fit = Math.min(size / w, size / h);
        const dw = w * fit;
        const dh = h * fit;
        ctx.clearRect(0, 0, size, size);
        ctx.drawImage(entry.image, (size - dw) / 2, (size - dh) / 2, dw, dh);
        const { gl } = this;
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
        gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, size, size, 1, gl.RGBA, gl.UNSIGNED_BYTE, this.scratch);
        const u = dw / size / 2;
        const v = dh / size / 2;
        entry.content = [0.5 - u, 0.5 - v, 0.5 + u, 0.5 + v];
        entry.layer = layer;
        entry.state = "ready";
        if (!entry.loadedAt) entry.loadedAt = performance.now();
        this.mipsDirty = true;
      }
    
      /** Cover-fits a card onto its image's content rect, optionally panned. */
      private coverUv(entry: TextureEntry, width: number, height: number, panX: number, panY: number, out: Float32Array, offset: number) {
        const cardAspect = width / Math.max(1, height);
        let uSpan = 1;
        let vSpan = 1;
        if (cardAspect > entry.aspect) vSpan = entry.aspect / cardAspect;
        else uSpan = cardAspect / entry.aspect;
        const uc = 0.5 + (clamp(panX, -1, 1) * (1 - uSpan)) / 2;
        const vc = 0.5 + (clamp(panY, -1, 1) * (1 - vSpan)) / 2;
        const [u0, v0, u1, v1] = entry.content;
        out[offset] = u0 + (uc - uSpan / 2) * (u1 - u0);
        out[offset + 1] = v0 + (vc - vSpan / 2) * (v1 - v0);
        out[offset + 2] = u0 + (uc + uSpan / 2) * (u1 - u0);
        out[offset + 3] = v0 + (vc + vSpan / 2) * (v1 - v0);
      }
    
      private updateCamera(scene: GalleryScene) {
        const fov = ((scene.fov ?? 30) * Math.PI) / 180;
        const distance = this.cssHeight / 2 / Math.tan(fov / 2);
        const c = scene.camera ?? {};
        const eye = [c.x ?? 0, c.y ?? 0, distance + (c.z ?? 0)];
        lookAt(this.view, eye, [c.targetX ?? 0, c.targetY ?? 0, c.targetZ ?? 0]);
        perspective(this.projection, fov, this.cssWidth / this.cssHeight, Math.max(1, distance * 0.02), distance * 20);
        multiply(this.viewProj, this.projection, this.view);
        invert(this.inverseViewProj, this.viewProj);
      }
    
      /** Draws one frame. Returns true while any image is still fading in. */
      render(cards: readonly GalleryCard[], scene: GalleryScene = {}) {
        const { gl } = this;
        this.frame++;
        this.updateCamera(scene);
        const now = performance.now();
        let fading = false;
    
        // Resolve textures and upload anything that finished decoding.
        const textures: (TextureEntry | null)[] = cards.map((card) => (card.image >= 0 ? this.request(card.image) : null));
        for (const entry of textures) if (entry && entry.state === "decoded") this.upload(entry);
        if (this.mipsDirty) {
          gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
          gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
          this.mipsDirty = false;
        }
    
        // Back-to-front order by view-space depth.
        while (this.models.length < cards.length) this.models.push(createMat4());
        const order = cards.map((card, i) => {
          compose(this.models[i], card.x, card.y, card.z, card.rotateX ?? 0, card.rotateY ?? 0, card.rotateZ ?? 0, card.scale ?? 1, card.flip ?? 0);
          const depth = this.view[2] * card.x + this.view[6] * card.y + this.view[10] * card.z + this.view[14];
          return { i, depth };
        });
        order.sort((a, b) => a.depth - b.depth);
    
        const reflections = scene.floorY !== undefined ? order.filter(({ i }) => (cards[i].reflection ?? 0) > 0) : [];
        const needed = reflections.length + order.length * 2;
        if (needed > this.capacity) {
          this.capacity = Math.max(needed, this.capacity * 2);
          this.data = new Float32Array(this.capacity * FLOATS);
          gl.bindBuffer(gl.ARRAY_BUFFER, this.instances);
          gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
        }
    
        let count = 0;
        const write = (i: number, kind: number) => {
          const card = cards[i];
          const entry = textures[i];
          const o = count * FLOATS;
          const d = this.data;
          d.set(this.models[i], o);
          d[o + 16] = card.width;
          d[o + 17] = card.height;
          d[o + 18] = card.radius ?? 12;
          d[o + 19] = card.round ?? 0;
          d[o + 20] = card.opacity ?? 1;
          d[o + 21] = card.dim ?? 0;
          d[o + 22] = card.tone ?? 1;
          d[o + 23] = card.ring ?? 0;
          const ready = entry !== null && entry.state === "ready" && entry.layer >= 0;
          const loaded = ready ? clamp((now - entry.loadedAt) / 320, 0, 1) : 0;
          if (ready && loaded < 1) fading = true;
          d[o + 24] = ready ? entry.layer : -1;
          d[o + 25] = loaded;
          d[o + 26] = card.border ?? 0;
          d[o + 27] = card.shadow ?? 0;
          d[o + 28] = kind;
          d[o + 29] = card.reflection ?? 0;
          d[o + 30] = 0;
          d[o + 31] = 0;
          if (ready) this.coverUv(entry, card.width - (card.border ?? 0) * 2, card.height - (card.border ?? 0) * 2, card.panX ?? 0, card.panY ?? 0, d, o + 32);
          else d.fill(0, o + 32, o + 36);
          const fill = card.fill;
          d[o + 36] = fill ? fill[0] : 0;
          d[o + 37] = fill ? fill[1] : 0;
          d[o + 38] = fill ? fill[2] : 0;
          d[o + 39] = fill ? fill[3] : 0;
          count++;
        };
        for (const { i } of reflections) write(i, KIND_REFLECTION);
        this.picks.length = 0;
        for (const { i } of order) {
          const card = cards[i];
          if ((card.shadow ?? 0) > 0 && (card.opacity ?? 1) > 0) write(i, KIND_SHADOW);
          write(i, KIND_CARD);
          this.picks.push({ id: card.id ?? i, model: this.models[i], width: card.width, height: card.height });
        }
    
        const canvas = gl.canvas as HTMLCanvasElement;
        gl.viewport(0, 0, canvas.width, canvas.height);
        gl.clearColor(0, 0, 0, 0);
        gl.clear(gl.COLOR_BUFFER_BIT);
        if (count) {
          gl.enable(gl.BLEND);
          gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
          gl.useProgram(this.program);
          const u = this.uniforms;
          gl.uniformMatrix4fv(u.u_viewProj, false, this.viewProj);
          gl.uniform1f(u.u_floorY, scene.floorY ?? -1e6);
          gl.uniform1f(u.u_reflectFade, scene.reflectionFade ?? 160);
          gl.uniform1f(u.u_shadowOffset, scene.shadowOffset ?? 14);
          gl.uniform1f(u.u_shadowBlur, scene.shadowBlur ?? 22);
          gl.uniform1f(u.u_shadowOpacity, scene.shadowOpacity ?? 0.28);
          gl.uniform2f(u.u_resolution, canvas.width, canvas.height);
          gl.uniform2f(u.u_edgeFade, scene.edgeFade?.[0] ?? 0, scene.edgeFade?.[1] ?? 0);
          gl.uniform3fv(u.u_placeholder, scene.placeholder ?? [0.94, 0.94, 0.95]);
          gl.uniform3fv(u.u_paper, scene.paper ?? [0.98, 0.97, 0.95]);
          gl.uniform3fv(u.u_ring, scene.ring ?? [0.145, 0.388, 0.922]);
          gl.uniform1i(u.u_images, 0);
          gl.activeTexture(gl.TEXTURE0);
          gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
          gl.bindBuffer(gl.ARRAY_BUFFER, this.instances);
          gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.data, 0, count * FLOATS);
          gl.bindVertexArray(this.vao);
          gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
          gl.bindVertexArray(null);
        }
        return fading;
      }
    
      /** The front-most card under a point in CSS px (relative to the canvas), or null. */
      pick(x: number, y: number): PickResult | null {
        const nx = (x / this.cssWidth) * 2 - 1;
        const ny = 1 - (y / this.cssHeight) * 2;
        const near = transform(this.inverseViewProj, nx, ny, -1);
        const far = transform(this.inverseViewProj, nx, ny, 1);
        const origin = [near[0] / near[3], near[1] / near[3], near[2] / near[3]];
        const end = [far[0] / far[3], far[1] / far[3], far[2] / far[3]];
        const inverse = createMat4();
        for (let k = this.picks.length - 1; k >= 0; k--) {
          const { id, model, width, height } = this.picks[k];
          if (!invert(inverse, model)) continue;
          const o = transform(inverse, origin[0], origin[1], origin[2]);
          const e = transform(inverse, end[0], end[1], end[2]);
          const dz = e[2] - o[2];
          if (Math.abs(dz) < 1e-9) continue;
          const t = -o[2] / dz;
          if (t < 0 || t > 1) continue;
          const px = o[0] + (e[0] - o[0]) * t;
          const py = o[1] + (e[1] - o[1]) * t;
          if (Math.abs(px) <= width / 2 && Math.abs(py) <= height / 2) return { id, u: px / width + 0.5, v: py / height + 0.5 };
        }
        return null;
      }
    
      /** World point → CSS px relative to the canvas (for positioning DOM overlays). */
      project(x: number, y: number, z: number) {
        const [cx, cy, , cw] = transform(this.viewProj, x, y, z);
        return { x: ((cx / cw) * 0.5 + 0.5) * this.cssWidth, y: (0.5 - (cy / cw) * 0.5) * this.cssHeight, visible: cw > 0 };
      }
    
      /** True once the image at `index` is on the GPU. */
      isLoaded(index: number) {
        const src = this.images[index];
        return src ? this.entries.get(src)?.state === "ready" : false;
      }
    
      destroy() {
        const { gl } = this;
        gl.deleteBuffer(this.quad);
        gl.deleteBuffer(this.instances);
        gl.deleteVertexArray(this.vao);
        gl.deleteTexture(this.texture);
        gl.deleteProgram(this.program);
        this.entries.clear();
        gl.getExtension("WEBGL_lose_context")?.loseContext();
      }
    }
    lib/gallery/types.ts
    import type { CSSProperties, Ref } from "react";
    
    /** One image in a gallery. */
    export interface GalleryItem {
      src: string;
      /** Describes the image for screen readers and the fallback list. */
      alt: string;
      title?: string;
      subtitle?: string;
      /** Optional link, e.g. opened by onSelect. */
      href?: string;
    }
    
    /** Imperative handle shared by every gallery. */
    export interface GalleryHandle {
      next: () => void;
      previous: () => void;
      goTo: (index: number) => void;
    }
    
    /**
     * Props every gallery accepts. Each gallery copies these into its own props interface (with the
     * JSDoc and defaults that fit it) so its props table stays complete.
     */
    export interface GallerySharedProps {
      items: GalleryItem[];
      startIndex?: number;
      autoplay?: boolean;
      interval?: number;
      pauseOnHover?: boolean;
      draggable?: boolean;
      wheel?: boolean;
      captions?: boolean;
      controls?: boolean;
      backgroundColor?: string;
      paused?: boolean;
      dpr?: number;
      ariaLabel?: string;
      className?: string;
      style?: CSSProperties;
      onIndexChange?: (index: number) => void;
      onSelect?: (item: GalleryItem, index: number) => void;
      ref?: Ref<GalleryHandle>;
    }
    lib/gallery/use-gallery-input.ts
    import { useEffect, useMemo, useRef, useState } from "react";
    import { clamp, wrap } from "@/lib/gallery/math";
    
    export interface DragMove {
      /** Pointer movement since the previous move event, CSS px. */
      dx: number;
      dy: number;
      /** Pointer and drag-start positions relative to the element. */
      x: number;
      y: number;
      startX: number;
      startY: number;
      width: number;
      height: number;
    }
    
    export interface UseGalleryInputOptions {
      /** Number of items. */
      count: number;
      /** Wrap past the first and last item. @default false */
      loop?: boolean;
      /** Index shown first. @default 0 */
      startIndex?: number;
      /** Pointer drag with momentum. @default true */
      draggable?: boolean;
      /** One item per wheel notch. At the first or last item the page scrolls instead. @default true */
      wheel?: boolean;
      /** ← → (or ↑ ↓ on the y axis), Home, End, Enter, Escape and Space. @default true */
      keyboard?: boolean;
      /** Settle on the nearest item after a drag or throw. @default true */
      snap?: boolean;
      /** Seconds of glide a throw carries; 0 stops dead. @default 0.35 */
      inertia?: number;
      /** Drag and arrow-key axis. @default "x" */
      axis?: "x" | "y";
      /** Pixels of drag that move one item, or a function of the element's size. @default 200 */
      dragDistance?: number | ((width: number, height: number) => number);
      /** Custom drag mapping (e.g. turning a wheel around its center): returns the change in position, in items. */
      dragDelta?: (move: DragMove) => number;
      /** Step to the next item every `interval`. @default false */
      autoplay?: boolean;
      /** Autoplay interval in ms. @default 4000 */
      interval?: number;
      /** Pause autoplay while hovered. @default true */
      pauseOnHover?: boolean;
      /** Stops autoplay (input still works). @default false */
      paused?: boolean;
      /** Space toggles pause even without step autoplay, for galleries with continuous motion (drift, spin, streams). @default false */
      pausable?: boolean;
      /** Request a frame on every pointer move, e.g. for hover picking. @default false */
      trackPointer?: boolean;
      onIndexChange?: (index: number) => void;
      /** Enter on the gallery itself (not on a button inside it). */
      onSelect?: (index: number) => void;
      onEscape?: () => void;
      /** A press without a drag, in element coordinates. */
      onTap?: (point: { x: number; y: number }, input: Omit<GalleryInput, "index" | "autoplaying" | "userPaused">) => void;
      /** Called whenever the position changes so a renderer can draw. */
      invalidate?: () => void;
    }
    
    export interface GalleryInput {
      /** Pass as the gallery root's `ref`: the root receives pointer, wheel and key events. */
      bind: (element: HTMLElement | null) => void | (() => void);
      /** Active item (nearest to the current position). */
      index: number;
      /** Autoplay is on and not paused by the user or the `paused` prop. */
      autoplaying: boolean;
      /** The user paused motion (Space or `togglePause`). */
      userPaused: boolean;
      /** Current position in items (fractional while moving; unbounded when looping). */
      position: () => number;
      /** Active item right now (use in event handlers; `index` is the rendered value). */
      activeIndex: () => number;
      /** Velocity in items per second. */
      velocity: () => number;
      /** Advances the physics by dt seconds. Returns true while still moving. */
      update: (dt: number) => boolean;
      /** Moves the position by `delta` items with no spring (continuous drift). Ignored while a gesture or snap is in progress. */
      drift: (delta: number) => void;
      /** Drags from another control (e.g. a ruler): moves by `delta` items like a pointer drag. Finish with `scrubEnd()`. */
      scrub: (delta: number) => void;
      /** Ends a scrub: momentum, snapping and the quick-flick rule apply as for a drag. */
      scrubEnd: () => void;
      next: () => void;
      previous: () => void;
      goTo: (index: number, options?: { instant?: boolean }) => void;
      dragging: () => boolean;
      hovering: () => boolean;
      /** Last pointer position over the element. */
      pointer: () => { x: number; y: number; inside: boolean };
      /** True when autoplay or drift may run right now (not hovered, dragged, held, hidden or reduced). */
      canAutoplay: () => boolean;
      /** Holds autoplay for a reason, e.g. while an item is open. */
      hold: (reason: string, active: boolean) => void;
      togglePause: () => void;
    }
    
    const reducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    
    /**
     * One input model for every gallery: a continuous position (in items) driven by pointer drag with
     * momentum and snapping, wheel notches, the keyboard and autoplay. Physics runs in `update(dt)`,
     * which the gallery's render loop calls each frame, so nothing re-renders React mid-gesture.
     */
    export function useGalleryInput(options: UseGalleryInputOptions): GalleryInput {
      const latest = useRef(options);
      const startIndex = clamp(Math.round(options.startIndex ?? 0), 0, Math.max(0, options.count - 1));
      const [index, setIndex] = useState(startIndex);
      const [userPaused, setUserPaused] = useState(false);
      const restartAutoplay = useRef(() => {});
      const state = useRef({
        p: startIndex,
        raw: startIndex,
        v: 0,
        target: null as number | null,
        dragging: false,
        pending: false,
        pointerId: -1,
        startX: 0,
        startY: 0,
        startP: 0,
        downT: 0,
        lastX: 0,
        lastY: 0,
        samples: [] as { t: number; p: number }[],
        hovering: false,
        inside: false,
        px: 0,
        py: 0,
        wheelAcc: 0,
        wheelAt: 0,
        lastIndex: startIndex,
        suppressClick: false,
        holds: new Set<string>(),
        userPaused: false,
      });
    
      useEffect(() => {
        latest.current = options;
      });
    
      const api = useMemo(() => {
        const st = () => state.current;
        const max = () => Math.max(0, latest.current.count - 1);
        const kick = () => latest.current.invalidate?.();
    
        const sync = () => {
          const s = st();
          const { count, loop } = latest.current;
          if (count <= 0) return;
          const i = loop ? wrap(Math.round(s.p), count) : clamp(Math.round(s.p), 0, count - 1);
          if (i === s.lastIndex) return;
          s.lastIndex = i;
          setIndex(i);
          latest.current.onIndexChange?.(i);
        };
    
        const moveTo = (position: number, instant = false) => {
          const s = st();
          s.dragging = false;
          s.target = position;
          if (instant || reducedMotion()) {
            s.p = s.raw = position;
            s.v = 0;
            s.target = null;
          }
          sync();
          kick();
          restartAutoplay.current();
        };
    
        const goTo = (target: number, opts?: { instant?: boolean }) => {
          const { count, loop } = latest.current;
          if (!count) return;
          const s = st();
          const base = Math.round(s.target ?? s.p);
          if (loop) {
            let delta = wrap(Math.round(target) - wrap(base, count), count);
            if (delta > count / 2) delta -= count;
            moveTo(base + delta, opts?.instant);
          } else {
            moveTo(clamp(Math.round(target), 0, max()), opts?.instant);
          }
        };
    
        const step = (direction: number, cycle = false) => {
          const s = st();
          const base = Math.round(s.target ?? s.p);
          if (latest.current.loop) return moveTo(base + direction);
          const next = base + direction;
          if (cycle && next > max()) return moveTo(0);
          moveTo(clamp(next, 0, max()));
        };
    
        const canAutoplay = () => {
          const o = latest.current;
          const s = st();
          return (
            o.count > 1 &&
            !o.paused &&
            !s.userPaused &&
            !reducedMotion() &&
            !((o.pauseOnHover ?? true) && s.hovering) &&
            !s.dragging &&
            s.holds.size === 0 &&
            document.visibilityState === "visible"
          );
        };
    
        const update = (dt: number) => {
          const s = st();
          if (s.dragging) return true;
          if (s.target !== null) {
            if (reducedMotion()) {
              s.p = s.raw = s.target;
              s.v = 0;
              s.target = null;
              sync();
              return false;
            }
            // Critically damped spring, sub-stepped for stability on slow frames.
            const omega = 11;
            let remaining = dt;
            while (remaining > 0) {
              const h = Math.min(remaining, 1 / 120);
              s.v += (-omega * omega * (s.p - s.target) - 2 * omega * s.v) * h;
              s.p += s.v * h;
              remaining -= h;
            }
            if (Math.abs(s.p - s.target) < 0.0008 && Math.abs(s.v) < 0.01) {
              s.p = s.target;
              s.v = 0;
              s.target = null;
            }
            s.raw = s.p;
            sync();
            return s.target !== null;
          }
          if (s.v !== 0) {
            s.v *= Math.exp(-dt / Math.max(0.05, latest.current.inertia ?? 0.35));
            s.p += s.v * dt;
            if (!latest.current.loop && (s.p < 0 || s.p > max())) s.target = clamp(s.p, 0, max());
            if (Math.abs(s.v) < 0.02) s.v = 0;
            s.raw = s.p;
            sync();
            return s.v !== 0 || s.target !== null;
          }
          return false;
        };
    
        const release = () => {
          const s = st();
          s.dragging = false;
          const now = performance.now();
          const samples = s.samples;
          const lastSample = samples[samples.length - 1];
          let v = 0;
          // Only a pointer still moving at release throws. Use the last 150ms, but always at least the
          // last two samples: slow devices and coalesced events can space moves 80ms or more apart.
          if (lastSample && now - lastSample.t < 100) {
            let first = samples.find((sample) => now - sample.t < 150) ?? lastSample;
            if (first === lastSample && samples.length >= 2) first = samples[samples.length - 2];
            if (first !== lastSample) v = (lastSample.p - first.p) / Math.max(0.016, (lastSample.t - first.t) / 1000);
          }
          v = clamp(v, -30, 30);
          const { snap = true, inertia = 0.35, loop } = latest.current;
          if (snap) {
            let target = Math.round(s.p + v * inertia);
            const from = Math.round(s.startP);
            // A quick flick always moves at least one item: either it was still moving fast at release,
            // or the whole press was short and travelled a noticeable distance.
            const travel = s.p - s.startP;
            const quick = now - s.downT < 320 && Math.abs(travel) > 0.06;
            if (target === from && (Math.abs(v) > 0.6 || quick)) target = from + Math.sign(Math.abs(v) > 0.6 ? v : travel);
            s.target = loop ? target : clamp(target, 0, max());
          } else if (!loop && (s.p < 0 || s.p > max())) {
            s.target = clamp(s.p, 0, max());
          }
          s.v = v;
          if (reducedMotion()) {
            s.p = s.raw = s.target ?? s.p;
            s.v = 0;
            s.target = null;
          }
          sync();
          kick();
          restartAutoplay.current();
        };
    
        const togglePause = () => {
          const s = st();
          s.userPaused = !s.userPaused;
          setUserPaused(s.userPaused);
          restartAutoplay.current();
        };
    
        const bind = (element: HTMLElement | null) => {
          if (!element) return;
          const s = st();
          const local = (event: { clientX: number; clientY: number }) => {
            const rect = element.getBoundingClientRect();
            return { x: event.clientX - rect.left, y: event.clientY - rect.top, width: rect.width, height: rect.height };
          };
          const onDown = (event: PointerEvent) => {
            if (event.pointerType === "mouse" && event.button !== 0) return;
            if ((event.target as Element | null)?.closest?.("[data-gallery-control]")) return;
            const point = local(event);
            s.pending = true;
            s.pointerId = event.pointerId;
            s.startX = s.lastX = point.x;
            s.startY = s.lastY = point.y;
            s.downT = performance.now();
          };
          const onMove = (event: PointerEvent) => {
            const point = local(event);
            s.px = point.x;
            s.py = point.y;
            s.inside = true;
            if (latest.current.trackPointer) kick();
            if (!s.pending || event.pointerId !== s.pointerId) return;
            if (!s.dragging) {
              if (!(latest.current.draggable ?? true)) return;
              if (Math.hypot(point.x - s.startX, point.y - s.startY) < 6) return;
              // Capture only once the pointer really moves, so clicks on links and buttons still land.
              s.dragging = true;
              s.target = null;
              s.v = 0;
              s.startP = s.raw = s.p;
              // Measure from the press: a fast flick may arrive as a single coalesced move event, and its
              // full distance (and speed) must still count.
              s.samples = [{ t: s.downT, p: s.p }];
              s.lastX = s.startX;
              s.lastY = s.startY;
              try {
                element.setPointerCapture(event.pointerId);
              } catch {}
            }
            const move: DragMove = { dx: point.x - s.lastX, dy: point.y - s.lastY, x: point.x, y: point.y, startX: s.startX, startY: s.startY, width: point.width, height: point.height };
            const o = latest.current;
            let delta: number;
            if (o.dragDelta) delta = o.dragDelta(move);
            else {
              const distance = typeof o.dragDistance === "function" ? o.dragDistance(point.width, point.height) : (o.dragDistance ?? 200);
              delta = -((o.axis ?? "x") === "y" ? move.dy : move.dx) / Math.max(1, distance);
            }
            s.raw += delta;
            s.p = rubber(s.raw);
            s.lastX = point.x;
            s.lastY = point.y;
            s.samples.push({ t: performance.now(), p: s.p });
            if (s.samples.length > 12) s.samples.shift();
            sync();
            kick();
          };
          const onUp = (event: PointerEvent) => {
            if (event.pointerId !== s.pointerId) return;
            const wasDragging = s.dragging;
            s.pending = false;
            s.pointerId = -1;
            if (wasDragging) {
              s.suppressClick = true;
              window.setTimeout(() => {
                s.suppressClick = false;
              }, 0);
              try {
                element.releasePointerCapture(event.pointerId);
              } catch {}
              release();
            } else if (event.type === "pointerup") {
              latest.current.onTap?.(local(event), controls);
            }
          };
          const onClickCapture = (event: MouseEvent) => {
            if (!s.suppressClick) return;
            event.preventDefault();
            event.stopPropagation();
          };
          const onEnter = (event: PointerEvent) => {
            if (event.pointerType !== "mouse") return;
            s.hovering = true;
            kick();
          };
          const onLeave = () => {
            s.hovering = false;
            s.inside = false;
            kick();
            restartAutoplay.current();
          };
          const onWheel = (event: WheelEvent) => {
            if (!(latest.current.wheel ?? true)) return;
            const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientHeight : 1;
            const dx = event.deltaX * scale;
            const dy = event.deltaY * scale;
            const delta = Math.abs(dx) > Math.abs(dy) ? dx : dy;
            if (!delta) return;
            const direction = Math.sign(delta);
            const base = Math.round(s.target ?? s.p);
            // At either end, let the page scroll instead of trapping the wheel.
            if (!latest.current.loop && ((direction < 0 && base <= 0) || (direction > 0 && base >= max()))) {
              s.wheelAcc = 0;
              return;
            }
            event.preventDefault();
            s.wheelAcc += delta;
            const now = performance.now();
            const notch = event.deltaMode !== 0 || Math.abs(delta) >= 50;
            if ((notch || Math.abs(s.wheelAcc) >= 60) && now - s.wheelAt > 110) {
              s.wheelAt = now;
              s.wheelAcc = 0;
              step(direction);
            }
          };
          const onKeyDown = (event: KeyboardEvent) => {
            const o = latest.current;
            if (!(o.keyboard ?? true)) return;
            const target = event.target as HTMLElement;
            if (target.closest("input, textarea, select, [contenteditable='true']")) return;
            const vertical = (o.axis ?? "x") === "y";
            if (event.key === (vertical ? "ArrowDown" : "ArrowRight")) {
              event.preventDefault();
              step(1);
            } else if (event.key === (vertical ? "ArrowUp" : "ArrowLeft")) {
              event.preventDefault();
              step(-1);
            } else if (event.key === "Home") {
              event.preventDefault();
              goTo(0);
            } else if (event.key === "End") {
              event.preventDefault();
              goTo(max());
            } else if (event.key === "Enter") {
              // Buttons and links inside the gallery handle Enter themselves.
              if (target !== element && target.closest("button, a")) return;
              event.preventDefault();
              o.onSelect?.(s.lastIndex);
            } else if (event.key === "Escape") {
              o.onEscape?.();
            } else if (event.key === " " && (o.autoplay || o.pausable) && !target.closest("[data-gallery-control]")) {
              event.preventDefault();
              togglePause();
            }
          };
          const onKeyUp = (event: KeyboardEvent) => {
            const o = latest.current;
            if (event.key === " " && (o.autoplay || o.pausable) && (o.keyboard ?? true) && !(event.target as HTMLElement).closest("[data-gallery-control]")) event.preventDefault();
          };
          const onDragStart = (event: Event) => event.preventDefault();
    
          element.addEventListener("pointerdown", onDown);
          element.addEventListener("pointermove", onMove);
          element.addEventListener("pointerup", onUp);
          element.addEventListener("pointercancel", onUp);
          element.addEventListener("pointerenter", onEnter);
          element.addEventListener("pointerleave", onLeave);
          element.addEventListener("click", onClickCapture, true);
          element.addEventListener("wheel", onWheel, { passive: false });
          element.addEventListener("keydown", onKeyDown);
          element.addEventListener("keyup", onKeyUp);
          element.addEventListener("dragstart", onDragStart);
          return () => {
            element.removeEventListener("pointerdown", onDown);
            element.removeEventListener("pointermove", onMove);
            element.removeEventListener("pointerup", onUp);
            element.removeEventListener("pointercancel", onUp);
            element.removeEventListener("pointerenter", onEnter);
            element.removeEventListener("pointerleave", onLeave);
            element.removeEventListener("click", onClickCapture, true);
            element.removeEventListener("wheel", onWheel);
            element.removeEventListener("keydown", onKeyDown);
            element.removeEventListener("keyup", onKeyUp);
            element.removeEventListener("dragstart", onDragStart);
          };
        };
    
        const rubber = (value: number) => {
          if (latest.current.loop) return value;
          const limit = max();
          if (value < 0) return value * 0.35;
          if (value > limit) return limit + (value - limit) * 0.35;
          return value;
        };
    
        const scrub = (delta: number) => {
          const s = st();
          const now = performance.now();
          if (!s.dragging) {
            s.dragging = true;
            s.target = null;
            s.v = 0;
            s.startP = s.raw = s.p;
            s.downT = now;
            s.samples = [{ t: now, p: s.p }];
          }
          s.raw += delta;
          s.p = rubber(s.raw);
          s.samples.push({ t: now, p: s.p });
          if (s.samples.length > 12) s.samples.shift();
          sync();
          kick();
        };
    
        const scrubEnd = () => {
          if (st().dragging) release();
        };
    
        const drift = (delta: number) => {
          const s = st();
          if (s.dragging || s.pending || s.target !== null || s.v !== 0 || !delta) return;
          s.p = latest.current.loop ? s.p + delta : clamp(s.p + delta, 0, max());
          s.raw = s.p;
          sync();
        };
    
        const controls: Omit<GalleryInput, "index" | "autoplaying" | "userPaused"> = {
          bind,
          position: () => st().p,
          activeIndex: () => st().lastIndex,
          velocity: () => st().v,
          update,
          drift,
          scrub,
          scrubEnd,
          next: () => step(1),
          previous: () => step(-1),
          goTo,
          dragging: () => st().dragging,
          hovering: () => st().hovering,
          pointer: () => ({ x: st().px, y: st().py, inside: st().inside }),
          canAutoplay,
          hold: (reason: string, active: boolean) => {
            const s = st();
            if (active) s.holds.add(reason);
            else s.holds.delete(reason);
            restartAutoplay.current();
            kick();
          },
          togglePause,
        };
        // Autoplay cycles back to the start at the end of a non-looping gallery.
        return { controls, autoStep: () => step(1, true) };
      }, []);
    
      const { autoplay, interval } = options;
      useEffect(() => {
        if (!autoplay) return;
        const wait = Math.max(800, interval ?? 4000);
        let timer = 0;
        const tick = () => {
          timer = window.setTimeout(() => {
            if (api.controls.canAutoplay()) api.autoStep();
            tick();
          }, wait);
        };
        // Any manual move restarts the countdown so autoplay never jumps right after the user acts.
        restartAutoplay.current = () => {
          window.clearTimeout(timer);
          tick();
        };
        tick();
        return () => {
          window.clearTimeout(timer);
          restartAutoplay.current = () => {};
        };
      }, [autoplay, interval, api]);
    
      return { ...api.controls, index, userPaused, autoplaying: Boolean(autoplay) && !options.paused && !userPaused };
    }
    lib/gallery/use-gallery-renderer.ts
    import { useCallback, useEffect, useRef, useState } from "react";
    import { GalleryRenderer, type GalleryCard, type GalleryScene } from "@/lib/gallery/renderer";
    
    /**
     * - `idle`: not started yet (always the value on the server and the first client render)
     * - `ready`: WebGL is drawing
     * - `fallback`: WebGL2 is unavailable, the shaders failed or the context could not be restored
     */
    export type GalleryStatus = "idle" | "ready" | "fallback";
    
    export interface GalleryFrameState {
      /** Seconds since the renderer started (only advances while frames are drawn). */
      time: number;
      /** Seconds since the previous frame, capped at 0.1. */
      dt: number;
      /** Host size in CSS px. */
      width: number;
      height: number;
      reducedMotion: boolean;
      /**
       * Ambient motion (drift, autoplay) may run. False for all but one gallery preview on a page of
       * gallery cards: the others hold a still poster frame until they become the hovered or most
       * visible card. Input-driven motion always runs.
       */
      ambient: boolean;
      /** Scratch values that persist between frames (e.g. a drift velocity). */
      memory: Record<string, number>;
      renderer: GalleryRenderer;
    }
    
    export interface GalleryFrame {
      cards: readonly GalleryCard[];
      scene?: GalleryScene;
      /** Keep drawing frames. When false the loop sleeps until `invalidate()` is called. */
      animating?: boolean;
    }
    
    export interface UseGalleryRendererOptions {
      /** Image URLs; cards refer to them by index. */
      images: string[];
      /** Builds each frame's cards. Runs inside requestAnimationFrame, so it can read refs freely. */
      frame: (state: GalleryFrameState) => GalleryFrame;
      /** Highest device pixel ratio used (software WebGL is capped at 1). @default 2 */
      dpr?: number;
      /** Texture layer size in px. @default 512 */
      textureSize?: number;
      /** Texture layers kept on the GPU (least recently used are recycled). @default number of distinct images, up to 32 */
      maxTextures?: number;
    }
    
    interface PreviewLease {
      ratio: number;
      hovered: boolean;
      wake: () => void;
    }
    
    // Gallery previews (inside [data-preview-card], e.g. docs gallery cards) share one animation lease.
    const previews = new Set<PreviewLease>();
    let leader: PreviewLease | null = null;
    
    function electLeader() {
      let best: PreviewLease | null = null;
      for (const lease of previews) {
        if (lease.ratio <= 0) continue;
        if (!best || (lease.hovered && !best.hovered) || (lease.hovered === best.hovered && lease.ratio > best.ratio)) best = lease;
      }
      if (best === leader) return;
      const previous = leader;
      leader = best;
      previous?.wake();
      best?.wake();
    }
    
    /**
     * Runs a GalleryRenderer on a canvas the hook creates inside the element you attach `hostRef` to
     * (give it a size and `position: relative` or `absolute`). The loop only runs while the frame
     * reports `animating`, images fade in, or `invalidate()` is called, and never offscreen or in a
     * hidden tab. Context loss is recovered; everything is released on unmount.
     */
    export function useGalleryRenderer<T extends HTMLElement = HTMLDivElement>(options: UseGalleryRendererOptions) {
      const hostRef = useRef<T>(null);
      const rendererRef = useRef<GalleryRenderer | null>(null);
      const [status, setStatus] = useState<GalleryStatus>("idle");
      const latest = useRef(options);
      const wake = useRef<() => void>(() => {});
    
      useEffect(() => {
        latest.current = options;
        wake.current();
      });
    
      /** Requests a frame. Safe to call from any event handler, many times per frame. */
      const invalidate = useCallback(() => wake.current(), []);
    
      useEffect(() => {
        const host = hostRef.current;
        if (!host) return;
        const canvas = document.createElement("canvas");
        canvas.setAttribute("aria-hidden", "true");
        Object.assign(canvas.style, {
          position: "absolute",
          inset: "0",
          width: "100%",
          height: "100%",
          display: "block",
          pointerEvents: "none",
          opacity: "0",
          transition: "opacity 320ms ease-out",
        });
        host.appendChild(canvas);
    
        const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
        const previewCard = host.closest<HTMLElement>("[data-preview-card]");
        const lease: PreviewLease | null = previewCard ? { ratio: 0, hovered: false, wake: () => wakeUp() } : null;
        const memory: Record<string, number> = {};
        let renderer: GalleryRenderer | null = null;
        let raf = 0;
        let last = 0;
        let clock = 0;
        let inView = false;
        let pageVisible = document.visibilityState === "visible";
        let lost = false;
        let needsResize = true;
        let shown = false;
    
        const resize = () => {
          if (!renderer) return;
          const cap = renderer.software ? 1 : Math.min(latest.current.dpr ?? 2, lease ? 1.5 : Infinity);
          renderer.resize(host.clientWidth, host.clientHeight, Math.min(window.devicePixelRatio || 1, Math.max(0.5, cap)));
          needsResize = false;
        };
    
        const frame = (now: number) => {
          raf = 0;
          if (!renderer || lost) return;
          const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60;
          last = now;
          clock += dt;
          if (needsResize) resize();
          renderer.setImages(latest.current.images);
          const result = latest.current.frame({
            time: clock,
            dt,
            width: host.clientWidth,
            height: host.clientHeight,
            reducedMotion: reduced.matches,
            ambient: !lease || leader === lease,
            memory,
            renderer,
          });
          const fading = renderer.render(result.cards, result.scene);
          if (!shown) {
            shown = true;
            canvas.style.opacity = "1";
          }
          if ((result.animating || fading) && inView && pageVisible) raf = requestAnimationFrame(frame);
          else last = 0;
        };
    
        const wakeUp = () => {
          if (!raf && renderer && !lost && inView && pageVisible) raf = requestAnimationFrame(frame);
        };
    
        const intersection = new IntersectionObserver(
          ([entry]) => {
            inView = entry.isIntersecting;
            if (lease) {
              lease.ratio = entry.isIntersecting ? entry.intersectionRatio : 0;
              electLeader();
            }
            wakeUp();
          },
          { rootMargin: "100px", threshold: [0, 0.25, 0.5, 0.75, 1] },
        );
        const onPreviewEnter = () => {
          if (!lease) return;
          lease.hovered = true;
          electLeader();
        };
        const onPreviewLeave = () => {
          if (!lease) return;
          lease.hovered = false;
          electLeader();
        };
        const resizeObserver = new ResizeObserver(() => {
          needsResize = true;
          wakeUp();
        });
        const onVisibility = () => {
          pageVisible = document.visibilityState === "visible";
          wakeUp();
        };
        const onLost = (event: Event) => {
          event.preventDefault();
          lost = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
        };
        const onRestored = () => {
          if (renderer?.restore()) {
            lost = false;
            needsResize = true;
            wakeUp();
          } else {
            setStatus("fallback");
          }
        };
    
        // Start on the next frame: keeps WebGL setup out of the effect body and off the hydration task.
        const start = requestAnimationFrame(() => {
          const { textureSize = 512, maxTextures, images } = latest.current;
          // Previews are small: smaller textures keep several on one page light on GPU memory.
          renderer = GalleryRenderer.create(canvas, {
            textureSize: lease ? Math.min(textureSize, 256) : textureSize,
            layers: maxTextures ?? Math.min(32, Math.max(1, new Set(images).size)),
          });
          if (!renderer) {
            canvas.remove();
            setStatus("fallback");
            return;
          }
          rendererRef.current = renderer;
          renderer.onImageLoad = wakeUp;
          wake.current = wakeUp;
          canvas.addEventListener("webglcontextlost", onLost);
          canvas.addEventListener("webglcontextrestored", onRestored);
          document.addEventListener("visibilitychange", onVisibility);
          intersection.observe(host);
          resizeObserver.observe(host);
          if (lease && previewCard) {
            previews.add(lease);
            previewCard.addEventListener("pointerenter", onPreviewEnter);
            previewCard.addEventListener("pointerleave", onPreviewLeave);
          }
          setStatus("ready");
        });
    
        return () => {
          cancelAnimationFrame(start);
          if (raf) cancelAnimationFrame(raf);
          wake.current = () => {};
          intersection.disconnect();
          resizeObserver.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          canvas.removeEventListener("webglcontextlost", onLost);
          canvas.removeEventListener("webglcontextrestored", onRestored);
          if (lease && previewCard) {
            previews.delete(lease);
            previewCard.removeEventListener("pointerenter", onPreviewEnter);
            previewCard.removeEventListener("pointerleave", onPreviewLeave);
            if (leader === lease) leader = null;
            electLeader();
          }
          renderer?.destroy();
          rendererRef.current = null;
          canvas.remove();
        };
      }, []);
    
      return { hostRef, rendererRef, status, invalidate };
    }
    lib/gallery/use-gallery.ts
    import { useEffect, useRef } from "react";
    import { useGalleryInput, type GalleryInput, type UseGalleryInputOptions } from "@/lib/gallery/use-gallery-input";
    import { useGalleryRenderer, type GalleryFrame, type GalleryFrameState, type UseGalleryRendererOptions } from "@/lib/gallery/use-gallery-renderer";
    
    export interface UseGalleryOptions extends Omit<UseGalleryInputOptions, "invalidate">, Pick<UseGalleryRendererOptions, "images" | "dpr" | "textureSize" | "maxTextures"> {
      /** Builds each frame from the input state, e.g. card positions from `input.position()`. */
      frame: (state: GalleryFrameState, input: GalleryInput) => GalleryFrame;
    }
    
    /** The renderer and the input hook wired together: input changes request frames, frames read input. */
    export function useGallery<T extends HTMLElement = HTMLDivElement>({ images, dpr, textureSize, maxTextures, frame, ...inputOptions }: UseGalleryOptions) {
      const invalidateRef = useRef(() => {});
      const input = useGalleryInput({ ...inputOptions, invalidate: () => invalidateRef.current() });
      const renderer = useGalleryRenderer<T>({ images, dpr, textureSize, maxTextures, frame: (state) => frame(state, input) });
      const { invalidate } = renderer;
      useEffect(() => {
        invalidateRef.current = invalidate;
      }, [invalidate]);
      // `bind` is returned on its own: pass it straight to the root's ref (the compiler treats whatever
      // feeds a ref prop as a ref, so it must not be read off the input object during render).
      return { ...renderer, bind: input.bind, input };
    }
    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/darkroom-stream/darkroom-stream.tsx
    "use client";
    
    import { useCallback, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react";
    import { AnimatePresence, motion } from "motion/react";
    import { Pause, Play, X } from "lucide-react";
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { GalleryA11y } from "@/lib/gallery/gallery-a11y";
    import { chromeScheme } from "@/lib/gallery/gallery-chrome";
    import { GalleryFallback } from "@/lib/gallery/gallery-fallback";
    import { clamp, lerp, smoothstep, wrap } from "@/lib/gallery/math";
    import type { GalleryCard } from "@/lib/gallery/renderer";
    import type { GalleryHandle, GalleryItem } from "@/lib/gallery/types";
    import { useGallery } from "@/lib/gallery/use-gallery";
    
    export interface DarkroomStreamHandle extends GalleryHandle {
      /** Adds a burst of speed, in stream cycles per second (negative flies back). */
      boost: (amount: number) => void;
      /** Opens an item (default: the active one) enlarged in the center. */
      open: (index?: number) => void;
      /** Closes the open photo. */
      close: () => void;
    }
    
    export interface DarkroomStreamProps {
      /** Photos in the stream. */
      items: GalleryItem[];
      /** Content shown in the empty center, e.g. a headline. It fades while a photo is open. */
      children?: ReactNode;
      /** Photos in flight at once. @default 28 */
      count?: number;
      /** Item the keyboard starts on. @default 0 */
      startIndex?: number;
      /** Let the stream flow on its own. @default true */
      autoplay?: boolean;
      /** Flow speed; 1 takes a photo from the center to the edge in about 12 s, negative reverses the stream into the center. @default 1 */
      speed?: number;
      /** 0 is a calm, even zoom; 1 is true perspective that rushes near the edges. @default 0.6 */
      rush?: number;
      /** Curls the paths into a spiral, in half turns from center to edge (negative curls the other way). @default 0.35 */
      spiral?: number;
      /** Empty center for a headline, as a fraction of the smaller side. @default 0.34 */
      centerSpace?: number;
      /** Photo height as a fraction of the smaller side (at mid-flight). @default 0.2 */
      size?: number;
      /** Random size variation for depth, 0–1. @default 0.35 */
      sizeVariance?: number;
      /** Photo width ÷ height. @default 0.8 */
      aspect?: number;
      /** Photo corner radius, px. @default 6 */
      radius?: number;
      /** Develop each photo on a paper border, like a print. @default false */
      printBorder?: boolean;
      /** Paper color of the print border. @default "#F7F5F0" */
      paperColor?: string;
      /** Hairline light rim around each photo, 0–1. @default 0.5 */
      rim?: number;
      /** Random tilt like scattered prints, in degrees. @default 6 */
      scatterTilt?: number;
      /** Photos emerge monochrome and low contrast, then develop to full color. @default true */
      develop?: boolean;
      /** Fades the stream into the background toward the edges, as a fraction of the size. @default 0.12 */
      edgeFade?: number;
      /** Soft shadow strength, 0–1. @default 0.6 */
      shadow?: number;
      /** Hovering the stream slows it to this fraction of its speed. @default 0.3 */
      hoverSlow?: number;
      /** Hovering a photo catches it: it stops and lifts while the rest flow past. @default true */
      catchOnHover?: boolean;
      /** Pause hover slowing and catching. @default true */
      pauseOnHover?: boolean;
      /** Drag steers: away from the center flies forward, toward it flies back, around it rotates. @default true */
      draggable?: boolean;
      /** The wheel flies through the stream. @default true */
      wheel?: boolean;
      /** Caption and close button under an open photo. @default true */
      captions?: boolean;
      /** A pause/play button in the corner. @default true */
      controls?: boolean;
      /** Background color; text switches to light on dark colors. @default "transparent" */
      backgroundColor?: string;
      /** Stops the flow (input still works). @default false */
      paused?: boolean;
      /** Highest device pixel ratio rendered. @default 2 */
      dpr?: number;
      /** Accessible name of the stream. @default "Photos" */
      ariaLabel?: string;
      /** Classes for the root, which fills its parent (at least 420px tall). */
      className?: string;
      /** Inline styles for the root. */
      style?: CSSProperties;
      /** Fires when the keyboard's active item changes. */
      onIndexChange?: (index: number) => void;
      /** Fires when a photo opens. */
      onSelect?: (item: GalleryItem, index: number) => void;
      /** Fires when a photo opens. */
      onOpen?: (item: GalleryItem, index: number) => void;
      /** Fires when the open photo closes. */
      onClose?: () => void;
      /** Imperative `next()`, `previous()`, `goTo()`, `boost(amount)`, `open(index?)` and `close()`. */
      ref?: Ref<DarkroomStreamHandle>;
    }
    
    /** Deterministic 0–1 hash of a photo slot, its cycle and a channel. */
    function hash(slot: number, cycle: number, channel: number) {
      let h = Math.imul(slot + 1, 374761393) ^ Math.imul(cycle + 7919, 668265263) ^ Math.imul(channel + 1, 2246822519);
      h = Math.imul(h ^ (h >>> 13), 1274126177);
      h ^= h >>> 16;
      return (h >>> 0) / 4294967296;
    }
    
    function toRgb(color: string): [number, number, number] {
      const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
      if (!hex) return [0.97, 0.96, 0.94];
      const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
      return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number];
    }
    
    type OpenState = { slot: number; image: number } | null;
    
    /**
     * Photos stream outward from an empty center toward the viewer, developing like darkroom prints
     * as they travel. Rendered with the shared WebGL2 gallery engine; a hidden button list carries the
     * accessible gallery.
     */
    export function DarkroomStream({
      items,
      children,
      count = 28,
      startIndex = 0,
      autoplay = true,
      speed = 1,
      rush = 0.6,
      spiral = 0.35,
      centerSpace = 0.34,
      size = 0.2,
      sizeVariance = 0.35,
      aspect = 0.8,
      radius = 6,
      printBorder = false,
      paperColor = "#F7F5F0",
      rim = 0.5,
      scatterTilt = 6,
      develop = true,
      edgeFade = 0.12,
      shadow = 0.6,
      hoverSlow = 0.3,
      catchOnHover = true,
      pauseOnHover = true,
      draggable = true,
      wheel = true,
      captions = true,
      controls = true,
      backgroundColor = "transparent",
      paused = false,
      dpr = 2,
      ariaLabel = "Photos",
      className,
      style,
      onIndexChange,
      onSelect,
      onOpen,
      onClose,
      ref,
    }: DarkroomStreamProps) {
      const slots = clamp(Math.round(count), 4, 160);
      const [open, setOpen] = useState<OpenState>(null);
      const scheme = chromeScheme(backgroundColor);
      const paper = useMemo(() => toRgb(paperColor), [paperColor]);
      // Shared between pointer handlers and the render loop (never read during render).
      const motionState = useRef({ angle: 0, angleV: 0, lastMove: 0, boost: 0, slotImages: [] as number[] });
    
      const openItem = useCallback(
        (next: OpenState) => {
          if (next) {
            const item = items[next.image];
            if (!item) return;
            setOpen(next);
            onOpen?.(item, next.image);
            onSelect?.(item, next.image);
          } else if (open) {
            setOpen(null);
            onClose?.();
          }
        },
        [items, open, onOpen, onSelect, onClose],
      );
    
      const { hostRef, bind, status, input, rendererRef } = useGallery({
        images: items.map((item) => item.src),
        count: items.length,
        loop: true,
        startIndex,
        draggable,
        wheel,
        snap: false,
        inertia: 0.6,
        pauseOnHover,
        pausable: autoplay,
        paused,
        dpr,
        trackPointer: catchOnHover,
        dragDelta: (move) => {
          const cx = move.width / 2;
          const cy = move.height / 2;
          const rx = move.x - cx;
          const ry = move.y - cy;
          const length = Math.hypot(rx, ry) || 1;
          const ux = rx / length;
          const uy = ry / length;
          // Around the center rotates the stream (with momentum); along the radius flies through it.
          const tangential = move.dx * -uy + move.dy * ux;
          const turn = -tangential / Math.max(60, length);
          const state = motionState.current;
          const now = performance.now();
          const elapsed = Math.max(0.008, (now - (state.lastMove || now - 16)) / 1000);
          state.lastMove = now;
          state.angle += turn;
          state.angleV = clamp(turn / elapsed, -6, 6);
          const radial = move.dx * ux + move.dy * uy;
          return radial / Math.max(20, Math.min(move.width, move.height) * 0.05);
        },
        onIndexChange,
        onSelect: (index) => openItem({ slot: -1, image: index }),
        onEscape: () => openItem(null),
        onTap: (point) => {
          if (open) return openItem(null);
          const hit = rendererRef.current?.pick(point.x, point.y);
          if (!hit || hit.id < 0) return;
          const image = motionState.current.slotImages[hit.id];
          if (image !== undefined) openItem({ slot: hit.id, image });
        },
        frame: ({ dt, width, height, reducedMotion, ambient, memory, renderer }, gallery) => {
          const state = motionState.current;
          let moving = false;
          // Flow speed eases toward its target: hover slows it, an open photo almost stops it.
          let rate = autoplay && !paused && !gallery.userPaused && ambient && !reducedMotion ? speed / 12 : 0;
          if (pauseOnHover && gallery.hovering()) rate *= clamp(hoverSlow, 0, 1);
          if (open) rate *= 0.12;
          memory.rate = (memory.rate ?? 0) + (rate - (memory.rate ?? 0)) * (1 - Math.exp(-dt * 3));
          if (rate === 0 && Math.abs(memory.rate) < 0.0015) memory.rate = 0;
          memory.boostV = (memory.boostV ?? 0) + state.boost;
          state.boost = 0;
          memory.flow = (memory.flow ?? 0) + (memory.rate + memory.boostV) * dt;
          memory.boostV *= Math.exp(-dt * 1.6);
          if (Math.abs(memory.boostV) < 1e-4) memory.boostV = 0;
          if (memory.rate || memory.boostV) moving = true;
          if (gallery.update(dt)) moving = true;
          if (!gallery.dragging() && state.angleV) {
            state.angle += state.angleV * dt;
            state.angleV *= Math.exp(-dt / 0.7);
            if (Math.abs(state.angleV) < 0.002) state.angleV = 0;
            moving = true;
          }
          const travel = memory.flow + gallery.position() / slots;
    
          // Open photo spring (0 = in the stream, 1 = enlarged in the center).
          const openTarget = open ? 1 : 0;
          if (open) memory.openSlot = open.slot;
          memory.openT ??= 0;
          if (reducedMotion) memory.openT = openTarget;
          else {
            const h = Math.min(dt, 1 / 30);
            memory.openV = (memory.openV ?? 0) + (-120 * (memory.openT - openTarget) - 22 * (memory.openV ?? 0)) * h;
            memory.openT += memory.openV * h;
            if (Math.abs(memory.openT - openTarget) < 0.001 && Math.abs(memory.openV) < 0.01) {
              memory.openT = openTarget;
              memory.openV = 0;
            } else moving = true;
          }
          const openT = smoothstep(0, 1, memory.openT);
          const openSlot = open ? open.slot : memory.openT > 0.001 ? (memory.openSlot ?? -1) : -1;
    
          // Hovering a photo catches it; an open photo stays caught until it is back in the stream.
          const pointer = gallery.pointer();
          const hit = catchOnHover && pauseOnHover && pointer.inside && !gallery.dragging() && openSlot < 0 ? renderer.pick(pointer.x, pointer.y) : null;
          const caught = openSlot >= 0 ? openSlot : hit && hit.id >= 0 ? hit.id : -1;
          if ((memory.caught ?? -1) !== caught) {
            const previous = memory.caught ?? -1;
            if (previous >= 0) {
              // Release: shift this photo's phase so it carries on exactly where it stopped.
              memory[`d${previous}`] = (memory.caughtCycle ?? 0) + (memory.caughtS ?? 0) - travel - previous / slots;
            }
            if (caught >= 0) {
              const raw = travel + caught / slots + (memory[`d${caught}`] ?? 0);
              memory.caughtCycle = Math.floor(raw);
              memory.caughtS = raw - Math.floor(raw);
            }
            memory.caught = caught;
          }
    
          const minSide = Math.min(width, height);
          const inner = clamp(centerSpace, 0, 0.9) * minSide * 0.5;
          const outer = Math.hypot(width, height) / 2 + minSide * size;
          const photoHeight = clamp(size, 0.05, 0.6) * minSide;
          const photoWidth = photoHeight * clamp(aspect, 0.3, 3);
          const border = printBorder ? photoHeight * 0.055 : 0;
          const perspective = (s: number) => (1 / (1 - s * 0.86) - 1) / (1 / 0.14 - 1);
          const cards: GalleryCard[] = [];
          const itemsCount = items.length || 1;
          for (let slot = 0; slot < slots; slot++) {
            const raw = slot === caught ? (memory.caughtCycle ?? 0) + (memory.caughtS ?? 0) : travel + slot / slots + (memory[`d${slot}`] ?? 0);
            const cycle = Math.floor(raw);
            const s = raw - cycle;
            const u = lerp(s, perspective(s), clamp(rush, 0, 1));
            const image = wrap(slot + cycle * slots, itemsCount);
            state.slotImages[slot] = image;
            const phi = hash(slot, cycle, 0) * Math.PI * 2 + spiral * u * Math.PI + state.angle;
            const distance = inner + (outer - inner) * u;
            const variance = 1 + (hash(slot, cycle, 1) - 0.5) * 2 * clamp(sizeVariance, 0, 0.9);
            const tilt = ((hash(slot, cycle, 2) - 0.5) * 2 * scatterTilt * Math.PI) / 180;
            const fade = smoothstep(0, 0.1, s) * (1 - smoothstep(0.9, 1, u));
            const card: GalleryCard = {
              image,
              id: slot,
              x: Math.cos(phi) * distance,
              y: Math.sin(phi) * distance,
              z: u * 40,
              rotateZ: tilt,
              scale: (0.16 + 1.1 * u) * variance,
              width: photoWidth,
              height: photoHeight,
              radius,
              border,
              opacity: fade,
              tone: develop ? smoothstep(0.06, 0.45, u) : 1,
              ring: clamp(rim, 0, 1) * 0.5,
              shadow: clamp(shadow, 0, 1) * fade,
              dim: openT * 0.45,
            };
            if (slot === caught && slot !== openSlot) {
              // Caught: it stops and lifts while the rest flow past.
              card.scale = (card.scale ?? 1) * 1.12;
              card.z = 120;
              card.tone = 1;
              card.opacity = 1;
              card.shadow = 1;
            }
            if (slot === openSlot) {
              const target = Math.min((height * 0.62) / photoHeight, (width * 0.82) / photoWidth);
              card.x = lerp(card.x, 0, openT);
              card.y = lerp(card.y, height * 0.05, openT);
              card.rotateZ = lerp(tilt, 0, openT);
              card.scale = lerp(card.scale ?? 1, target, openT);
              card.z = 200;
              card.tone = lerp(card.tone ?? 1, 1, openT);
              card.opacity = Math.max(card.opacity ?? 1, openT);
              card.shadow = 1;
              card.dim = 0;
            }
            cards.push(card);
          }
          // Opened from the keyboard or the list: the photo rises from the center.
          if (open && open.slot < 0) {
            const target = Math.min((height * 0.62) / photoHeight, (width * 0.82) / photoWidth);
            cards.push({
              image: open.image,
              id: -1,
              x: 0,
              y: height * 0.05,
              z: 200,
              scale: lerp(0.3, target, openT),
              width: photoWidth,
              height: photoHeight,
              radius,
              border,
              opacity: openT,
              shadow: openT,
              ring: clamp(rim, 0, 1) * 0.5,
            });
          }
          return {
            cards,
            animating: moving,
            scene: {
              fov: 30,
              edgeFade: [clamp(edgeFade, 0, 0.5), clamp(edgeFade, 0, 0.5)],
              paper,
              ring: [1, 1, 1],
              shadowOffset: 10,
              shadowBlur: 18,
              shadowOpacity: 0.24,
            },
          };
        },
      });
    
      const { next, previous, goTo, togglePause, userPaused } = input;
      useImperativeHandle(
        ref,
        () => ({
          next,
          previous,
          goTo: (index: number) => goTo(index),
          boost: (amount: number) => {
            motionState.current.boost += amount;
          },
          open: (index?: number) => openItem({ slot: -1, image: wrap(index ?? input.activeIndex(), items.length || 1) }),
          close: () => openItem(null),
        }),
        [next, previous, goTo, openItem, input, items.length],
      );
    
      const fallback = status === "fallback";
      const openedItem = open ? items[open.image] : undefined;
      const flowing = autoplay && !paused && !userPaused;
      return (
        <div
          ref={bind}
          role="group"
          aria-roledescription="carousel"
          aria-label={ariaLabel}
          // Focusable by click (not by Tab) so Escape and arrow keys work after pointer use.
          tabIndex={-1}
          className={cn("relative outline-none size-full min-h-[420px] touch-pan-y overflow-hidden rounded-[inherit] select-none [container-type:size] has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", className)}
          style={{ backgroundColor, ...style }}
        >
          <div ref={hostRef} className="absolute inset-0">
            {fallback ? <GalleryFallback items={items} label={ariaLabel} aspect={String(aspect)} onSelect={onSelect} className="absolute inset-0" /> : null}
          </div>
          {children && !fallback ? (
            <div className={cn("pointer-events-none absolute inset-0 grid place-items-center transition-opacity duration-300 motion-reduce:transition-none", open ? "opacity-0" : "opacity-100")}>
              <div data-gallery-control inert={Boolean(open)} className={cn("max-w-[44cqmin] text-center", !open && "pointer-events-auto")}>
                {children}
              </div>
            </div>
          ) : null}
          {fallback ? null : (
            <GalleryA11y items={items} index={input.index} label={ariaLabel} live={!flowing} onActivate={(i) => openItem({ slot: -1, image: i })} />
          )}
          <AnimatePresence>
            {captions && openedItem && !fallback ? (
              <motion.div
                key={open?.image}
                data-gallery-control
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 4 }}
                transition={{ duration: duration.base, ease: ease.out }}
                className={cn(
                  "absolute bottom-4 left-1/2 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-3 rounded-full border py-1.5 pr-1.5 pl-4 shadow-lg backdrop-blur",
                  scheme === "dark" ? "border-white/15 bg-zinc-900/80 text-white" : "border-border bg-background/90 text-foreground",
                )}
              >
                <p className="min-w-0 truncate text-sm">
                  <span className="font-medium">{openedItem.title ?? openedItem.alt}</span>
                  {openedItem.subtitle ? <span className={scheme === "dark" ? "text-white/65" : "text-muted-foreground"}> · {openedItem.subtitle}</span> : null}
                </p>
                <button
                  type="button"
                  aria-label="Close photo"
                  onClick={() => openItem(null)}
                  className={cn("relative after:absolute after:-inset-1 grid size-8 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring", scheme === "dark" ? "hover:bg-white/10" : "hover:bg-surface-2")}
                >
                  <X className="size-4" aria-hidden="true" />
                </button>
              </motion.div>
            ) : null}
          </AnimatePresence>
          {controls && autoplay && !fallback ? (
            <button
              type="button"
              data-gallery-control
              aria-label={flowing ? "Pause stream" : "Play stream"}
              aria-pressed={!flowing}
              onClick={togglePause}
              className={cn(
                "after:absolute after:-inset-0.5 absolute right-3 bottom-3 grid size-9 cursor-pointer place-items-center rounded-full border outline-none transition-opacity focus-visible:ring-2 focus-visible:ring-ring",
                open ? "pointer-events-none opacity-0" : "opacity-100",
                scheme === "dark" ? "border-white/15 bg-zinc-900/70 text-white hover:bg-zinc-800" : "border-border bg-background/85 text-foreground hover:bg-surface-2",
              )}
            >
              {flowing ? <Pause className="size-3.5 fill-current" aria-hidden="true" /> : <Play className="ml-0.5 size-3.5 fill-current" aria-hidden="true" />}
            </button>
          ) : null}
        </div>
      );
    }

Usage

import { DarkroomStream } from "@/components/ui/darkroom-stream/darkroom-stream";

<div className="h-[32rem]">
  <DarkroomStream items={photos} printBorder onOpen={(item) => console.log(item.title)}>
    <h2 className="text-2xl font-semibold">Every frame develops on the way out</h2>
  </DarkroomStream>
</div>

API reference

PropTypeDefaultDescription
items*GalleryItem[]–Photos in the stream.
childrenReactNode–Content shown in the empty center, e.g. a headline. It fades while a photo is open.
countnumber28Photos in flight at once.
startIndexnumber0Item the keyboard starts on.
autoplaybooleantrueLet the stream flow on its own.
speednumber1Flow speed; 1 takes a photo from the center to the edge in about 12 s, negative reverses the stream into the center.
rushnumber0.60 is a calm, even zoom; 1 is true perspective that rushes near the edges.
spiralnumber0.35Curls the paths into a spiral, in half turns from center to edge (negative curls the other way).
centerSpacenumber0.34Empty center for a headline, as a fraction of the smaller side.
sizenumber0.2Photo height as a fraction of the smaller side (at mid-flight).
sizeVariancenumber0.35Random size variation for depth, 0–1.
aspectnumber0.8Photo width ÷ height.
radiusnumber6Photo corner radius, px.
printBorderbooleanfalseDevelop each photo on a paper border, like a print.
paperColorstring"#F7F5F0"Paper color of the print border.
rimnumber0.5Hairline light rim around each photo, 0–1.
scatterTiltnumber6Random tilt like scattered prints, in degrees.
developbooleantruePhotos emerge monochrome and low contrast, then develop to full color.
edgeFadenumber0.12Fades the stream into the background toward the edges, as a fraction of the size.
shadownumber0.6Soft shadow strength, 0–1.
hoverSlownumber0.3Hovering the stream slows it to this fraction of its speed.
catchOnHoverbooleantrueHovering a photo catches it: it stops and lifts while the rest flow past.
pauseOnHoverbooleantruePause hover slowing and catching.
draggablebooleantrueDrag steers: away from the center flies forward, toward it flies back, around it rotates.
wheelbooleantrueThe wheel flies through the stream.
captionsbooleantrueCaption and close button under an open photo.
controlsbooleantrueA pause/play button in the corner.
backgroundColorstring"transparent"Background color; text switches to light on dark colors.
pausedbooleanfalseStops the flow (input still works).
dprnumber2Highest device pixel ratio rendered.
ariaLabelstring"Photos"Accessible name of the stream.
classNamestring–Classes for the root, which fills its parent (at least 420px tall).
styleCSSProperties–Inline styles for the root.
onIndexChange(index: number) => void–Fires when the keyboard's active item changes.
onSelect(item: GalleryItem, index: number) => void–Fires when a photo opens.
onOpen(item: GalleryItem, index: number) => void–Fires when a photo opens.
onClose() => void–Fires when the open photo closes.
refRef<DarkroomStreamHandle>–Imperative `next()`, `previous()`, `goTo()`, `boost(amount)`, `open(index?)` and `close()`.
  • Each photo slot follows one shared travel value, so the whole stream costs a few arithmetic operations per photo per frame. When a photo leaves the screen its slot respawns with a new angle, size and image from a deterministic hash, so the stream never repeats in an obvious pattern and needs no per-photo state.
  • rush blends a linear zoom with a perspective curve (1 / (1 − 0.86s)), so 0 drifts evenly and 1 rushes past the edges. spiral curls the paths, and a negative speed runs the stream backwards into the center.
  • Develop is done in the shader: photos start monochrome and low contrast and reach full color by mid-flight. With printBorder they develop out of the blank paper instead, the way a print appears in the tray.
  • A caught photo freezes its phase and lifts; when released, its phase is shifted so it carries on exactly where it stopped, with the same image. Drags steer: along the radius flies through the stream, around the center rotates it, both with momentum.

Accessibility

  • The canvas is decorative (aria-hidden). A visually hidden list of buttons, one per photo, opens any photo enlarged with its caption; Escape or the caption's close button puts it back.
  • Space or the corner button pauses the stream (the button reports its state with aria-pressed). Arrow keys and the wheel fly through the stream; the live region stays quiet while it flows on its own.
  • The headline in the center is real content and becomes inert while a photo is open, so focus can't land on it behind the photo.
  • With reduced motion the stream stands still and moves only when you drag, scroll or use the keys; opening a photo jumps straight to the end.
  • Without WebGL the photos render as a plain, swipeable scroll-snap list.