{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "background-ascii-wake",
  "title": "Background ASCII Wake",
  "description": "Full-bleed monospace cursor-trail field: a sparse ambient scatter at rest, with the pointer dragging a decaying character comet whose length and brightness depend on how fast it moves.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/background-ascii-wake/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\n\n// ---------------------------------------------------------------------------\n// WakeGlyph — a full-bleed monospace character-comet field. A sparse, static\n// ambient scatter sits at rest; the pointer stamps a \"heat\" value into the\n// cells it passes over, and each stamped cell decays back toward the ambient\n// floor at a rate fixed at stamp time — a fast pass stamps many cells at a\n// low, quick-decaying heat (a long, thin, short-lived wake), a slow pass\n// stamps fewer cells at high heat with a slow decay rate (a fat, lingering\n// blob). Direct-DOM rAF over a persistent Float32Array grid, glyph density\n// mapped from an ASCII ramp, theme-aware ink read via getComputedStyle.\n// ---------------------------------------------------------------------------\n\nconst RAMP = \" .:-=+*#%@\";\n\nexport interface WakeGlyphProps {\n  /** grid cell size in px */\n  cellSize?: number;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\n// deterministic PRNG so the ambient scatter is stable within a session\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a |= 0;\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\nexport function WakeGlyph({ cellSize = 12, className = \"\" }: WakeGlyphProps) {\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    let fg = getComputedStyle(canvas).color;\n    const monoFont =\n      getComputedStyle(document.documentElement).getPropertyValue(\"--font-mono\") ||\n      \"ui-monospace, monospace\";\n\n    let dpr = 1;\n    let cols = 0;\n    let rows = 0;\n    let heat: Float32Array = new Float32Array(0);\n    let rate: Float32Array = new Float32Array(0);\n    let ambient: Float32Array = new Float32Array(0);\n    let raf = 0;\n    let last = 0;\n    const pointer = { x: -1, y: -1, t: 0, has: false };\n\n    const resize = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const { width, height } = canvas.getBoundingClientRect();\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      ctx.font = `${cellSize * 0.85}px ${monoFont}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n\n      cols = Math.max(1, Math.ceil(width / cellSize));\n      rows = Math.max(1, Math.ceil(height / cellSize));\n      const n = cols * rows;\n      heat = new Float32Array(n);\n      rate = new Float32Array(n).fill(1.4);\n      ambient = new Float32Array(n);\n\n      // sparse, near-silent ground state: a static seeded scatter of faint cells\n      const rand = mulberry32(0xa53f9c1 ^ (cols * 71 + rows));\n      for (let i = 0; i < n; i++) {\n        if (rand() < 0.035) ambient[i] = 0.08 + rand() * 0.14;\n      }\n    };\n\n    const draw = () => {\n      const { width, height } = canvas.getBoundingClientRect();\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = fg;\n      for (let gy = 0; gy < rows; gy++) {\n        for (let gx = 0; gx < cols; gx++) {\n          const idx = gy * cols + gx;\n          const lum = Math.max(ambient[idx]!, heat[idx]!);\n          if (lum <= 0.03) continue;\n          const ch = RAMP[Math.min(RAMP.length - 1, Math.floor(lum * (RAMP.length - 1)))];\n          if (ch === \" \") continue;\n          ctx.globalAlpha = Math.min(1, 0.15 + lum * 0.85);\n          ctx.fillText(ch, gx * cellSize + cellSize / 2, gy * cellSize + cellSize / 2);\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    // stamp a soft circular blot of heat around a grid cell, recording the\n    // decay rate (higher = faster fade) that this pass's speed implies.\n    const stamp = (px: number, py: number, radiusCells: number, decayRate: number) => {\n      const cx = px / cellSize;\n      const cy = py / cellSize;\n      const r = Math.max(0.6, radiusCells);\n      const minGx = Math.max(0, Math.floor(cx - r));\n      const maxGx = Math.min(cols - 1, Math.ceil(cx + r));\n      const minGy = Math.max(0, Math.floor(cy - r));\n      const maxGy = Math.min(rows - 1, Math.ceil(cy + r));\n      for (let gy = minGy; gy <= maxGy; gy++) {\n        for (let gx = minGx; gx <= maxGx; gx++) {\n          const d = Math.hypot(gx + 0.5 - cx, gy + 0.5 - cy);\n          if (d > r) continue;\n          const falloff = 1 - d / r;\n          const idx = gy * cols + gx;\n          if (falloff > heat[idx]!) {\n            heat[idx] = falloff;\n            rate[idx] = decayRate;\n          }\n        }\n      }\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      const x = e.clientX - rect.left;\n      const y = e.clientY - rect.top;\n      const now = performance.now();\n      if (pointer.has) {\n        const dx = x - pointer.x;\n        const dy = y - pointer.y;\n        const dist = Math.hypot(dx, dy);\n        const dt = Math.max(1, now - pointer.t);\n        const speed = dist / dt; // px/ms\n\n        // velocity-dependent stamp: fast = thin+quick-fading, slow = fat+lingering\n        const radiusCells = Math.max(0.6, Math.min(2.6, 2.4 / (1 + speed * 1.6)));\n        const decayRate = Math.max(0.8, Math.min(4.5, 1.0 + speed * 3.2));\n\n        // sample along the path so a fast pass doesn't leave gaps between frames\n        const steps = Math.max(1, Math.ceil(dist / (cellSize * 0.5)));\n        for (let s = 1; s <= steps; s++) {\n          const t = s / steps;\n          stamp(pointer.x + dx * t, pointer.y + dy * t, radiusCells, decayRate);\n        }\n      } else {\n        stamp(x, y, 1.4, 1.4);\n      }\n      pointer.x = x;\n      pointer.y = y;\n      pointer.t = now;\n      pointer.has = true;\n    };\n\n    const onPointerLeave = () => {\n      pointer.has = false;\n    };\n\n    const loop = (now: number) => {\n      const dt = last ? Math.min(64, now - last) / 1000 : 1 / 60;\n      last = now;\n      for (let i = 0; i < heat.length; i++) {\n        if (heat[i]! > 0) {\n          heat[i]! -= rate[i]! * dt;\n          if (heat[i]! < 0) heat[i] = 0;\n        }\n      }\n      draw();\n      raf = requestAnimationFrame(loop);\n    };\n\n    resize();\n    if (reduced) {\n      draw(); // ambient-only static frame; pointer wake is skipped entirely\n    } else {\n      canvas.addEventListener(\"pointermove\", onPointerMove);\n      canvas.addEventListener(\"pointerleave\", onPointerLeave);\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 (reduced) draw();\n      }, 150);\n    };\n    window.addEventListener(\"resize\", onResize);\n\n    // the site's theme toggle flips a `.dark` class on <html> live, with no\n    // remount — watch it so the glyph color updates without a page reload\n    const themeObserver = new MutationObserver(() => {\n      fg = getComputedStyle(canvas).color;\n      if (reduced) draw();\n    });\n    themeObserver.observe(document.documentElement, {\n      attributes: true,\n      attributeFilter: [\"class\"],\n    });\n\n    return () => {\n      cancelAnimationFrame(raf);\n      if (resizeTimer) clearTimeout(resizeTimer);\n      themeObserver.disconnect();\n      window.removeEventListener(\"resize\", onResize);\n      canvas.removeEventListener(\"pointermove\", onPointerMove);\n      canvas.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [cellSize]);\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-wake.tsx"
    }
  ],
  "meta": {
    "collection": "core",
    "tags": [
      "background",
      "ascii",
      "cursor",
      "canvas",
      "trail"
    ],
    "instruction": "Build <WakeGlyph cellSize? className?> as a full-bleed <canvas> over a persistent per-cell grid, not a stateless proximity glow. STATE: two parallel Float32Arrays sized cols*rows (cols/rows from container size divided by cellSize, default 12px) — `ambient`, a static seeded scatter generated once per resize (~3.5% of cells set to a low 0.08-0.22 value, deterministic per grid size via a small PRNG) representing the sparse, near-silent ground state, and `heat`/`rate`, the live wake: heat decays every frame by `rate * dt` per cell, where `rate` was fixed at the moment that specific cell was last stamped rather than being a single global constant. STAMPING: on pointermove, compute distance and elapsed time since the previous move to get a speed in px/ms, then derive both a stamp radius (in cells) and a decay rate from that speed with inverse relationships — fast movement yields a SMALL radius and a HIGH decay rate (many cells lit briefly and thinly along the path), slow movement yields a LARGE radius and a LOW decay rate (fewer stamps but each one fat and lingering) — this is the \"per-cell decay with velocity dependence\" the trail is built on. Because a fast pointer move can skip several grid cells between two consecutive pointermove events, the path between the previous and current point is sampled in sub-steps (spaced roughly cellSize/2 apart) and each sample stamps its own circular falloff blot, so the wake has no gaps at high speed. A stamp only raises a cell's heat (`Math.max`), never lowers it, and carries its rate along only when it does. RENDER: every frame, each cell's displayed luminance is `Math.max(ambient[i], heat[i])` mapped through the shared \" .:-=+*#%@\" density ramp exactly as background-ascii-dither does, with alpha scaled to luminance; cells at or below the ramp's blank threshold are skipped entirely rather than drawn as an empty glyph, which is what keeps a several-thousand-cell grid affordable to redraw every frame. Glyph ink is read once via getComputedStyle(canvas).color (theme-aware, never a hardcoded hex) and the canvas font uses the live --font-mono custom property rather than a literal family string. Window resize is debounced 150ms and regenerates the whole grid (new dimensions invalidate the old ambient/heat arrays outright — there is no cross-resize cell mapping). REDUCED MOTION: pointer listeners are never attached and no rAF loop starts; a single frame of the ambient scatter alone is painted once, so the component is inert but never blank or crashing."
  },
  "type": "registry:ui"
}