{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gallery-coverflow-caustic",
  "title": "Gallery Coverflow Caustic",
  "description": "3D coverflow gallery whose frosted-glass cards run a live caustic-light simulation: drifting light pools refract across each pane, and drag velocity fringes the focused card's edges with chromatic aberration that settles to zero at rest.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/gallery-coverflow-caustic/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// CausticCoverflow — 3D coverflow gallery whose cards sit behind frosted\n// glass running a lightweight caustic-light simulation. Three drifting light\n// pools (Lissajous paths, 7s/11s/13s periods) refract across each pane —\n// drawn into a quarter-resolution buffer and upscaled with image smoothing\n// for soft pool edges. Drag velocity drives chromatic aberration on the\n// focused card's border (red/blue channel restrokes) that decays to zero at\n// rest under a forced-settle deadline. Direct-DOM rAF: React renders once,\n// the loop is the sole writer of transforms and canvas frames.\n// ---------------------------------------------------------------------------\n\ntype RGB = { r: number; g: number; b: number };\n\nfunction parseColor(raw: string): RGB | null {\n  const v = raw.trim();\n  if (v.startsWith(\"#\")) {\n    const hex = v.slice(1);\n    if (hex.length === 3) {\n      const r = parseInt(hex[0] + hex[0], 16);\n      const g = parseInt(hex[1] + hex[1], 16);\n      const b = parseInt(hex[2] + hex[2], 16);\n      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;\n      return { r, g, b };\n    }\n    if (hex.length >= 6) {\n      const r = parseInt(hex.slice(0, 2), 16);\n      const g = parseInt(hex.slice(2, 4), 16);\n      const b = parseInt(hex.slice(4, 6), 16);\n      if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null;\n      return { r, g, b };\n    }\n    return null;\n  }\n  const m = v.match(/rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)/i);\n  if (!m) return null;\n  return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };\n}\n\nfunction mixRGB(a: RGB, b: RGB, t: number): RGB {\n  return {\n    r: a.r + (b.r - a.r) * t,\n    g: a.g + (b.g - a.g) * t,\n    b: a.b + (b.b - a.b) * t,\n  };\n}\n\n// deterministic 2-octave value noise for the generated thumbs — no deps\nfunction hash2(x: number, y: number) {\n  const n = Math.sin(x * 127.1 + y * 311.7) * 43758.5453123;\n  return n - Math.floor(n);\n}\nfunction vnoise(x: number, y: number) {\n  const xi = Math.floor(x);\n  const yi = Math.floor(y);\n  const xf = x - xi;\n  const yf = y - yi;\n  const u = xf * xf * (3 - 2 * xf);\n  const v = yf * yf * (3 - 2 * yf);\n  const a = hash2(xi, yi);\n  const b = hash2(xi + 1, yi);\n  const c = hash2(xi, yi + 1);\n  const d = hash2(xi + 1, yi + 1);\n  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;\n}\nfunction noise2(x: number, y: number) {\n  return 0.62 * vnoise(x, y) + 0.38 * vnoise(x * 2.3 + 19.7, y * 2.3 + 7.3);\n}\n\nfunction clamp(v: number, lo: number, hi: number) {\n  return Math.min(hi, Math.max(lo, v));\n}\nfunction easeOutExpo(t: number) {\n  return t >= 1 ? 1 : 1 - Math.pow(2, -10 * t);\n}\nfunction rrectPath(\n  ctx: CanvasRenderingContext2D,\n  x: number,\n  y: number,\n  w: number,\n  h: number,\n  r: number\n) {\n  ctx.beginPath();\n  ctx.moveTo(x + r, y);\n  ctx.arcTo(x + w, y, x + w, y + h, r);\n  ctx.arcTo(x + w, y + h, x, y + h, r);\n  ctx.arcTo(x, y + h, x, y, r);\n  ctx.arcTo(x, y, x + w, y, r);\n  ctx.closePath();\n}\n\n// coverflow pose for a card at signed index distance d from the focus point:\n// center card scale 1, sides rotateY ±35° / scale 0.82, x-spacing in px,\n// translateZ -60px per index of distance. Offsets from the stage center —\n// never absolute coords (the -50%,-50% anchor does the centering).\nfunction cardTransform(d: number, spacing: number) {\n  const a = clamp(d, -1, 1);\n  const rot = -35 * a;\n  const scale = 1 - 0.18 * Math.min(1, Math.abs(d));\n  const dx = d * spacing;\n  const z = -60 * Math.abs(d);\n  return `translate(-50%, -50%) translateX(${dx.toFixed(2)}px) translateZ(${z.toFixed(\n    1\n  )}px) rotateY(${rot.toFixed(2)}deg) scale(${scale.toFixed(4)})`;\n}\n\nexport type CausticCoverflowItem = {\n  /** card title, Geist Sans */\n  title: string;\n  /** mono meta line (date + index); auto-numbered when omitted */\n  meta?: string;\n  /** seed for the generated abstract thumb + caustic phases */\n  seed?: number;\n};\n\nconst DEFAULT_ITEMS: CausticCoverflowItem[] = [\n  { title: \"Meltwater channel, dawn\", meta: \"JAN 07 · 01\" },\n  { title: \"Condensation study\", meta: \"JAN 19 · 02\" },\n  { title: \"Caustic net, tank 4\", meta: \"FEB 02 · 03\" },\n  { title: \"Refraction drift\", meta: \"FEB 21 · 04\" },\n  { title: \"Surface tension map\", meta: \"MAR 05 · 05\" },\n  { title: \"Foam decay series\", meta: \"MAR 18 · 06\" },\n  { title: \"Light pool census\", meta: \"APR 02 · 07\" },\n];\n\n// Lissajous pools: periods 7s / 11s / 13s, incommensurate x/y ratios so the\n// paths never close, foreground-ink alphas in the 0.05–0.10 band\nconst POOLS = [\n  { T: 7, rx: 1.0, ry: 1.31, a: 0.1 },\n  { T: 11, rx: 0.83, ry: 1.0, a: 0.07 },\n  { T: 13, rx: 1.19, ry: 0.71, a: 0.05 },\n] as const;\n\nexport function CausticCoverflow({\n  items = DEFAULT_ITEMS,\n  initialIndex,\n  cardWidth = 264,\n  cardHeight = 330,\n  className = \"\",\n  \"aria-label\": ariaLabel = \"Coverflow gallery\",\n}: {\n  /** the gallery images, in order */\n  items?: CausticCoverflowItem[];\n  /** starting focused card; defaults to the middle */\n  initialIndex?: number;\n  /** card width in px (shrinks responsively on narrow containers) */\n  cardWidth?: number;\n  /** card height in px */\n  cardHeight?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n  /** accessible name for the gallery. Default \"Coverflow gallery\". */\n  \"aria-label\"?: string;\n}) {\n  const n = Math.max(1, items.length);\n  const start = clamp(initialIndex ?? Math.floor((n - 1) / 2), 0, n - 1);\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const stageRef = useRef<HTMLDivElement>(null);\n  const liveRef = useRef<HTMLSpanElement>(null);\n  const cardRefs = useRef<(HTMLDivElement | null)[]>([]);\n  const thumbRefs = useRef<(HTMLCanvasElement | null)[]>([]);\n  const fxRefs = useRef<(HTMLCanvasElement | null)[]>([]);\n  const dotRefs = useRef<(HTMLButtonElement | null)[]>([]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const stage = stageRef.current;\n    if (!root || !stage) return;\n    const cardsRaw = cardRefs.current.slice(0, n);\n    const fxRaw = fxRefs.current.slice(0, n);\n    const thumbRaw = thumbRefs.current.slice(0, n);\n    if (\n      cardsRaw.length < n ||\n      fxRaw.length < n ||\n      thumbRaw.length < n ||\n      cardsRaw.includes(null) ||\n      fxRaw.includes(null) ||\n      thumbRaw.includes(null)\n    )\n      return;\n    const cards = cardsRaw as HTMLDivElement[];\n    const fxs = fxRaw as HTMLCanvasElement[];\n    const thumbs = thumbRaw as HTMLCanvasElement[];\n\n    const reduced = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const dpr = Math.min(2, window.devicePixelRatio || 1);\n    const seedOf = (i: number) => items[i]?.seed ?? i * 7 + 3;\n\n    // --- theme tokens: every canvas ink derives from these, never hardcoded\n    let fg: RGB = { r: 237, g: 237, b: 237 };\n    let mut: RGB = { r: 143, g: 143, b: 143 };\n    let brd: RGB = { r: 46, g: 46, b: 46 };\n    let srf: RGB = { r: 23, g: 23, b: 23 };\n    let dark = true;\n    const deriveTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      fg = parseColor(cs.getPropertyValue(\"--foreground\")) ?? fg;\n      mut = parseColor(cs.getPropertyValue(\"--ns-muted\")) ?? mut;\n      brd = parseColor(cs.getPropertyValue(\"--border\")) ?? brd;\n      srf = parseColor(cs.getPropertyValue(\"--surface\")) ?? srf;\n      const bg = parseColor(cs.getPropertyValue(\"--background\")) ?? srf;\n      dark = 0.2126 * bg.r + 0.7152 * bg.g + 0.0722 * bg.b < 128;\n    };\n    deriveTokens();\n\n    // --- geometry (responsive: cards shrink on narrow containers)\n    let cw = cardWidth;\n    let ch = cardHeight;\n    let spacing = cw * 0.46;\n    let sized = false;\n    // quarter-resolution offscreen buffer shared by all cards, upscaled with\n    // imageSmoothing for soft pool edges\n    const qc = document.createElement(\"canvas\");\n    const qctx = qc.getContext(\"2d\");\n    // tiny buffer for the generated thumbs\n    const tiny = document.createElement(\"canvas\");\n    const tctx = tiny.getContext(\"2d\");\n    if (!qctx || !tctx) return;\n\n    const layout = () => {\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 40 || rect.height < 10) {\n        sized = false; // zero-size guard — never draw into a collapsed stage\n        return;\n      }\n      sized = true;\n      cw = Math.max(140, Math.round(Math.min(cardWidth, rect.width * 0.58)));\n      ch = Math.round(cw * (cardHeight / cardWidth));\n      spacing = cw * 0.46;\n      stage.style.height = `${ch + 48}px`;\n      qc.width = Math.max(1, Math.round(cw / 4));\n      qc.height = Math.max(1, Math.round(ch / 4));\n      for (let i = 0; i < n; i++) {\n        cards[i].style.width = `${cw}px`;\n        cards[i].style.height = `${ch}px`;\n        // gotcha: a <canvas> is a replaced element — CSS inset does not size\n        // it. Explicit style.width/height + dpr backing store, always.\n        for (const c of [fxs[i], thumbs[i]]) {\n          c.style.width = `${cw}px`;\n          c.style.height = `${ch}px`;\n          c.width = Math.max(1, Math.round(cw * dpr));\n          c.height = Math.max(1, Math.round(ch * dpr));\n        }\n      }\n    };\n\n    // --- generated abstract thumbs: token-gray value-noise fields, one per\n    // seed, redrawn per theme (no external images)\n    const paintThumb = (i: number) => {\n      if (!sized) return;\n      const th = thumbs[i];\n      const ctx = th.getContext(\"2d\");\n      if (!ctx) return;\n      const seed = seedOf(i);\n      const gw = 26;\n      const gh = Math.max(8, Math.round((gw * ch) / cw));\n      tiny.width = gw;\n      tiny.height = gh;\n      const img = tctx.createImageData(gw, gh);\n      const data = img.data;\n      for (let y = 0; y < gh; y++) {\n        for (let x = 0; x < gw; x++) {\n          const v = clamp(\n            noise2(x * 0.33 + seed * 13.7, y * 0.33 + seed * 5.1),\n            0,\n            1\n          );\n          let c: RGB;\n          if (v < 0.62) c = mixRGB(srf, brd, v / 0.62);\n          else if (v < 0.88) c = mixRGB(brd, mut, (v - 0.62) / 0.26);\n          else c = mixRGB(mut, fg, ((v - 0.88) / 0.12) * 0.55);\n          // sink toward surface near the footer so the caption stays legible\n          const settle = (y / gh) * 0.45;\n          c = mixRGB(c, srf, settle);\n          const o = (y * gw + x) * 4;\n          data[o] = Math.round(c.r);\n          data[o + 1] = Math.round(c.g);\n          data[o + 2] = Math.round(c.b);\n          data[o + 3] = 255;\n        }\n      }\n      tctx.putImageData(img, 0, 0);\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.imageSmoothingEnabled = true;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.clearRect(0, 0, th.width, th.height);\n      ctx.drawImage(tiny, 0, 0, gw, gh, 0, 0, th.width, th.height);\n    };\n    const paintThumbs = () => {\n      for (let i = 0; i < n; i++) paintThumb(i);\n    };\n\n    // --- caustics: 3 radial light pools on Lissajous paths, drawn into the\n    // quarter-res buffer on a fully cleared frame (no alpha accumulation),\n    // then upscaled onto the card's overlay canvas\n    const drawCaustics = (i: number, timeSec: number, amp: number) => {\n      if (!sized) return;\n      const fx = fxs[i];\n      const ctx = fx.getContext(\"2d\");\n      if (!ctx) return;\n      const qw = qc.width;\n      const qh = qc.height;\n      qctx.setTransform(1, 0, 0, 1, 0, 0);\n      qctx.clearRect(0, 0, qw, qh);\n      const seed = seedOf(i);\n      for (let j = 0; j < POOLS.length; j++) {\n        const p = POOLS[j];\n        const ph = seed * 1.7 + j * 2.399;\n        const x =\n          qw * 0.5 +\n          qw * 0.3 * amp * Math.sin(((timeSec / p.T) * 2 * Math.PI) * p.rx + ph);\n        const y =\n          qh * 0.5 +\n          qh *\n            0.3 *\n            amp *\n            Math.sin(((timeSec / p.T) * 2 * Math.PI) * p.ry + ph * 1.83);\n        const r = Math.max(2, qw * 0.4); // 40% of card width, quarter space\n        const g = qctx.createRadialGradient(x, y, 0, x, y, r);\n        g.addColorStop(0, `rgba(${fg.r},${fg.g},${fg.b},${p.a})`);\n        g.addColorStop(1, `rgba(${fg.r},${fg.g},${fg.b},0)`);\n        qctx.fillStyle = g;\n        qctx.fillRect(0, 0, qw, qh);\n      }\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, cw, ch);\n      ctx.imageSmoothingEnabled = true;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.drawImage(qc, 0, 0, qw, qh, 0, 0, cw, ch);\n    };\n\n    // static frame for cards outside the live trio (and for reduced motion):\n    // one fixed-phase caustic frame at half amplitude\n    const paintStatic = () => {\n      for (let i = 0; i < n; i++) drawCaustics(i, seedOf(i) * 0.37, 0.5);\n    };\n\n    // --- chromatic aberration: the focused card's border restroked twice\n    // with horizontal offsets ±k in red/blue channel inks split from the\n    // foreground token (fg.r → red pass, fg.b → blue pass). Additive\n    // 'lighter' on dark themes; source-over fringes on light. Drawn onto the\n    // freshly cleared caustic frame each tick — never accumulates.\n    const drawAberration = (i: number, k: number) => {\n      if (!sized) return;\n      const ctx = fxs[i].getContext(\"2d\");\n      if (!ctx) return;\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      const alpha = clamp(0.18 + (k / 6) * 0.55, 0, 0.85);\n      ctx.lineWidth = 1.25;\n      ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n      // fixed minimum-saturation floor on the split channel: a pure\n      // channel-split off a near-black --foreground (light theme) degrades\n      // to a gray smudge, not a color, without a floor to keep it visible\n      const redV = Math.max(fg.r, 190);\n      const blueV = Math.max(fg.b, 190);\n      ctx.save();\n      ctx.translate(-k, 0);\n      rrectPath(ctx, 1, 1, cw - 2, ch - 2, 11);\n      ctx.strokeStyle = `rgba(${redV},0,0,${alpha})`;\n      ctx.stroke();\n      ctx.restore();\n      ctx.save();\n      ctx.translate(k, 0);\n      rrectPath(ctx, 1, 1, cw - 2, ch - 2, 11);\n      ctx.strokeStyle = `rgba(0,0,${blueV},${alpha})`;\n      ctx.stroke();\n      ctx.restore();\n      ctx.globalCompositeOperation = \"source-over\";\n    };\n\n    // --- motion state (refs/locals only — never React state on hot paths)\n    let pos = start;\n    let prevPos = start;\n    let vel = 0; // idx/s\n    let effVel = 0; // smoothed, drives aberration\n    let mode: \"idle\" | \"drag\" | \"momentum\" | \"spring\" | \"tween\" = \"idle\";\n    let springStart = 0;\n    let springTarget = start;\n    let tweenFrom = 0;\n    let tweenTarget = 0;\n    let tweenStart = 0;\n    let tweenDur = 250;\n    let aberK = 0;\n    let aberDeadline = 0;\n    let lastWindow: number[] = [];\n    let ambient = 0.5; // pool amplitude scale: 0.5 idle ↔ 1 interacting\n    let raf = 0;\n    let running = false;\n    let last = 0;\n    let lastCaustic = 0;\n    let intersecting = true;\n    let activeShown = -1;\n\n    const applyTransforms = () => {\n      for (let i = 0; i < n; i++) {\n        const d = i - pos;\n        cards[i].style.transform = cardTransform(d, spacing);\n        // depth sort via explicit z-index (no preserve-3d intersection risk)\n        cards[i].style.zIndex = String(200 - Math.round(Math.abs(d) * 10));\n      }\n    };\n\n    const syncActive = () => {\n      const a = Math.round(clamp(pos, 0, n - 1));\n      if (a === activeShown) return;\n      activeShown = a;\n      const dots = dotRefs.current;\n      for (let i = 0; i < n; i++) {\n        const dot = dots[i];\n        if (dot) dot.dataset.active = i === a ? \"true\" : \"false\";\n      }\n      if (liveRef.current)\n        liveRef.current.textContent = `${items[a]?.title ?? \"\"}, item ${\n          a + 1\n        } of ${n}`;\n    };\n\n    const settledNow = () => mode === \"idle\" && aberK < 0.02;\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(0.05, last === 0 ? 1 / 60 : (now - last) / 1000);\n      last = now;\n\n      if (mode === \"momentum\") {\n        vel *= Math.pow(0.92, dt * 60); // friction 0.92/frame, fps-normalized\n        pos += vel * dt;\n        if (pos < 0) {\n          pos += (0 - pos) * Math.min(1, dt * 10);\n          vel *= 0.8;\n        } else if (pos > n - 1) {\n          pos += (n - 1 - pos) * Math.min(1, dt * 10);\n          vel *= 0.8;\n        }\n        if (Math.abs(vel) < 0.05) {\n          mode = \"spring\";\n          springTarget = Math.round(clamp(pos, 0, n - 1));\n          springStart = now;\n        }\n      } else if (mode === \"spring\") {\n        const kk = 170;\n        const cc = 2 * Math.sqrt(kk) * 0.95;\n        vel += (-kk * (pos - springTarget) - cc * vel) * dt;\n        pos += vel * dt;\n        // forced-settle deadline: a flick can never oscillate past 900ms\n        if (\n          (Math.abs(pos - springTarget) < 0.0015 && Math.abs(vel) < 0.02) ||\n          now - springStart > 900\n        ) {\n          pos = springTarget;\n          vel = 0;\n          mode = \"idle\";\n        }\n      } else if (mode === \"tween\") {\n        const t = Math.min(1, (now - tweenStart) / tweenDur);\n        pos = tweenFrom + (tweenTarget - tweenFrom) * easeOutExpo(t);\n        if (t >= 1) {\n          pos = tweenTarget;\n          mode = \"idle\";\n        }\n      }\n      // mode === \"drag\": pointermove writes pos directly between frames\n\n      // posChanged must be read before prevPos updates — a spring/tween that\n      // just force-settled flipped mode to \"idle\" this frame, but its final\n      // pos snap still needs one applyTransforms pass\n      const posChanged = pos !== prevPos;\n      const inst = (pos - prevPos) / Math.max(dt, 1e-4);\n      prevPos = pos;\n      effVel += (inst - effVel) * 0.4;\n\n      // aberration: k = min(6, |angular velocity| * 0.04), angular velocity\n      // of the focused card = 35 deg per index of travel; exponential decay\n      // at rest (τ=100ms → settles < 500ms) under a 900ms hard deadline\n      const moving = mode !== \"idle\" || posChanged;\n      if (moving) {\n        aberK = Math.min(6, Math.abs(effVel) * 35 * 0.04);\n        if (aberK > 0.05) aberDeadline = now + 900;\n      } else {\n        aberK *= Math.exp(-dt * 10);\n        if (aberK < 0.03 || now > aberDeadline) aberK = 0;\n      }\n\n      // idle pools drift at half amplitude — ambient is the default look\n      const ambTarget = moving ? 1 : 0.5;\n      ambient += (ambTarget - ambient) * Math.min(1, dt * 4);\n\n      if (sized) {\n        if (moving) applyTransforms();\n        syncActive();\n        const a = activeShown;\n        // full-rate while interacting or aberrating; 30fps cap at rest\n        const draw = moving || aberK >= 0.02 || now - lastCaustic >= 33;\n        if (draw) {\n          lastCaustic = now;\n          const t = now / 1000;\n          // only the focused card and its two neighbors run live caustics\n          const winStart = Math.max(0, a - 1);\n          const winEnd = Math.min(n - 1, a + 1);\n          // unconditionally wipe any index the live window left behind —\n          // e.g. a fast multi-card navigate tween where `a` jumps 2+ cards\n          // in one frame — regardless of aberK's decay state, so a stale\n          // aberration fringe can never strand on an unattended card\n          for (const idx of lastWindow) {\n            if (idx < winStart || idx > winEnd) drawCaustics(idx, t, ambient);\n          }\n          lastWindow = [];\n          for (let i = winStart; i <= winEnd; i++) {\n            drawCaustics(i, t, ambient);\n            lastWindow.push(i);\n          }\n          if (aberK >= 0.02) drawAberration(a, aberK);\n        }\n      }\n\n      // sleep policy: offscreen → stop; settled AND tab hidden → full sleep;\n      // otherwise the ambient 30fps drift keeps running\n      if (!intersecting || (settledNow() && document.hidden)) {\n        running = false;\n        return;\n      }\n      raf = requestAnimationFrame(frame);\n    };\n\n    const wake = () => {\n      if (reduced || running || !intersecting) return;\n      running = true;\n      last = 0;\n      raf = requestAnimationFrame(frame);\n    };\n\n    const navigate = (target: number, dur: number) => {\n      const t = clamp(Math.round(target), 0, n - 1);\n      if (reduced) {\n        // reduced motion: instant 200ms CSS ease, no momentum, no aberration\n        pos = t;\n        vel = 0;\n        applyTransforms();\n        syncActive();\n        return;\n      }\n      tweenFrom = pos;\n      tweenTarget = t;\n      tweenStart = performance.now();\n      tweenDur = dur;\n      mode = \"tween\";\n      wake();\n    };\n\n    // --- pointer: drag scrubs the index continuously, release applies\n    // momentum, taps center the tapped card\n    let dragging = false;\n    let dragMoved = 0;\n    let dragTotal = 0;\n    let lastX = 0;\n    let lastT = 0;\n    let dragVel = 0;\n    const onDown = (e: PointerEvent) => {\n      if (e.pointerType === \"mouse\" && e.button !== 0) return;\n      dragging = true;\n      dragMoved = 0;\n      dragTotal = 0;\n      lastX = e.clientX;\n      lastT = performance.now();\n      dragVel = 0;\n      if (!reduced) {\n        mode = \"drag\";\n        vel = 0;\n        stage.style.cursor = \"grabbing\";\n        wake();\n      }\n      try {\n        stage.setPointerCapture(e.pointerId);\n      } catch {\n        /* capture unsupported */\n      }\n    };\n    const onMove = (e: PointerEvent) => {\n      if (!dragging) return;\n      const now = performance.now();\n      const dx = e.clientX - lastX;\n      lastX = e.clientX;\n      dragMoved += Math.abs(dx);\n      dragTotal += dx;\n      if (reduced) return;\n      const dtm = Math.max(4, now - lastT) / 1000;\n      lastT = now;\n      let next = pos - dx / spacing;\n      if (next < 0) next *= 0.3; // rubber band past the ends\n      else if (next > n - 1) next = n - 1 + (next - (n - 1)) * 0.3;\n      dragVel += ((next - pos) / dtm - dragVel) * 0.35;\n      pos = next;\n    };\n    const onUp = (e: PointerEvent) => {\n      if (!dragging) return;\n      dragging = false;\n      stage.style.cursor = \"grab\";\n      try {\n        stage.releasePointerCapture(e.pointerId);\n      } catch {\n        /* already released */\n      }\n      if (dragMoved < 5) {\n        // click: center the card under the pointer\n        const rect = stage.getBoundingClientRect();\n        const rel = (e.clientX - rect.left - rect.width / 2) / spacing;\n        navigate(pos + rel, 300);\n        return;\n      }\n      if (reduced) {\n        if (Math.abs(dragTotal) > 30)\n          navigate(Math.round(pos) - Math.sign(dragTotal), 200);\n        return;\n      }\n      vel = clamp(dragVel, -8, 8);\n      mode = \"momentum\";\n      wake();\n    };\n    const onCancel = () => {\n      if (!dragging) return;\n      dragging = false;\n      stage.style.cursor = \"grab\";\n      if (!reduced) {\n        vel = clamp(dragVel, -8, 8);\n        mode = \"momentum\";\n        wake();\n      }\n    };\n\n    // --- wheel steps one card (250ms ease-out-expo), locked per step\n    let wheelLock = 0;\n    const onWheel = (e: WheelEvent) => {\n      const delta =\n        Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;\n      if (delta === 0) return;\n      e.preventDefault();\n      const now = performance.now();\n      if (now < wheelLock) return;\n      wheelLock = now + 260;\n      navigate(Math.round(pos) + (delta > 0 ? 1 : -1), 250);\n    };\n\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"ArrowLeft\") {\n        e.preventDefault();\n        navigate(Math.round(pos) - 1, 250);\n      } else if (e.key === \"ArrowRight\") {\n        e.preventDefault();\n        navigate(Math.round(pos) + 1, 250);\n      }\n    };\n\n    const dotCleanups: (() => void)[] = [];\n    dotRefs.current.slice(0, n).forEach((dot, i) => {\n      if (!dot) return;\n      const h = () => navigate(i, 300);\n      dot.addEventListener(\"click\", h);\n      dotCleanups.push(() => dot.removeEventListener(\"click\", h));\n    });\n\n    // --- observers\n    const io = new IntersectionObserver((entries) => {\n      intersecting = entries[0]?.isIntersecting ?? true;\n      if (intersecting) wake();\n    });\n    io.observe(root);\n\n    const ro = new ResizeObserver(() => {\n      const wasSized = sized;\n      layout();\n      if (!sized) return;\n      paintThumbs();\n      paintStatic();\n      applyTransforms();\n      if (!wasSized) syncActive();\n      wake();\n    });\n    ro.observe(root);\n\n    // live theme re-derive: regenerate thumbs and static frames per theme;\n    // the live trio re-inks on the next frame\n    const mo = new MutationObserver(() => {\n      deriveTokens();\n      paintThumbs();\n      paintStatic();\n      wake();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    const onVis = () => {\n      if (!document.hidden) wake();\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    if (reduced) {\n      for (const c of cards) c.style.transition = \"transform 200ms ease\";\n    }\n    stage.addEventListener(\"pointerdown\", onDown);\n    stage.addEventListener(\"pointermove\", onMove);\n    stage.addEventListener(\"pointerup\", onUp);\n    stage.addEventListener(\"pointercancel\", onCancel);\n    stage.addEventListener(\"wheel\", onWheel, { passive: false });\n    stage.addEventListener(\"keydown\", onKey);\n\n    // initial paint\n    layout();\n    paintThumbs();\n    paintStatic();\n    applyTransforms();\n    syncActive();\n    wake();\n\n    return () => {\n      cancelAnimationFrame(raf);\n      running = false;\n      io.disconnect();\n      ro.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n      stage.removeEventListener(\"pointerdown\", onDown);\n      stage.removeEventListener(\"pointermove\", onMove);\n      stage.removeEventListener(\"pointerup\", onUp);\n      stage.removeEventListener(\"pointercancel\", onCancel);\n      stage.removeEventListener(\"wheel\", onWheel);\n      stage.removeEventListener(\"keydown\", onKey);\n      for (const c of dotCleanups) c();\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [items, n, start, cardWidth, cardHeight]);\n\n  return (\n    <div ref={rootRef} className={`w-full ${className}`}>\n      <div\n        ref={stageRef}\n        role=\"group\"\n        aria-roledescription=\"carousel\"\n        aria-label={ariaLabel}\n        tabIndex={0}\n        className=\"relative w-full touch-pan-y select-none overflow-hidden rounded-md outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ns-accent\"\n        style={{\n          height: cardHeight + 48,\n          perspective: \"1200px\",\n          cursor: \"grab\",\n        }}\n      >\n        {items.map((item, i) => {\n          const d = i - start;\n          return (\n            <div\n              key={i}\n              ref={(el) => {\n                cardRefs.current[i] = el;\n              }}\n              className=\"absolute left-1/2 top-1/2\"\n              style={{\n                width: cardWidth,\n                height: cardHeight,\n                transform: cardTransform(d, cardWidth * 0.46),\n                zIndex: 200 - Math.round(Math.abs(d) * 10),\n                willChange: \"transform\",\n              }}\n            >\n              {/* hover lift + border shift on an inner wrapper so the rAF\n                  loop's 3D transform on the outer node is never fought.\n                  clip-path (not just overflow-hidden+rounded-md) contains the\n                  backdrop-filter pane and canvases: this wrapper sits inside\n                  an ancestor that's both will-change:transform'd and\n                  rotateY/translateZ'd for the coverflow pose, and on that\n                  combination Chromium/WebKit can rasterize a border-radius +\n                  overflow:hidden clip as its full rectangular layer bounds\n                  instead of the rounded mask — a rectangular box behind the\n                  center card, sharp corner wedges poking past the radius on\n                  the rotated side cards. clip-path is evaluated as its own\n                  mask primitive every paint and doesn't share that failure\n                  mode, so it stays authoritative regardless of compositing. */}\n              <div\n                className=\"relative h-full w-full overflow-hidden rounded-md border border-border bg-surface transition-[translate,border-color] duration-200 hover:-translate-y-0.5 hover:border-foreground/25\"\n                style={{ clipPath: \"inset(0 round var(--radius-md))\" }}\n              >\n                <canvas\n                  ref={(el) => {\n                    thumbRefs.current[i] = el;\n                  }}\n                  aria-hidden\n                  className=\"absolute left-0 top-0\"\n                />\n                {/* frosted pane over the generated thumb */}\n                <div\n                  aria-hidden\n                  className=\"absolute inset-0\"\n                  style={{\n                    backdropFilter: \"blur(14px) saturate(1.1)\",\n                    WebkitBackdropFilter: \"blur(14px) saturate(1.1)\",\n                    background:\n                      \"color-mix(in srgb, var(--surface) 60%, transparent)\",\n                  }}\n                />\n                <div className=\"absolute inset-x-0 bottom-0 p-4\">\n                  <p className=\"text-sm font-semibold tracking-tight text-foreground\">\n                    {item.title}\n                  </p>\n                  <p className=\"mt-1 font-mono text-[10px] tracking-widest text-ns-muted\">\n                    {item.meta ?? `NO. ${String(i + 1).padStart(2, \"0\")}`}\n                  </p>\n                </div>\n                {/* caustic + aberration overlay */}\n                <canvas\n                  ref={(el) => {\n                    fxRefs.current[i] = el;\n                  }}\n                  aria-hidden\n                  className=\"pointer-events-none absolute left-0 top-0\"\n                />\n              </div>\n            </div>\n          );\n        })}\n      </div>\n      <div className=\"mt-5 flex items-center justify-center gap-2\">\n        {items.map((item, i) => (\n          <button\n            key={i}\n            ref={(el) => {\n              dotRefs.current[i] = el;\n            }}\n            type=\"button\"\n            data-active={i === start ? \"true\" : \"false\"}\n            aria-label={`Go to ${item.title}`}\n            className=\"h-1.5 w-6 rounded-full bg-border transition-colors duration-200 hover:bg-ns-muted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent data-[active=true]:bg-foreground\"\n          />\n        ))}\n      </div>\n      <span ref={liveRef} aria-live=\"polite\" className=\"sr-only\" />\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/gallery-coverflow-caustic.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)",
      "color-surface": "var(--surface)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff",
      "surface": "#fafafa"
    },
    "dark": {
      "ns-muted": "#8f8f8f",
      "surface": "#171717"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "gallery",
      "coverflow",
      "3d",
      "canvas",
      "caustics",
      "glass",
      "chromatic-aberration",
      "drag",
      "momentum",
      "ambient",
      "showpiece"
    ],
    "instruction": "A 3D coverflow gallery where every card sits behind frosted glass running a lightweight caustic-light simulation. RENDERING: DOM cards under a perspective-1200px stage, anchored at the stage center with translate(-50%,-50%) plus offset transforms (never absolute coords) — center card scale 1.0, side cards rotateY ±35° and scale 0.82, x-spacing 46% of card width, translateZ −60px per index of distance with explicit z-index depth sorting. Each card is a frosted pane: backdrop-filter blur(14px) saturate(1.1) over color-mix(--surface 60%, transparent), 1px --border, rounded-md, over a token-derived generated abstract thumb (grayscale value-noise field mixed from --surface/--border/--ns-muted/--foreground, no external images). A per-card pointer-events-none Canvas 2D overlay (explicit style.width/height + dpr-capped-2 backing store — CSS inset never sizes a canvas) renders caustics: three radial gradients (radius 40% of card width, --foreground ink at alphas 0.10/0.07/0.05) whose centers travel incommensurate Lissajous paths with periods 7s/11s/13s and amplitude 30% of card size, drawn onto a fully cleared quarter-resolution offscreen buffer each tick (no alpha accumulation, ever) and upscaled with imageSmoothing for soft pool edges. Only the focused card and its two neighbors run live caustics; outer cards keep a fixed-phase static frame. CHROMATIC ABERRATION: focused card only — its rounded border restroked twice, offset ±k px horizontally, in red/blue channel inks split from the foreground token with a fixed minimum-saturation floor so the fringe stays colored in light theme too (rgba(max(fg.r,190),0,0) / rgba(0,0,max(fg.b,190))), composited 'lighter' on dark themes and source-over on light; k = min(6, |angular velocity in deg/s| × 0.04) where the focused card turns 35°/index, exponentially damped (τ≈100ms, settles < 500ms) with a 900ms forced-settle deadline. INTERACTION: pointer drag scrubs the continuous index (rubber-band past the ends); release applies momentum with friction 0.92/frame (fps-normalized) then hands off to a snap spring (k=170, ζ≈0.95) once |v| < 0.05 idx/s, itself under a 900ms forced-settle deadline so a flick can never oscillate forever; wheel steps one card per 260ms with a 250ms ease-out-expo tween; clicking a side card centers it (taps under 5px of travel); ArrowLeft/Right step with a visible token-relative focus ring on the stage; index dots below the stage navigate and mirror the active card via data-active. IDLE: pools keep drifting at half amplitude (ambient default look) capped at 30fps, blending back to full amplitude while interacting. REDUCED MOTION: no rAF loop, one static caustic frame per card, no aberration, navigation via instant 200ms CSS ease with no momentum. LIFECYCLE: all canvas inks derived from getComputedStyle tokens at mount and re-derived by a MutationObserver on documentElement class (thumbs and static frames regenerate per theme); IntersectionObserver pauses the loop offscreen and the loop fully sleeps when settled AND the tab is hidden; zero-size containers guard all drawing; hover is a 2px inner-wrapper lift plus border-foreground/25 so the rAF transform is never fought; every listener, observer, and rAF torn down on unmount. Demo: padded 'Field notes' gallery section on a surface card with mono eyebrow, muted subline, seven believable archive entries (generated thumbs, Geist Sans titles, mono date + index meta) and index dots below the stage."
  },
  "type": "registry:ui"
}