{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bitplane-cascade",
  "title": "Bitplane Cascade",
  "description": "A full-bleed hero/background that composites a real Amiga-class planar framebuffer image one bitplane at a time: a coarse 2-tone silhouette lands first, then three more binary-weighted planes each add a finer layer of tonal detail on top of it, before the full 16-level image holds and the stack clears to rebuild from plane 0 again.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/bitplane-cascade/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// BitplaneCascade — a full-bleed background modeling Amiga-class planar\n// framebuffers (and 1-bit fax/plotter graphics before them): an N-bit image\n// isn't stored as one array of pixel values, it's N separate 1-bit bitplanes,\n// each full-resolution, composited by adding each plane's binary contribution\n// at its place value (plane weighted 8, 4, 2, 1 for a 4-bit / 16-level image).\n// This component samples ONE static scalar field once per size, quantizes it\n// to a 16-level (4-bit) value per grid cell, and reveals that value's bits one\n// plane at a time — MOST significant place value first. That ordering is the\n// deliberate, historically-accurate choice: real progressive bit-plane\n// transmission (fax/plotter preview passes, progressive image codecs) sends\n// the MSB plane first because it alone reconstructs a legible coarse\n// silhouette; sending the LSB first would just be spatial noise with no\n// silhouette to build on. So \"plane 0\" here is place value 8 (arrives first,\n// t=0), \"plane 1\" is place value 4 (t=350ms), \"plane 2\" is place value 2\n// (t=700ms), \"plane 3\" is place value 1, the finest bit (t=1050ms) — each\n// arrival ANDs one more high bit into the visible reconstruction, so the\n// already-revealed hard silhouette edge never moves, only finer bands appear\n// inside/around it. Full stack holds 900ms, then the accumulator clears and\n// plane 0 lands again: 350*4 + 900 = 2300ms per cycle, forever.\n//\n// Differentiation: background-halftone-rosette is also a full-bleed canvas\n// reveal, but its identity is continuous same-ink dot-angle drift with no\n// discrete steps — pick that for a texture that's always mid-motion. Pick\n// bitplane-cascade when the point is a countable, steppy \"one more layer just\n// landed\" beat with a held clean frame, not a continuous drift.\n//\n// ALIVE AT REST, not aliased to the checkpoint clock: within any single\n// plane's 350ms window the cell states are static (a real bitplane compositor\n// doesn't blend), so a bare step function would make t0 and any other time\n// that lands in the same window of a later cycle read as pixel-identical.\n// Instead, every cell touched by the most-recently-landed plane carries a\n// short landing flash (an extra brightness pop that decays over ~220ms) — the\n// legibility cue for \"a layer just arrived\", and it also means elapsed time\n// since that landing (not just which plane is current) is always part of the\n// rendered frame, so no two real-time samples within a cycle are identical.\n//\n// Tokens: --foreground is the only ink (mixed toward --background by opacity,\n// never a literal color); --background clears the frame each draw. No\n// --ns-accent anywhere — this is a resting ambient surface, not an\n// interaction moment.\n// ---------------------------------------------------------------------------\n\nconst STEP_MS = 350; // per-plane arrival interval\nconst HOLD_MS = 900; // full-stack clean-image hold after the last plane lands\nconst CYCLE_MS = STEP_MS * 4 + HOLD_MS; // 2300ms\nconst FLASH_MS = 220; // landing-flash decay window, inside one 350ms step\nconst PLANE_BITS = [8, 4, 2, 1]; // MSB-first arrival order (place values)\n\nfunction hash2(ix: number, iy: number, seed: number) {\n  let h = Math.imul(ix, 374761393) ^ Math.imul(iy, 668265263) ^ Math.imul(seed, 2246822519);\n  h = Math.imul(h ^ (h >>> 13), 1274126177);\n  h = h ^ (h >>> 16);\n  return (h >>> 0) / 4294967295;\n}\n\nfunction smooth(t: number) {\n  return t * t * t * (t * (t * 6 - 15) + 10);\n}\n\nfunction valueNoise(x: number, y: number, seed: number) {\n  const x0 = Math.floor(x);\n  const y0 = Math.floor(y);\n  const sx = smooth(x - x0);\n  const sy = smooth(y - y0);\n  const n00 = hash2(x0, y0, seed);\n  const n10 = hash2(x0 + 1, y0, seed);\n  const n01 = hash2(x0, y0 + 1, seed);\n  const n11 = hash2(x0 + 1, y0 + 1, seed);\n  const nx0 = n00 + (n10 - n00) * sx;\n  const nx1 = n01 + (n11 - n01) * sx;\n  return nx0 + (nx1 - nx0) * sy;\n}\n\nexport interface BitplaneCascadeProps {\n  /** field cell pitch as a fraction of the container's smaller dimension, before clamping. @default 1/96 */\n  cellRatio?: number;\n  /** freeze the field at its reduced-motion frame. @default false */\n  paused?: boolean;\n  children?: ReactNode;\n  className?: string;\n  style?: CSSProperties;\n}\n\nexport function BitplaneCascade({\n  cellRatio = 1 / 96,\n  paused = false,\n  children,\n  className = \"\",\n  style,\n}: BitplaneCascadeProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const canvas = canvasRef.current;\n    if (!canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    // token fields start empty and are assigned unconditionally from\n    // getComputedStyle before any draw path can run — no literal fallback.\n    let ink = \"\";\n    let bg = \"\";\n    const readTokens = () => {\n      const cs = getComputedStyle(document.documentElement);\n      ink = cs.getPropertyValue(\"--foreground\").trim();\n      bg = cs.getPropertyValue(\"--background\").trim();\n    };\n\n    let dpr = 1;\n    let width = 0;\n    let height = 0;\n    let sized = false;\n    let ready = false;\n    let disposed = false;\n    let visible = true;\n    let raf = 0;\n\n    let cols = 0;\n    let rows = 0;\n    let cell = 8;\n    let field: Uint8Array = new Uint8Array(0); // per-cell quantized 0..15 value\n    // deterministic fixed seed; the field is sampled once per size and never\n    // rerolled, so no per-mount randomization is needed here.\n    const FIELD_SEED = 0x51ed270b;\n\n    let startTime = 0;\n\n    const buildField = () => {\n      const minDim = Math.min(width, height) || 1;\n      // clamp per spec: clamp(round(minDim/96), 4, 12)\n      cell = Math.max(4, Math.min(12, Math.round(minDim * cellRatio)));\n      cols = Math.max(1, Math.ceil(width / cell));\n      rows = Math.max(1, Math.ceil(height / cell));\n      field = new Uint8Array(cols * rows);\n\n      const s = FIELD_SEED;\n      const minGrid = Math.max(1, Math.min(cols, rows));\n      // two octaves of hashed value noise -> large soft shapes, not fine grain\n      const wave1 = minGrid / 3.2;\n      const wave2 = minGrid / 9;\n      const raw = new Float32Array(cols * rows);\n      let minV = Infinity;\n      let maxV = -Infinity;\n      for (let ry = 0; ry < rows; ry++) {\n        for (let rx = 0; rx < cols; rx++) {\n          const n1 = valueNoise(rx / wave1, ry / wave1, s);\n          const n2 = valueNoise(rx / wave2, ry / wave2, s + 97);\n          const v = n1 * 0.7 + n2 * 0.3;\n          raw[ry * cols + rx] = v;\n          if (v < minV) minV = v;\n          if (v > maxV) maxV = v;\n        }\n      }\n      // stretch to the field's own full observed range before quantizing —\n      // two blended octaves cluster toward the middle, and without this the\n      // 16-level output never reaches its low/high bins, so the \"full\n      // luminance gradient\" the cascade builds toward would never arrive.\n      const range = Math.max(1e-6, maxV - minV);\n      for (let i = 0; i < raw.length; i++) {\n        field[i] = Math.max(0, Math.min(15, Math.floor(((raw[i] - minV) / range) * 15.999)));\n      }\n    };\n\n    // returns { count: 1..4, mask: bits revealed so far, sinceLanding: ms }\n    const phaseAt = (elapsed: number) => {\n      const t = ((elapsed % CYCLE_MS) + CYCLE_MS) % CYCLE_MS;\n      let count: number;\n      let landAt: number;\n      if (t < STEP_MS) {\n        count = 1;\n        landAt = 0;\n      } else if (t < STEP_MS * 2) {\n        count = 2;\n        landAt = STEP_MS;\n      } else if (t < STEP_MS * 3) {\n        count = 3;\n        landAt = STEP_MS * 2;\n      } else {\n        count = 4;\n        landAt = STEP_MS * 3;\n      }\n      let mask = 0;\n      for (let i = 0; i < count; i++) mask |= PLANE_BITS[i];\n      const newestBit = PLANE_BITS[count - 1];\n      const sinceLanding = t - landAt;\n      return { count, mask, newestBit, sinceLanding };\n    };\n\n    // reused across draws, sized to the largest possible bucket count so a\n    // resize never reallocates mid-frame: 16 reconstructed values x 2\n    // (flashed / not) = 32 buckets, each a flat list of cell coordinate pairs.\n    const bucketCells: number[][] = Array.from({ length: 32 }, () => []);\n    const bucketAlpha = new Float32Array(32);\n\n    const draw = (elapsed: number, forceFlash?: number) => {\n      // no paint before the first token read — an empty getPropertyValue\n      // string would otherwise leave ctx.fillStyle at its default black.\n      if (!sized || !ink || !bg) return;\n      const { mask, newestBit, sinceLanding } = phaseAt(elapsed);\n      const flashT = forceFlash !== undefined ? forceFlash : sinceLanding;\n      const flashAmt = Math.max(0, 1 - flashT / FLASH_MS);\n\n      ctx.fillStyle = bg;\n      ctx.fillRect(0, 0, width, height);\n\n      // bucket cells by their exact alpha (at most 32 distinct values per\n      // frame: 16 reconstructed levels x flashed/not) so globalAlpha is only\n      // set once per bucket instead of once per cell.\n      let bucketCount = 0;\n      for (let b = 0; b < bucketCells.length; b++) bucketCells[b].length = 0;\n\n      for (let ry = 0; ry < rows; ry++) {\n        for (let rx = 0; rx < cols; rx++) {\n          const v = field[ry * cols + rx];\n          const reconstructed = v & mask; // only already-arrived bits count\n          if (reconstructed <= 0) continue;\n          const flashed = flashAmt > 0 && (v & newestBit) !== 0;\n          let alpha = reconstructed / 15;\n          // plane-0-only: raise the contrast floor so the first silhouette\n          // never washes out against a light background.\n          if (mask === PLANE_BITS[0]) alpha = Math.max(alpha, 0.25);\n          if (flashed) alpha = Math.min(1, alpha + flashAmt * 0.35);\n\n          const key = (reconstructed - 1) * 2 + (flashed ? 1 : 0);\n          if (bucketCells[key].length === 0) {\n            bucketAlpha[key] = alpha;\n            bucketCount++;\n          }\n          bucketCells[key].push(rx, ry);\n        }\n      }\n\n      if (bucketCount > 0) {\n        ctx.fillStyle = ink;\n        for (let b = 0; b < bucketCells.length; b++) {\n          const coords = bucketCells[b];\n          if (coords.length === 0) continue;\n          ctx.globalAlpha = bucketAlpha[b];\n          for (let i = 0; i < coords.length; i += 2) {\n            ctx.fillRect(coords[i] * cell, coords[i + 1] * cell, cell, cell);\n          }\n        }\n        ctx.globalAlpha = 1;\n      }\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (rect.width < 2 || rect.height < 2) {\n        sized = false;\n        return;\n      }\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      buildField();\n      sized = true;\n    };\n\n    // last plane-count actually painted — a static window (no new landing,\n    // flash fully decayed) redraws nothing, since the frame is provably\n    // unchanged: same field, same mask, same zero flash contribution.\n    let lastDrawnCount = -1;\n\n    const loop = (now: number) => {\n      if (!visible) return;\n      if (!startTime) startTime = now;\n      const elapsed = now - startTime;\n      const { count, sinceLanding } = phaseAt(elapsed);\n      if (count !== lastDrawnCount || sinceLanding < FLASH_MS) {\n        draw(elapsed);\n        lastDrawnCount = count;\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    let resizeTimer: ReturnType<typeof setTimeout> | null = null;\n    const onResize = () => {\n      if (resizeTimer) clearTimeout(resizeTimer);\n      resizeTimer = setTimeout(() => {\n        resizeTimer = null;\n        resize();\n        if (!sized) return;\n        ready = true;\n        if (reduced || paused) {\n          // frame: plane 2 of 4 just landed (3 bits revealed, MSB-first),\n          // flash near-max — \"just landed\", not settled.\n          draw(STEP_MS * 2, 10);\n        } else {\n          startTime = 0;\n          lastDrawnCount = -1;\n          if (visible && !raf) raf = requestAnimationFrame(loop);\n        }\n      }, 150);\n    };\n\n    const ro = new ResizeObserver(onResize);\n    ro.observe(canvas);\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        visible = entries[0]?.isIntersecting ?? true;\n        if (visible && ready && !reduced && !paused) {\n          startTime = 0;\n          raf = requestAnimationFrame(loop);\n        } else {\n          cancelAnimationFrame(raf);\n          raf = 0;\n        }\n      },\n      { threshold: 0 }\n    );\n    io.observe(canvas);\n\n    const onVis = () => {\n      if (document.hidden) {\n        cancelAnimationFrame(raf);\n        raf = 0;\n      } else if (visible && ready && !reduced && !paused) {\n        startTime = 0;\n        raf = requestAnimationFrame(loop);\n      }\n    };\n    document.addEventListener(\"visibilitychange\", onVis);\n\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced || paused) draw(STEP_MS * 2, 10);\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    document.fonts.ready.then(() => {\n      if (disposed) return;\n      readTokens();\n      resize();\n      if (!sized) {\n        ready = true;\n        return;\n      }\n      ready = true;\n      if (reduced || paused) {\n        draw(STEP_MS * 2, 10);\n      } else if (!raf) {\n        raf = requestAnimationFrame(loop);\n      }\n    });\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      ro.disconnect();\n      io.disconnect();\n      mo.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVis);\n    };\n  }, [cellRatio, paused]);\n\n  return (\n    <div\n      className={`relative isolate h-full w-full overflow-hidden bg-background ${className}`}\n      style={style}\n    >\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full\"\n      />\n      {children ? <div className=\"relative z-[1] h-full w-full\">{children}</div> : null}\n    </div>\n  );\n}\n\nBitplaneCascade.displayName = \"BitplaneCascade\";\n",
      "type": "registry:ui",
      "target": "components/ui/bitplane-cascade.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-accent": "#006bff"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "background",
      "canvas",
      "bitplane",
      "framebuffer",
      "print",
      "ambient",
      "monochrome"
    ],
    "instruction": "Build <BitplaneCascade cellRatio? paused? className? style? children?> as a full-bleed 2D canvas background modeling Amiga-class planar framebuffer compositing: an N-bit image is stored as N separate full-resolution 1-bit bitplanes, and the visible pixel value is the sum of each plane's binary contribution at its place value. Sample ONE static scalar field once per container size — two octaves of hashed value noise (a coarse octave at wavelength minGridDim/3.2 blended 0.7, a finer octave at minGridDim/9 blended 0.3, minGridDim = min(cols,rows) of the sample grid) — and quantize each cell to a 4-bit value (0-15, 16 levels). Sample grid: cell = clamp(round(containerMinDim/96), 4, 12) px, cols/rows = ceil(width|height / cell); render each cell as a flat unsmoothed cell x cell rect (no interpolation) so every plane's threshold edge stays a hard boundary. MSB-FIRST ARRIVAL IS THE LOAD-BEARING DECISION: planes arrive in place-value order 8, 4, 2, 1 (NOT LSB-first) — real progressive bit-plane transmission (fax/plotter preview passes) sends the most significant plane first because only it reconstructs a legible coarse silhouette on its own; sending the LSB first would just be spatial noise. Plane 0 (place value 8) arrives at t=0, plane 1 (value 4) at t=350ms, plane 2 (value 2) at t=700ms, plane 3 (value 1, the finest bit) at t=1050ms; each arrival ANDs one more high bit into the visible reconstruction (reconstructed = fieldValue & maskOfArrivedBits), so the already-revealed silhouette edge never moves, only finer bands fill in around it. Full stack holds 900ms once complete, then the accumulator clears and plane 0 lands again: cycle length 350*4 + 900 = 2300ms, unbounded loop, zero input. LANDING FLASH SOLVES A REAL ALIASING BUG: within a single plane's 350ms window cell states are static (a real compositor doesn't blend), so a bare step function makes two arbitrary real-time samples that land in the same window pixel-identical, which fails the alive-at-rest checkpoint rule; every cell touched by the most-recently-landed plane's bit gets an extra brightness pop (+alpha up to 0.35, decaying linearly over 220ms) that makes elapsed-time-since-landing part of every frame, not just which plane is current. Cell alpha = (reconstructed / 15), drawn as --foreground at that globalAlpha over a --background-cleared frame (never a literal color, never --ns-accent). Light theme: when only plane 0 has arrived (mask === 8) and the theme is light (per-mount luminance compare of --foreground vs --background via getComputedStyle, re-read on documentElement class MutationObserver), floor alpha at 0.25 so the first silhouette never washes out. prefers-reduced-motion and the paused prop both freeze on plane 2 of 4 just landed (3 bits revealed: 8+4+2, finest bit still pending) with the landing flash held near-max, not the full-stack hold and not plane 0 alone — the frame that most clearly shows a coarse silhouette with partially-resolved finer banding and one more step visibly pending. Tokens read via getComputedStyle(document.documentElement) with no paint before the first read; ResizeObserver rebuilds the field (debounced 150ms) and IntersectionObserver/visibilitychange pause the rAF loop off-screen/hidden; all observers and the rAF are torn down on unmount. Props: cellRatio (fraction of container's smaller dimension used before the 4-12px clamp, default 1/96), paused, className, style, children (rendered above the canvas for overlay content)."
  },
  "type": "registry:ui"
}