{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bitmap-noise","title":"Bitmap Noise","description":"A self-generating dither field. Two drifting noise layers interfere to form patches of particles that appear, dissolve and reform in place. Canvas 2D, no WebGL.","dependencies":["gsap@^3.15.0","@gsap/react@^2.1.2"],"files":[{"path":"src/components/BitmapNoise/index.js","content":"export { default } from './BitmapNoise.jsx'\n","type":"registry:component","target":"components/bitmap-noise/index.js"},{"path":"src/components/BitmapNoise/BitmapNoise.jsx","content":"'use client'\n\nimport { useRef, useEffect } from 'react'\nimport { useBitmapField, useLiveParams } from '../bitmap-core/engine.js'\nimport { createNoiseSource } from '../bitmap-core/sources.js'\nimport '../bitmap-core/bitmap.css'\n\n/* =========================================================\n   BitmapNoise\n   A dithered field with no input at all — patches of particles\n   that form, drift, dissolve and reform on their own.\n\n   Two pre-baked fBm noise tiles pan past each other on different\n   headings; where they agree the field is bright, where they\n   disagree it falls dark. The interference between the two is\n   what makes patches appear to *evolve in place* rather than\n   slide across the screen as one sheet. `patch` then squeezes\n   the midtones outward so the result breaks into islands instead\n   of reading as a smooth gradient.\n\n   Everything is quantised through the shared bitmap engine, so\n   the output is real 1-bit dither, not a blurred texture.\n   ========================================================= */\n\n/**\n * A self-generating dither field.\n *\n *   <BitmapNoise scale={2} speed={0.4} patch={0.55} dither=\"bayer8\" />\n */\nexport default function BitmapNoise({\n  // --- field ---\n  scale = 2,               // patch size, in cells — bigger is blobbier\n  speed = 0.4,             // drift rate; 0 freezes the field\n  patch = 0.42,            // 0 = smooth gradient, 1 = hard islands\n  density = 0.36,          // fraction of the field that ends up lit\n  drift = 0,               // constant lateral push on the second layer\n\n  // --- raster ---\n  cell = 4,                // px per cell\n  levels = 2,              // 2 = 1-bit; higher posterises\n  dither = 'bayer8',       // bayer2|4|8|16 · floyd · atkinson · jarvis · none\n  ditherAmount = 1,\n  serpentine = true,       // alternate scan direction (error diffusion only)\n\n  // --- tone ---\n  contrast = 1,\n  brightness = 0,\n  invert = false,\n\n  // --- interaction ---\n  warp = 0,                // flow-field shear, in cells\n  cursorRadius = 160,      // px\n  cursorStrength = 0,      // px of outward push at the centre\n  cursorLift = 0,          // luminance added at the centre\n\n  // --- paint ---\n  color = '#e8e4dc',\n  background = '#0a0b0e',  // null renders transparent\n\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\n  // Live parameters: the loop reads these every frame, so changing a prop\n  // retunes the field in place instead of restarting it.\n  const params = useLiveParams({\n    scale, speed, patch, density, drift,\n    cell, levels, dither, ditherAmount, serpentine,\n    contrast, brightness, invert,\n    warp, cursorRadius, cursorStrength, cursorLift,\n    color, background, paused, respectReducedMotion,\n  })\n\n  const { rebuild, repaint } = useBitmapField({\n    rootRef,\n    canvasRef,\n    params,\n    createSource: () => createNoiseSource(params),\n  })\n\n  // Cell size changes the grid geometry; everything else is read live by the\n  // render passes and only needs a nudge for the frames where the field is\n  // paused and therefore not redrawing on its own.\n  useEffect(() => { rebuild.current() }, [rebuild, cell])\n  useEffect(() => {\n    repaint.current()\n  }, [repaint, color, background, levels, dither, ditherAmount, serpentine,\n      contrast, brightness, invert, scale, patch, density, drift, paused])\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      {...rest}\n    >\n      {/* A drifting noise field carries nothing to announce. */}\n      <canvas className=\"bitmap-field__canvas\" ref={canvasRef} aria-hidden=\"true\" />\n    </div>\n  )\n}\n","type":"registry:component","target":"components/bitmap-noise/BitmapNoise.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/BitmapNoise/BitmapNoise.d.ts","content":"import type { ReactElement } from 'react'\nimport type { BitmapFieldProps } from '../bitmap-core/bitmap'\n\nexport interface BitmapNoiseProps extends BitmapFieldProps {\n  /** Patch size, in cells. Bigger is blobbier. */\n  scale?: number\n  /** Drift rate. 0 freezes the field. */\n  speed?: number\n  /** 0 is a smooth gradient, 1 is hard islands. */\n  patch?: number\n  /** Fraction of the field that ends up lit. */\n  density?: number\n  /** Constant lateral push on the second layer. */\n  drift?: number\n}\n\ndeclare const BitmapNoise: (props: BitmapNoiseProps) => ReactElement\nexport default BitmapNoise\n","type":"registry:lib","target":"components/bitmap-noise/BitmapNoise.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"}