{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-ascii-dither",
  "title": "Background ASCII Dither",
  "description": "Luminance-to-glyph canvas renderer: ASCII, Bayer-dither, and dot-matrix modes with cursor-proximity resolve.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/background-ascii-dither/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\nconst ASCII_RAMP = \" .:-=+*#%@\";\n// 4x4 Bayer matrix, normalized 0..1\nconst BAYER = [0, 8, 2, 10, 12, 4, 14, 6, 3, 11, 1, 9, 15, 7, 13, 5].map(\n  (v) => (v + 0.5) / 16\n);\n\ntype Mode = \"ascii\" | \"dither\" | \"dot\";\n\n// cheap flowing value-noise, no deps\nfunction noise(x: number, y: number, t: number): number {\n  const v =\n    Math.sin(x * 1.7 + t * 0.6) +\n    Math.sin(y * 2.3 - t * 0.4) +\n    Math.sin((x + y) * 1.1 + t * 0.25) +\n    Math.sin(Math.hypot(x - 6, y - 4) * 1.9 - t * 0.7);\n  return v / 8 + 0.5; // 0..1\n}\n\nexport function AsciiDitherMedia({\n  mode = \"ascii\",\n  src,\n  cellSize = 14,\n  cursorRadius = 140,\n  className = \"\",\n}: {\n  /** rendering style: \"ascii\" characters, \"dither\" pattern, or \"dot\" halftone */\n  mode?: Mode;\n  /** optional image URL; omit for the built-in animated noise field */\n  src?: string;\n  /** grid cell size in px */\n  cellSize?: number;\n  /** cursor-proximity resolve radius in px */\n  cursorRadius?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}) {\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    // glyph color from the surrounding theme; canvas bg stays transparent\n    let fg = \"\";\n    const readTokens = () => {\n      fg = getComputedStyle(canvas).color;\n    };\n    readTokens();\n\n    let cols = 0;\n    let rows = 0;\n    let dpr = 1;\n    let imageLum: Float32Array | null = null;\n    const cursor = { x: -1e4, y: -1e4, tx: -1e4, ty: -1e4 };\n    let raf = 0;\n    let t = 0;\n    // bumped on every sampleImage() dispatch so a late-resolving onload from\n    // a superseded resize (rapid window-resize) can detect it's stale and\n    // bail instead of writing imageLum sized to an out-of-date cols/rows\n    let sampleGen = 0;\n\n    const resize = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const { width, height } = canvas.getBoundingClientRect();\n      canvas.width = width * dpr;\n      canvas.height = height * dpr;\n      cols = Math.ceil(width / cellSize);\n      rows = Math.ceil(height / cellSize);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.font = `${cellSize * 0.85}px \"GeistMono\", ui-monospace, monospace`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      if (src) sampleImage();\n    };\n\n    // coalesce bursts of resize events (dragging a window edge fires dozens)\n    // into one recompute + one image refetch/redecode\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      }, 150);\n    };\n\n    const sampleImage = () => {\n      if (!src) return;\n      const gen = ++sampleGen;\n      const sampleCols = cols;\n      const sampleRows = rows;\n      const img = new Image();\n      img.crossOrigin = \"anonymous\";\n      img.onload = () => {\n        if (gen !== sampleGen) return; // superseded by a newer resize/sample\n        const buf = document.createElement(\"canvas\");\n        buf.width = sampleCols;\n        buf.height = sampleRows;\n        const bctx = buf.getContext(\"2d\");\n        if (!bctx) return;\n        // cover-fit the image into the grid\n        const scale = Math.max(sampleCols / img.width, sampleRows / img.height);\n        const w = img.width * scale;\n        const h = img.height * scale;\n        bctx.drawImage(img, (sampleCols - w) / 2, (sampleRows - h) / 2, w, h);\n        const data = bctx.getImageData(0, 0, sampleCols, sampleRows).data;\n        const lum = new Float32Array(sampleCols * sampleRows);\n        for (let i = 0; i < sampleCols * sampleRows; i++) {\n          lum[i] =\n            (0.2126 * data[i * 4] + 0.7152 * data[i * 4 + 1] + 0.0722 * data[i * 4 + 2]) / 255;\n        }\n        imageLum = lum;\n        if (reduced) draw(); // static sources still need one paint\n      };\n      img.src = src;\n    };\n\n    const draw = () => {\n      const { width, height } = canvas.getBoundingClientRect();\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = fg;\n      // ease cursor for a trailing resolve\n      cursor.x += (cursor.tx - cursor.x) * 0.12;\n      cursor.y += (cursor.ty - cursor.y) * 0.12;\n      const r2 = cursorRadius * cursorRadius;\n\n      for (let gy = 0; gy < rows; gy++) {\n        for (let gx = 0; gx < cols; gx++) {\n          // gamma deepens the noise floor: large areas go empty, crests pop.\n          // x-advection makes the field travel, not just undulate in place\n          let lum = imageLum\n            ? imageLum[gy * cols + gx]\n            : Math.pow(noise(gx * 0.35 + t * 0.22, gy * 0.35, t), 1.7);\n          const px = gx * cellSize + cellSize / 2;\n          const py = gy * cellSize + cellSize / 2;\n          // gaussian-ish cursor boost\n          const dx = px - cursor.x;\n          const dy = py - cursor.y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < r2 * 4) lum = Math.min(1, lum + Math.exp(-d2 / r2) * 0.7);\n\n          if (mode === \"ascii\") {\n            const ch = ASCII_RAMP[Math.floor(lum * (ASCII_RAMP.length - 1))];\n            if (ch !== \" \") {\n              ctx.globalAlpha = 0.25 + lum * 0.75;\n              ctx.fillText(ch, px, py);\n            }\n          } else if (mode === \"dither\") {\n            if (lum > BAYER[(gy % 4) * 4 + (gx % 4)]) {\n              ctx.globalAlpha = 0.9;\n              ctx.fillRect(px - 1, py - 1, 2, 2);\n            }\n          } else {\n            const rad = (lum * cellSize) / 2.4;\n            if (rad > 0.4) {\n              ctx.globalAlpha = 0.3 + lum * 0.7;\n              ctx.beginPath();\n              ctx.arc(px, py, rad, 0, Math.PI * 2);\n              ctx.fill();\n            }\n          }\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const loop = () => {\n      t += 0.028;\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    const onPointer = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      cursor.tx = e.clientX - rect.left;\n      cursor.ty = e.clientY - rect.top;\n    };\n    const onLeave = () => {\n      cursor.tx = -1e4;\n      cursor.ty = -1e4;\n    };\n\n    // theme flip only swaps the .dark class — re-read the ink, no remount\n    const mo = new MutationObserver(() => {\n      readTokens();\n      if (reduced) draw();\n    });\n    mo.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    resize();\n    window.addEventListener(\"resize\", onResize);\n    canvas.addEventListener(\"pointermove\", onPointer);\n    canvas.addEventListener(\"pointerleave\", onLeave);\n    if (reduced) {\n      draw(); // single static frame\n    } else {\n      raf = requestAnimationFrame(loop);\n    }\n    return () => {\n      cancelAnimationFrame(raf);\n      mo.disconnect();\n      if (resizeTimer) clearTimeout(resizeTimer);\n      window.removeEventListener(\"resize\", onResize);\n      canvas.removeEventListener(\"pointermove\", onPointer);\n      canvas.removeEventListener(\"pointerleave\", onLeave);\n    };\n  }, [mode, src, cellSize, cursorRadius]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      aria-hidden\n      className={`block h-full w-full text-foreground ${className}`}\n    />\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/background-ascii-dither.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "background",
      "ascii",
      "dither",
      "canvas",
      "cursor"
    ],
    "instruction": "A full-bleed canvas engine that maps a source (animated flowing noise by default, or an image) to a luminance-driven monochrome glyph grid with three modes: ASCII characters in a density ramp, 4x4 Bayer-matrix dithered pixels, and dot-matrix circles sized by brightness. The cursor brightens and resolves nearby cells with a Gaussian falloff and eased trailing. Direct-DOM rAF loop with no React state, theme-aware glyph color, single static frame under prefers-reduced-motion. Window resize is debounced (150ms) so dragging a window edge doesn't spam the grid recompute or (in image mode) refetch/redecode the source on every event; a generation counter discards any image decode superseded by a newer resize before it can write stale-sized data."
  },
  "type": "registry:ui"
}