{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bitmap-image","title":"Bitmap Image","description":"Converts an image into a dithered bitmap — ordered Bayer or true error diffusion (Floyd-Steinberg, Atkinson, Jarvis), which no shader can do. Canvas 2D, no WebGL.","dependencies":["gsap@^3.15.0","@gsap/react@^2.1.2"],"files":[{"path":"src/components/BitmapImage/index.js","content":"export { default } from './BitmapImage.jsx'\n","type":"registry:component","target":"components/bitmap-image/index.js"},{"path":"src/components/BitmapImage/BitmapImage.jsx","content":"'use client'\n\nimport { useRef, useEffect } from 'react'\nimport { useBitmapField, useLiveParams } from '../bitmap-core/engine.js'\nimport { createRasterSource } from '../bitmap-core/sources.js'\nimport '../bitmap-core/bitmap.css'\n\nconst SUPERSAMPLE = 2\n\n/* =========================================================\n   BitmapImage\n   Converts an image into a dithered bitmap.\n\n   The image is drawn once into an offscreen canvas at 2× the\n   cell grid and box-filtered down, so every cell gets a properly\n   antialiased grey before it is thresholded. Skipping that step\n   is the usual reason naive dither looks like broken JPEG rather\n   than newsprint — a hard threshold of a hard-edged source has\n   no midtones left to diffuse.\n\n   Because the source is cached as a luminance buffer, the frame\n   loop only re-dithers. With no warp and a still pointer it does\n   not even do that: the engine skips identical frames outright.\n   ========================================================= */\n\n/** True when `src` will need CORS to be read back out of a canvas. */\nfunction isCrossOrigin(src) {\n  if (!/^https?:/i.test(src)) return false\n  try {\n    return new URL(src, window.location.href).origin !== window.location.origin\n  } catch {\n    return false\n  }\n}\n\n/**\n * An image, rendered as dither.\n *\n *   <BitmapImage src={url} cell={4} dither=\"floyd\" fit=\"cover\" />\n *\n * `src` accepts a URL, a data/blob URL, or anything canvas can draw directly\n * (an HTMLImageElement, ImageBitmap, HTMLCanvasElement or HTMLVideoElement).\n */\nexport default function BitmapImage({\n  // --- source ---\n  src,\n  alt = '',                // as on <img>: describe it, or leave empty if decorative\n  fit = 'cover',           // cover · contain · fill\n  focusX = 0.5,            // crop anchor, 0..1\n  focusY = 0.5,\n\n  // --- raster ---\n  cell = 4,\n  levels = 2,\n  dither = 'floyd',        // error diffusion suits photographs best\n  ditherAmount = 1,\n  serpentine = true,\n\n  // --- tone ---\n  contrast = 1.1,\n  brightness = 0,\n  invert = false,\n\n  // --- interaction ---\n  warp = 0,\n  cursorRadius = 160,\n  cursorStrength = 0,\n  cursorLift = 0,\n\n  // --- paint ---\n  color = '#e8e4dc',\n  background = '#0a0b0e',\n\n  onStatus,                // 'ready' | 'empty' | 'tainted'\n  paused = false,\n  respectReducedMotion = true,\n  fullscreen = false,\n  className = '',\n  style,\n  ...rest\n}) {\n  const rootRef = useRef(null)\n  const canvasRef = useRef(null)\n  const imageRef = useRef(null)\n\n  const params = useLiveParams({\n    fit, focusX, focusY,\n    cell, levels, dither, ditherAmount, serpentine,\n    contrast, brightness, invert,\n    warp, cursorRadius, cursorStrength, cursorLift,\n    color, background, paused, respectReducedMotion, onStatus,\n  })\n\n  const { rebuild, refresh, repaint } = useBitmapField({\n    rootRef,\n    canvasRef,\n    params,\n    createSource: () =>\n      createRasterSource(\n        (ctx, w, h) => {\n          const img = imageRef.current\n          if (!img) return false\n          const iw = img.naturalWidth || img.videoWidth || img.width\n          const ih = img.naturalHeight || img.videoHeight || img.height\n          if (!iw || !ih) return false\n\n          const p = params.current\n          if (p.fit === 'fill') {\n            ctx.drawImage(img, 0, 0, w, h)\n            return true\n          }\n          // cover takes the larger scale (crop), contain the smaller\n          // (letterbox); the offset formula is the same either way, since\n          // the leftover space just changes sign.\n          const s =\n            p.fit === 'contain'\n              ? Math.min(w / iw, h / ih)\n              : Math.max(w / iw, h / ih)\n          const dw = iw * s\n          const dh = ih * s\n          ctx.drawImage(img, (w - dw) * p.focusX, (h - dh) * p.focusY, dw, dh)\n          return true\n        },\n        { supersample: SUPERSAMPLE }\n      ),\n  })\n\n  // Load the image, then hand it to the source. A cross-origin URL is tried\n  // with CORS first because that is the only way the pixels can be read back;\n  // if the host refuses, retry without so the image at least displays for any\n  // future non-readback use, and report the failure.\n  useEffect(() => {\n    if (!src) {\n      imageRef.current = null\n      refresh.current()\n      return\n    }\n    if (typeof src !== 'string') {\n      imageRef.current = src\n      refresh.current()\n      return\n    }\n\n    let cancelled = false\n    const load = (withCors) => {\n      const img = new Image()\n      if (withCors) img.crossOrigin = 'anonymous'\n      img.onload = () => {\n        if (cancelled) return\n        imageRef.current = img\n        refresh.current()\n      }\n      img.onerror = () => {\n        if (cancelled) return\n        if (withCors) return load(false)\n        imageRef.current = null\n        params.current.onStatus?.('empty')\n        refresh.current()\n      }\n      img.src = src\n    }\n    load(isCrossOrigin(src))\n\n    return () => {\n      cancelled = true\n    }\n  }, [params, refresh, src])\n\n  // Re-rasterise only when the framing changes; re-lay-out on cell size;\n  // repaint on colour.\n  useEffect(() => { refresh.current() }, [refresh, fit, focusX, focusY])\n  useEffect(() => { rebuild.current() }, [rebuild, cell])\n  // Every prop below is read live inside the render passes rather than baked\n  // into the source, so a static field — which the engine otherwise lets idle\n  // — needs an explicit nudge to show the change.\n  useEffect(() => {\n    repaint.current()\n  }, [repaint, color, background, levels,\n      dither, ditherAmount, serpentine, contrast, brightness, invert,\n      warp, cursorRadius, cursorStrength, cursorLift, paused])\n\n\n  return (\n    <div\n      ref={rootRef}\n      className={`bitmap-field${fullscreen ? ' bitmap-field--fullscreen' : ''} ${className}`.trim()}\n      style={{ background: background ?? 'transparent', ...style }}\n      /* Same contract as <img>: a description names the picture, an empty alt\n         marks it decorative and hides it. Without either, the canvas was\n         exposed as an unlabelled element that told the user nothing. */\n      role={alt ? 'img' : undefined}\n      aria-label={alt || undefined}\n      aria-hidden={alt ? undefined : 'true'}\n      {...rest}\n    >\n      <canvas className=\"bitmap-field__canvas\" ref={canvasRef} aria-hidden=\"true\" />\n    </div>\n  )\n}\n","type":"registry:component","target":"components/bitmap-image/BitmapImage.jsx"},{"path":"src/components/bitmap-core/engine.js","content":"import { useEffect, useLayoutEffect, useRef } from 'react'\nimport gsap from 'gsap'\nimport { useGSAP } from '@gsap/react'\nimport { bayerMatrix, DIFFUSION_KERNELS, ORDERED_SIZES } from './dither.js'\n\ngsap.registerPlugin(useGSAP)\n\n/* =========================================================\n   Bitmap engine\n   The shared render pipeline behind BitmapNoise, BitmapImage and\n   BitmapText. Everything except \"how bright is this cell?\" lives\n   here; that one question is answered by a source (see sources.js).\n\n   Per frame, in four passes over a cols×rows grid:\n\n     1  warp     — per-cell source coordinates, displaced by the\n                   pointer and by an optional flow field\n     2  source   — luminance 0..1 at those coordinates\n     3  tone     — contrast, brightness, invert, cursor lift\n     4  dither   — quantise to `levels`, then colour into an\n                   ImageData at *grid* resolution\n\n   The last step is the one that makes this cheap enough to run on\n   the CPU at full-bleed: we never draw cells. We write one pixel\n   per cell into a cols×rows ImageData, then let the GPU blow it up\n   with `imageSmoothingEnabled = false`. A 4px cell on a 1440×900\n   viewport is a 360×225 buffer — 81,000 pixels, not 81,000 draw\n   calls — and the nearest-neighbour upscale is free.\n   ========================================================= */\n\nconst MAX_DPR = 2\n\n/**\n * Resolve any CSS colour — hex, rgb(), hsl(), a named colour, `transparent` —\n * to [r, g, b, a]. Painting onto a cleared 1×1 canvas means the browser does\n * the parsing, so we inherit every colour syntax it supports for free.\n */\nfunction resolveColor(css) {\n  const c = document.createElement('canvas')\n  c.width = 1\n  c.height = 1\n  const ctx = c.getContext('2d', { willReadFrequently: true })\n  ctx.clearRect(0, 0, 1, 1)\n  ctx.fillStyle = '#000'\n  ctx.fillStyle = css == null ? 'transparent' : css\n  ctx.fillRect(0, 0, 1, 1)\n  const d = ctx.getImageData(0, 0, 1, 1).data\n  return [d[0], d[1], d[2], d[3]]\n}\n\n/**\n * A `levels`-entry ramp from background to foreground, packed as native-endian\n * RGBA words. Writing one Uint32 per cell instead of four bytes roughly\n * quarters the colour pass; building the words through a byte view rather than\n * shifting keeps it correct on big-endian machines.\n */\nfunction buildPalette(levels, color, background) {\n  const [fr, fg, fb, fa] = resolveColor(color)\n  const [br, bg_, bb, ba] = resolveColor(background)\n  const pal = new Uint32Array(levels)\n  const bytes = new Uint8Array(4)\n  const word = new Uint32Array(bytes.buffer)\n  for (let i = 0; i < levels; i++) {\n    const t = levels === 1 ? 1 : i / (levels - 1)\n    bytes[0] = br + (fr - br) * t\n    bytes[1] = bg_ + (fg - bg_) * t\n    bytes[2] = bb + (fb - bb) * t\n    bytes[3] = ba + (fa - ba) * t\n    pal[i] = word[0]\n  }\n  return pal\n}\n\n/* useGSAP runs as a layout effect, so the live parameters are synced in one\n   too — declared by the caller ahead of useBitmapField, it runs first, and a\n   build or repaint in the same commit already sees the new values. A write\n   during render would be simpler and is unsound under concurrent rendering:\n   React can render and then discard, leaving the loop reading props that were\n   never committed. Plain useEffect on the server, where layout effects warn. */\nconst useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect\n\n/** A ref holding the latest committed props, for loops that must not restart. */\nexport function useLiveParams(values) {\n  const params = useRef(values)\n  useIsomorphicLayoutEffect(() => {\n    params.current = values\n  })\n  return params\n}\n\n/**\n * Wires a source to a canvas: sizing, the pointer, the gsap ticker, and the\n * four render passes. Returns imperative handles the calling component fires\n * from its own effects — the same live-params pattern the rest of the library\n * uses, so changing a prop never tears down the loop.\n *\n *   rebuild()  grid geometry changed (cell size, element resized)\n *   refresh()  the source's own input changed (new image, new text)\n *   repaint()  palette changed (colour, background, levels)\n */\nexport function useBitmapField({ rootRef, canvasRef, params, createSource }) {\n  const rebuild = useRef(() => {})\n  const refresh = useRef(() => {})\n  const repaint = useRef(() => {})\n\n  useGSAP(\n    () => {\n      const canvas = canvasRef.current\n      const root = rootRef.current\n      const ctx = canvas.getContext('2d')\n      const source = createSource()\n\n      // The grid-resolution buffer everything is composed into.\n      const grid = document.createElement('canvas')\n      const gctx = grid.getContext('2d')\n\n      let cols = 0\n      let rows = 0\n      let count = 0\n      let w = 0\n      let h = 0\n      let dpr = 1\n      let cellPx = 4\n\n      let lum = new Float32Array(0)\n      let wx = new Float32Array(0)\n      let wy = new Float32Array(0)\n      let lift = new Float32Array(0)\n      let err = new Float32Array(0)\n      let idx = new Uint8Array(0)\n      let imageData = null\n      let out32 = null\n      let palette = new Uint32Array(2)\n      let ready = false\n\n      // Own clock: integrating our own delta means changing `speed` eases into\n      // the new rate instead of jumping the field to a different phase.\n      let clock = 0\n\n      const cursor = { x: -1e5, y: -1e5 }\n      const xTo = gsap.quickTo(cursor, 'x', { duration: 0.28, ease: 'power3' })\n      const yTo = gsap.quickTo(cursor, 'y', { duration: 0.28, ease: 'power3' })\n\n      const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)')\n\n      const makePalette = () => {\n        const p = params.current\n        palette = buildPalette(Math.max(2, p.levels | 0), p.color, p.background)\n      }\n      repaint.current = () => {\n        makePalette()\n        draw()\n      }\n\n      const refreshSource = () => {\n        const result = source.prepare(cols, rows)\n        ready = result === true\n        // 'ready' | 'empty' | 'tainted' — only the image component listens, to\n        // tell \"still loading\" apart from \"this file can never be read\".\n        params.current.onStatus?.(result === true ? 'ready' : result || 'empty')\n        draw()\n      }\n      refresh.current = refreshSource\n\n      const build = () => {\n        const p = params.current\n        dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)\n        w = root.clientWidth\n        h = root.clientHeight\n        if (w < 1 || h < 1) return\n\n        cellPx = Math.max(1, p.cell)\n        cols = Math.max(1, Math.ceil(w / cellPx))\n        rows = Math.max(1, Math.ceil(h / cellPx))\n        count = cols * rows\n\n        canvas.width = Math.round(w * dpr)\n        canvas.height = Math.round(h * dpr)\n        canvas.style.width = w + 'px'\n        canvas.style.height = h + 'px'\n        ctx.imageSmoothingEnabled = false\n\n        grid.width = cols\n        grid.height = rows\n        imageData = gctx.createImageData(cols, rows)\n        out32 = new Uint32Array(imageData.data.buffer)\n\n        lum = new Float32Array(count)\n        wx = new Float32Array(count)\n        wy = new Float32Array(count)\n        lift = new Float32Array(count)\n        err = new Float32Array(count)\n        idx = new Uint8Array(count)\n\n        makePalette()\n        refreshSource()\n      }\n      rebuild.current = build\n\n      const animating = () => {\n        const p = params.current\n        if (p.paused) return false\n        if (p.respectReducedMotion && reduced?.matches) return false\n        return true\n      }\n\n      /* ---- pass 1: warped source coordinates, in cell space ---- */\n      const warpPass = () => {\n        const p = params.current\n        const R = p.cursorRadius / cellPx\n        const push = p.cursorStrength / cellPx\n        const cx = cursor.x / cellPx\n        const cy = cursor.y / cellPx\n        const active = R > 0 && (push !== 0 || p.cursorLift !== 0) && cursor.x > -1e4\n        const r2 = R * R\n        const flow = p.warp\n        const t = clock\n\n        for (let y = 0, i = 0; y < rows; y++) {\n          for (let x = 0; x < cols; x++, i++) {\n            let sx = x\n            let sy = y\n            let f = 0\n\n            if (active) {\n              const dx = x - cx\n              const dy = y - cy\n              const d2 = dx * dx + dy * dy\n              if (d2 < r2) {\n                const d = Math.sqrt(d2) || 1e-4\n                f = 1 - d / R\n                f *= f\n                const s = (f * push) / d\n                sx += dx * s\n                sy += dy * s\n              }\n            }\n\n            // Cheap analytic flow: two crossed waves read as a slow liquid\n            // shear without needing a second noise texture per frame.\n            if (flow !== 0) {\n              sx += Math.sin(sy * 0.09 + t * 0.9) * flow\n              sy += Math.cos(sx * 0.075 - t * 0.7) * flow\n            }\n\n            wx[i] = sx\n            wy[i] = sy\n            lift[i] = f\n          }\n        }\n      }\n\n      /* ---- pass 3: tone ---- */\n      const tonePass = () => {\n        const p = params.current\n        const contrast = p.contrast\n        const bright = p.brightness\n        const cl = p.cursorLift\n        const inv = p.invert\n        for (let i = 0; i < count; i++) {\n          let v = (lum[i] - 0.5) * contrast + 0.5 + bright + lift[i] * cl\n          if (inv) v = 1 - v\n          lum[i] = v < 0 ? 0 : v > 1 ? 1 : v\n        }\n      }\n\n      /* ---- pass 4: dither + colour ---- */\n      let bayer = bayerMatrix(8)\n      let bayerN = 8\n      let bayerKey = 'bayer8'\n\n      const ditherPass = () => {\n        const p = params.current\n        const levels = Math.max(2, p.levels | 0)\n        const q = levels - 1\n        const amount = p.ditherAmount\n        const mode = p.dither\n\n        if (mode === 'none' || amount === 0) {\n          for (let i = 0; i < count; i++) {\n            const v = Math.round(lum[i] * q)\n            out32[i] = palette[v]\n          }\n          return\n        }\n\n        const ordered = ORDERED_SIZES[mode]\n        if (ordered) {\n          if (bayerKey !== mode) {\n            bayer = bayerMatrix(ordered)\n            bayerN = ordered\n            bayerKey = mode\n          }\n          // The threshold offset is scaled by the quantisation step, so a\n          // 2-level field gets the full ±0.5 kick and a 16-level one only\n          // dithers across the gap between its own neighbouring levels.\n          const k = amount / q\n          for (let y = 0, i = 0; y < rows; y++) {\n            const brow = (y % bayerN) * bayerN\n            for (let x = 0; x < cols; x++, i++) {\n              let v = Math.round((lum[i] + bayer[brow + (x % bayerN)] * k) * q)\n              if (v < 0) v = 0\n              else if (v > q) v = q\n              out32[i] = palette[v]\n            }\n          }\n          return\n        }\n\n        // Error diffusion. Sequential by definition — each cell's rounding\n        // error is pushed into cells that haven't been visited yet — which is\n        // precisely why this look can't be a shader.\n        const kernel = DIFFUSION_KERNELS[mode] || DIFFUSION_KERNELS.floyd\n        const klen = kernel.length\n        err.set(lum)\n        const serp = p.serpentine\n\n        for (let y = 0; y < rows; y++) {\n          const l2r = !serp || (y & 1) === 0\n          const row = y * cols\n          const start = l2r ? 0 : cols - 1\n          const stop = l2r ? cols : -1\n          const step = l2r ? 1 : -1\n\n          for (let x = start; x !== stop; x += step) {\n            const i = row + x\n            const old = err[i]\n            let v = Math.round(old * q)\n            if (v < 0) v = 0\n            else if (v > q) v = q\n            idx[i] = v\n            const e = (old - v / q) * amount\n            if (e === 0) continue\n\n            for (let k = 0; k < klen; k++) {\n              const kern = kernel[k]\n              const nx = x + (l2r ? kern[0] : -kern[0])\n              if (nx < 0 || nx >= cols) continue\n              const ny = y + kern[1]\n              if (ny >= rows) continue\n              err[ny * cols + nx] += e * kern[2]\n            }\n          }\n        }\n        for (let i = 0; i < count; i++) out32[i] = palette[idx[i]]\n      }\n\n      const draw = () => {\n        if (!imageData || count === 0) return\n        warpPass()\n        if (ready) source.fill(lum, wx, wy, count, cols, rows, clock)\n        else lum.fill(0)\n        tonePass()\n        ditherPass()\n        gctx.putImageData(imageData, 0, 0)\n        ctx.clearRect(0, 0, canvas.width, canvas.height)\n        ctx.drawImage(grid, 0, 0, cols * cellPx * dpr, rows * cellPx * dpr)\n      }\n\n      // A static source with no warp and a still pointer produces a byte-for-byte\n      // identical frame every tick. Detecting that and returning early means a\n      // dithered image or headline costs nothing once it has settled, instead of\n      // re-running four passes 60 times a second forever.\n      let lastCx = NaN\n      let lastCy = NaN\n\n      const tick = (time, deltaMs) => {\n        if (!animating()) return\n        const p = params.current\n\n        if (!source.animated && p.warp === 0) {\n          const live = p.cursorRadius > 0 && (p.cursorStrength !== 0 || p.cursorLift !== 0)\n          if (!live) return\n          if (cursor.x === lastCx && cursor.y === lastCy) return\n          lastCx = cursor.x\n          lastCy = cursor.y\n          draw()\n          return\n        }\n\n        clock += Math.min(deltaMs, 50) / 1000\n        draw()\n      }\n\n      build()\n\n      const onMove = (e) => {\n        const rect = root.getBoundingClientRect()\n        xTo(e.clientX - rect.left)\n        yTo(e.clientY - rect.top)\n      }\n      const onLeave = () => {\n        xTo(-1e5)\n        yTo(-1e5)\n      }\n\n      // ResizeObserver rather than window.resize: these can live inside a\n      // sized container, not just full-bleed.\n      const ro = new ResizeObserver(() => build())\n      ro.observe(root)\n      window.addEventListener('pointermove', onMove, { passive: true })\n      root.addEventListener('pointerleave', onLeave)\n      reduced?.addEventListener?.('change', draw)\n      gsap.ticker.add(tick)\n\n      return () => {\n        ro.disconnect()\n        window.removeEventListener('pointermove', onMove)\n        root.removeEventListener('pointerleave', onLeave)\n        reduced?.removeEventListener?.('change', draw)\n        gsap.ticker.remove(tick)\n        rebuild.current = () => {}\n        refresh.current = () => {}\n        repaint.current = () => {}\n      }\n    },\n    { scope: rootRef }\n  )\n\n  return { rebuild, refresh, repaint }\n}\n\n/** Props every bitmap component shares, and their defaults. */\nexport const BASE_DEFAULTS = {\n  cell: 4,\n  levels: 2,\n  dither: 'bayer8',\n  ditherAmount: 1,\n  serpentine: true,\n  contrast: 1,\n  brightness: 0,\n  invert: false,\n  warp: 0,\n  cursorRadius: 160,\n  cursorStrength: 0,\n  cursorLift: 0,\n  color: '#e8e4dc',\n  background: '#0a0b0e',\n  paused: false,\n  respectReducedMotion: true,\n}\n","type":"registry:component","target":"components/bitmap-core/engine.js"},{"path":"src/components/bitmap-core/sources.js","content":"/* =========================================================\n   Sources\n   A \"source\" is anything that can answer \"how bright is this\n   cell?\" for every cell in the grid. Three of them exist:\n\n   - noise   — procedural, generated fresh every frame from a\n               pair of pre-baked tileable fBm tiles that pan past\n               each other. No input, infinite duration.\n   - raster  — a static luminance buffer painted once by a draw\n               callback (an image, or text) and then sampled with\n               bilinear filtering so it can be warped and scrolled.\n\n   Both expose the same two methods so the engine never has to\n   know which one it is holding:\n\n     prepare(cols, rows) -> boolean   rebuild for a new grid size\n     fill(lum, wx, wy, n, cols, rows, t)   write luminance 0..1\n\n   `wx`/`wy` are per-cell source coordinates the engine has already\n   warped (cursor push, flow). Sources read them and never compute\n   their own warp — that keeps warping identical across all three.\n   ========================================================= */\n\n/* ---------- deterministic RNG so a given seed always tiles the same ---------- */\nfunction mulberry32(seed) {\n  let a = seed >>> 0\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0\n    let t = Math.imul(a ^ (a >>> 15), 1 | a)\n    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296\n  }\n}\n\nconst smooth = (t) => t * t * (3 - 2 * t)\n\n/**\n * One octave of tileable value noise, rendered into a size×size Float32Array.\n * `period` lattice points span the tile exactly, and lattice lookups wrap, so\n * the result tiles seamlessly in both axes — which is what lets us pan it\n * forever without ever hitting an edge.\n */\nfunction valueNoiseTile(size, period, rng) {\n  const lattice = new Float32Array(period * period)\n  for (let i = 0; i < lattice.length; i++) lattice[i] = rng()\n\n  const out = new Float32Array(size * size)\n  const step = size / period\n  for (let y = 0; y < size; y++) {\n    const fy = y / step\n    const y0 = Math.floor(fy)\n    const ty = smooth(fy - y0)\n    const r0 = (y0 % period) * period\n    const r1 = ((y0 + 1) % period) * period\n    for (let x = 0; x < size; x++) {\n      const fx = x / step\n      const x0 = Math.floor(fx)\n      const tx = smooth(fx - x0)\n      const c0 = x0 % period\n      const c1 = (x0 + 1) % period\n      const top = lattice[r0 + c0] + (lattice[r0 + c1] - lattice[r0 + c0]) * tx\n      const bot = lattice[r1 + c0] + (lattice[r1 + c1] - lattice[r1 + c0]) * tx\n      out[y * size + x] = top + (bot - top) * ty\n    }\n  }\n  return out\n}\n\n/**\n * Fractal Brownian motion baked into a single tileable tile. Summing the\n * octaves once up front means the render loop pays for exactly one bilinear\n * lookup per layer per cell instead of re-summing octaves 80,000 times a frame\n * — this is the whole reason the noise mode stays affordable on the CPU.\n */\nexport function makeNoiseTile(size = 256, octaves = 4, basePeriod = 4, seed = 1) {\n  const rng = mulberry32(seed)\n  const out = new Float32Array(size * size)\n  let amp = 1\n  let norm = 0\n  for (let o = 0; o < octaves; o++) {\n    const period = basePeriod * 2 ** o\n    if (period > size) break\n    const layer = valueNoiseTile(size, period, rng)\n    for (let i = 0; i < out.length; i++) out[i] += layer[i] * amp\n    norm += amp\n    amp *= 0.5\n  }\n  // Summed octaves cluster hard around 0.5 — four averaged random layers land\n  // within roughly 0.14..0.83, and blending two of those tiles narrows it\n  // further. Left alone the field never leaves the midtones and dithers to flat\n  // 50% static instead of patches. Stretch each tile onto the full 0..1 range\n  // so `patch` has real signal to work with.\n  let lo = Infinity\n  let hi = -Infinity\n  for (let i = 0; i < out.length; i++) {\n    const v = (out[i] /= norm)\n    if (v < lo) lo = v\n    if (v > hi) hi = v\n  }\n  const span = hi - lo || 1\n  for (let i = 0; i < out.length; i++) out[i] = (out[i] - lo) / span\n  return out\n}\n\n/** Bilinear sample of a tile, wrapping on both axes. `x`/`y` are in tile px. */\nfunction sampleWrap(tile, size, x, y) {\n  let x0 = Math.floor(x)\n  let y0 = Math.floor(y)\n  const tx = x - x0\n  const ty = y - y0\n  x0 = ((x0 % size) + size) % size\n  y0 = ((y0 % size) + size) % size\n  const x1 = x0 + 1 === size ? 0 : x0 + 1\n  const y1 = y0 + 1 === size ? 0 : y0 + 1\n  const r0 = y0 * size\n  const r1 = y1 * size\n  const top = tile[r0 + x0] + (tile[r0 + x1] - tile[r0 + x0]) * tx\n  const bot = tile[r1 + x0] + (tile[r1 + x1] - tile[r1 + x0]) * tx\n  return top + (bot - top) * ty\n}\n\n/** Bilinear sample of a buffer, clamping at the edges. `x`/`y` are in buffer px. */\nexport function sampleClamp(buf, w, h, x, y) {\n  if (x < 0) x = 0\n  else if (x > w - 1) x = w - 1\n  if (y < 0) y = 0\n  else if (y > h - 1) y = h - 1\n  const x0 = x | 0\n  const y0 = y | 0\n  const x1 = x0 + 1 < w ? x0 + 1 : x0\n  const y1 = y0 + 1 < h ? y0 + 1 : y0\n  const tx = x - x0\n  const ty = y - y0\n  const r0 = y0 * w\n  const r1 = y1 * w\n  const top = buf[r0 + x0] + (buf[r0 + x1] - buf[r0 + x0]) * tx\n  const bot = buf[r1 + x0] + (buf[r1 + x1] - buf[r1 + x0]) * tx\n  return top + (bot - top) * ty\n}\n\nconst TILE = 256\n\n/* Blend weights for the two drifting layers, and the factor that restores the\n   spread averaging them costs: 1 / sqrt(WA^2 + WB^2). */\nconst WA = 0.62\nconst WB = 0.38\nconst BLEND_GAIN = 1 / Math.sqrt(WA * WA + WB * WB)\n\n/**\n * Procedural source: two fBm tiles drifting past each other on different\n * headings, then squeezed by `patch` into distinct blobs rather than a smooth\n * gradient. The counter-drift is what stops the field from reading as one\n * sheet sliding by — the interference between the two layers is what makes\n * patches appear, dissolve and reform in place.\n *\n * `params` is a live ref-like object read fresh every frame.\n */\nexport function createNoiseSource(params) {\n  const tileA = makeNoiseTile(TILE, 4, 4, 1)\n  const tileB = makeNoiseTile(TILE, 3, 6, 9)\n\n  return {\n    animated: true,\n    prepare: () => true,\n    fill(lum, wx, wy, n, cols, rows, t) {\n      const { scale, speed, patch, drift, density } = params.current\n      // A tile feature spans ~TILE/4 px; `k` maps that onto `scale` cells.\n      const k = 3.2 / Math.max(scale, 0.05)\n      const kb = k * 0.62\n      const ax = t * speed * 9\n      const ay = t * speed * 3.5\n      const bx = -t * speed * 5 + drift * 40\n      const by = t * speed * 7\n      const lo = 0.5 - Math.max(1 - patch, 0.02) * 0.5\n      const hi = 0.5 + Math.max(1 - patch, 0.02) * 0.5\n      const span = hi - lo\n      // How much of the field ends up lit. This belongs to the field, not to\n      // the tone stage: a 50/50 dither on a light-on-dark palette already\n      // reads as light-dominant, so the neutral-looking default sits below\n      // half and `brightness` stays free as a user-facing control.\n      const bias = density - 0.5\n\n      for (let i = 0; i < n; i++) {\n        const sx = wx[i]\n        const sy = wy[i]\n        const a = sampleWrap(tileA, TILE, sx * k + ax, sy * k + ay)\n        const b = sampleWrap(tileB, TILE, sx * kb + bx, sy * kb + by)\n        // Averaging two independent layers shrinks the spread by\n        // sqrt(wa^2 + wb^2); BLEND_GAIN puts it back, so the blend costs\n        // contrast-free interference rather than a washed-out field.\n        let v = 0.5 + ((a - 0.5) * WA + (b - 0.5) * WB) * BLEND_GAIN\n        // Squeeze the midtones outward so the field breaks into patches.\n        v = (v - lo) / span\n        v = v <= 0 ? 0 : v >= 1 ? 1 : smooth(v)\n        v += bias\n        lum[i] = v <= 0 ? 0 : v >= 1 ? 1 : v\n      }\n    },\n  }\n}\n\n/**\n * Static source: a draw callback paints into an offscreen canvas at SS× the\n * grid resolution, which is then box-filtered down to grid resolution. That\n * supersample is what makes the difference between a jagged, unreadable\n * threshold and clean antialiased greys that dither into legible shapes.\n *\n * `draw(ctx, w, h)` returns false to signal \"nothing to draw yet\" (an image\n * that hasn't loaded, empty text). `prepare` returns true on success, false\n * when there is nothing to draw, or the string 'tainted' when the canvas\n * could not be read back because of cross-origin restrictions.\n */\nexport function createRasterSource(draw, { supersample = 2 } = {}) {\n  const canvas = document.createElement('canvas')\n  const ctx = canvas.getContext('2d', { willReadFrequently: true })\n  let buf = new Float32Array(0)\n  let bw = 0\n  let bh = 0\n\n  return {\n    // Static: the engine can skip whole frames when nothing else is moving.\n    animated: false,\n    prepare(cols, rows) {\n      if (cols < 1 || rows < 1) return false\n      const w = Math.max(1, Math.round(cols * supersample))\n      const h = Math.max(1, Math.round(rows * supersample))\n      canvas.width = w\n      canvas.height = h\n      ctx.setTransform(1, 0, 0, 1, 0, 0)\n      ctx.clearRect(0, 0, w, h)\n\n      if (draw(ctx, w, h) === false) {\n        bw = 0\n        bh = 0\n        return false\n      }\n\n      // A cross-origin image drawn without CORS headers taints the canvas and\n      // makes this throw. Fail soft: the field renders empty and the component\n      // surfaces the error rather than taking the page down.\n      let px\n      try {\n        px = ctx.getImageData(0, 0, w, h).data\n      } catch {\n        bw = 0\n        bh = 0\n        return 'tainted'\n      }\n      if (buf.length !== cols * rows) buf = new Float32Array(cols * rows)\n      bw = cols\n      bh = rows\n\n      // Box-filter SS×SS blocks down to one cell. Alpha is folded in so\n      // transparent regions read as black instead of stray white fringes.\n      const inv = 1 / (supersample * supersample * 255)\n      for (let y = 0; y < rows; y++) {\n        const y0 = Math.round(y * supersample)\n        const y1 = Math.min(h, y0 + supersample)\n        for (let x = 0; x < cols; x++) {\n          const x0 = Math.round(x * supersample)\n          const x1 = Math.min(w, x0 + supersample)\n          let sum = 0\n          for (let sy = y0; sy < y1; sy++) {\n            let o = (sy * w + x0) * 4\n            for (let sx = x0; sx < x1; sx++, o += 4) {\n              const a = px[o + 3] / 255\n              // Rec. 709 luma, premultiplied by coverage.\n              sum += (0.2126 * px[o] + 0.7152 * px[o + 1] + 0.0722 * px[o + 2]) * a\n            }\n          }\n          buf[y * cols + x] = sum * inv\n        }\n      }\n      return true\n    },\n    fill(lum, wx, wy, n) {\n      if (!bw || !bh) {\n        lum.fill(0, 0, n)\n        return\n      }\n      for (let i = 0; i < n; i++) lum[i] = sampleClamp(buf, bw, bh, wx[i], wy[i])\n    },\n  }\n}\n","type":"registry:component","target":"components/bitmap-core/sources.js"},{"path":"src/components/bitmap-core/dither.js","content":"/* =========================================================\n   Dither kernels\n   Two families, and they are not interchangeable:\n\n   - Ordered (Bayer): a fixed threshold map tiled over the grid.\n     Every cell is independent, so it parallelises trivially and\n     produces the stable crosshatch you know from early Mac and\n     Amiga art. Because cells don't talk to each other, an animated\n     source stays temporally stable — no crawling.\n\n   - Error diffusion (Floyd–Steinberg, Atkinson, Jarvis): quantise a\n     cell, then push the rounding error into cells not yet visited.\n     Inherently sequential — each cell depends on its neighbours —\n     which is exactly why this look cannot be done as a per-pixel\n     shader, and why the whole component stays on the CPU.\n   ========================================================= */\n\n/**\n * Recursive Bayer threshold matrix. `n` must be a power of two (2, 4, 8, 16).\n * Returns a Float32Array of n*n thresholds centred on zero, i.e. in\n * (-0.5, 0.5), ready to be added to a luminance before quantising.\n */\nexport function bayerMatrix(n) {\n  let m = [0]\n  let s = 1\n  while (s < n) {\n    const w = s * 2\n    const next = new Array(w * w)\n    for (let y = 0; y < s; y++) {\n      for (let x = 0; x < s; x++) {\n        const v = m[y * s + x] * 4\n        next[y * w + x] = v\n        next[y * w + (x + s)] = v + 2\n        next[(y + s) * w + x] = v + 3\n        next[(y + s) * w + (x + s)] = v + 1\n      }\n    }\n    m = next\n    s = w\n  }\n  const total = n * n\n  const out = new Float32Array(total)\n  for (let i = 0; i < total; i++) out[i] = (m[i] + 0.5) / total - 0.5\n  return out\n}\n\n/* Error-diffusion kernels as flat [dx, dy, weight] triples. dx is mirrored\n   automatically on right-to-left rows when serpentine scanning is on. */\nexport const DIFFUSION_KERNELS = {\n  floyd: [\n    [1, 0, 7 / 16],\n    [-1, 1, 3 / 16],\n    [0, 1, 5 / 16],\n    [1, 1, 1 / 16],\n  ],\n  atkinson: [\n    [1, 0, 1 / 8],\n    [2, 0, 1 / 8],\n    [-1, 1, 1 / 8],\n    [0, 1, 1 / 8],\n    [1, 1, 1 / 8],\n    [0, 2, 1 / 8],\n  ],\n  jarvis: [\n    [1, 0, 7 / 48],\n    [2, 0, 5 / 48],\n    [-2, 1, 3 / 48],\n    [-1, 1, 5 / 48],\n    [0, 1, 7 / 48],\n    [1, 1, 5 / 48],\n    [2, 1, 3 / 48],\n    [-2, 2, 1 / 48],\n    [-1, 2, 3 / 48],\n    [0, 2, 5 / 48],\n    [1, 2, 3 / 48],\n    [2, 2, 1 / 48],\n  ],\n}\n\nexport const ORDERED_SIZES = { bayer2: 2, bayer4: 4, bayer8: 8, bayer16: 16 }\n","type":"registry:component","target":"components/bitmap-core/dither.js"},{"path":"src/components/bitmap-core/bitmap.css","content":".bitmap-field {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n}\n\n/* Opt-in full-bleed, for when one of these is the page background rather\n   than a sized element. */\n.bitmap-field--fullscreen {\n  position: fixed;\n  inset: 0;\n}\n\n.bitmap-field__canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n","type":"registry:file","target":"components/bitmap-core/bitmap.css"},{"path":"src/components/BitmapImage/BitmapImage.d.ts","content":"import type { ReactElement } from 'react'\nimport type { BitmapFieldProps } from '../bitmap-core/bitmap'\n\nexport interface BitmapImageProps extends BitmapFieldProps {\n  /** Address of the picture. One from another origin needs CORS headers, or it cannot be read and `onStatus` reports `tainted`. */\n  src?: string\n  /** As on `<img>`: describe the picture, or leave empty to mark it decorative. */\n  alt?: string\n  /** `cover` fills the box and crops the overflow, `contain` shows the whole picture and leaves the rest empty, `fill` stretches it to the box. */\n  fit?: 'cover' | 'contain' | 'fill'\n  /** Crop anchor, 0..1. */\n  focusX?: number\n  /** Crop anchor on the vertical axis, 0..1. */\n  focusY?: number\n  /** Reports `ready` when there is a picture to draw, `empty` when there is not (no `src`, or it failed to load), and `tainted` when the picture is cross-origin without CORS and cannot be read. */\n  onStatus?: (status: 'ready' | 'empty' | 'tainted') => void\n}\n\ndeclare const BitmapImage: (props: BitmapImageProps) => ReactElement\nexport default BitmapImage\n","type":"registry:lib","target":"components/bitmap-image/BitmapImage.d.ts"},{"path":"src/components/bitmap-core/bitmap.d.ts","content":"import type { ComponentPropsWithoutRef } from 'react'\n\n/**\n * The raster settings the three bitmap components share. They all run the same\n * engine — only the source differs — so the knobs below mean the same thing in\n * each, and are declared once here rather than three times.\n */\nexport type DitherKernel =\n  | 'bayer2'\n  | 'bayer4'\n  | 'bayer8'\n  | 'bayer16'\n  | 'floyd'\n  | 'atkinson'\n  | 'jarvis'\n  | 'none'\n\nexport interface BitmapFieldProps extends ComponentPropsWithoutRef<'div'> {\n  /** Pixels per cell. Smaller is finer and costs more per frame. */\n  cell?: number\n  /** Tone steps. 2 is one-bit; higher posterises. */\n  levels?: number\n  /** What turns tone into dots: `bayer2` to `bayer16` are fixed patterns that hold still, `floyd`, `atkinson` and `jarvis` diffuse the error and follow the picture more closely but can crawl as it changes, `none` just posterises. */\n  dither?: DitherKernel\n  /** 0 passes the tone through untouched, 1 applies the kernel in full. */\n  ditherAmount?: number\n  /** Alternate scan direction. Error-diffusion kernels only. */\n  serpentine?: boolean\n  /** Stretch of the tones around mid-grey before dithering. 1 leaves them alone, above 1 is harder, below 1 is flatter. */\n  contrast?: number\n  /** Added to every tone before dithering. 0 leaves them alone. */\n  brightness?: number\n  /** Swaps lit and unlit cells. */\n  invert?: boolean\n  /** Flow-field shear, in cells. */\n  warp?: number\n\n  /** Reach of the pointer's influence, in px. */\n  cursorRadius?: number\n  /** Outward push at the centre of that reach, in px. */\n  cursorStrength?: number\n  /** Luminance added at the centre of that reach. */\n  cursorLift?: number\n\n  /** Colour of the lit cells. */\n  color?: string\n  /** `null` renders transparent. */\n  background?: string | null\n\n  /** Stops the frame loop without unmounting. */\n  paused?: boolean\n  /** Honour `prefers-reduced-motion`, and keep honouring it if it changes. */\n  respectReducedMotion?: boolean\n  /** Position the field to the viewport rather than its container. */\n  fullscreen?: boolean\n}\n","type":"registry:lib","target":"components/bitmap-core/bitmap.d.ts"}],"type":"registry:component"}