{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"ascii-field","title":"ASCII Field","description":"A canvas field of glyphs that scramble around the cursor and settle back to rest.","dependencies":["gsap@^3.15.0","@gsap/react@^2.1.2"],"files":[{"path":"src/components/AsciiField/index.js","content":"export { default } from './AsciiField.jsx'\n","type":"registry:component","target":"components/ascii-field/index.js"},{"path":"src/components/AsciiField/AsciiField.jsx","content":"'use client'\n\nimport { useRef, useEffect } from 'react'\nimport gsap from 'gsap'\nimport { useGSAP } from '@gsap/react'\nimport './AsciiField.css'\n\ngsap.registerPlugin(useGSAP)\n\n/* =========================================================\n   AsciiField\n   A full-screen grid of ASCII glyphs, all resting at \"0\".\n   A circular cursor zone scrambles the characters: fastest at\n   the exact center of the cursor, slowing toward the\n   circumference, frozen outside the cursorRadius, and snapping back\n   to \"0\" the moment the cursor leaves.\n\n   Rendering is speed-proof: a static field of \"0\"s is\n   pre-rendered to an offscreen canvas and blitted in full every\n   frame (erasing the previous scramble wherever it was), then\n   only the glyphs inside the current circle are drawn on top —\n   so nothing can tear no matter how fast the pointer moves.\n\n   The loop runs on gsap.ticker; the cursor is smoothed with\n   gsap.quickTo. Radius / speed / color are live-tunable via the\n   control panel.\n   ========================================================= */\n\n// Printable ASCII glyphs (33..126) — the pool every cell samples from.\nconst GLYPHS = Array.from({ length: 126 - 33 + 1 }, (_, i) =>\n  String.fromCharCode(33 + i)\n)\nconst REST = 255 // sentinel index meaning \"resting\" — drawn as the typed rest char\n\nconst BASE_CELL_W = 9   // base cell width  — the aspect ratio the spacing keeps\nconst BASE_CELL_H = 16  // base cell height — 9:16, scaled together by \"spacing\"\nconst FONT_SIZE = 15    // px monospace glyph size (fixed — spacing widens the gaps)\nconst FALLOFF = 1.7     // >1 concentrates scramble speed toward the center\nconst FONT = `${FONT_SIZE}px ui-monospace, \"SF Mono\", Menlo, Consolas, monospace`\nconst BG = '#0a0b0e'   // default; the `background` prop overrides it\n\n/**\n * A canvas field of glyphs that scramble around the cursor and settle back\n * to rest. Fully controlled — every knob is a prop with a default.\n *\n *   <AsciiField cursorRadius={170} speed={26} opacity={0.4} spacing={1} />\n */\nexport default function AsciiField({\n  cursorRadius = 170,          // cursor influence, px\n  speed = 26,            // glyph changes per second at the centre\n  color = '#ffffff',     // glyph colour\n  opacity = 0.4,         // resting baseline opacity\n  spacing = 1,           // cell scale, keeps the 9:16 ratio\n  rest = '0',            // the resting glyph; '' leaves cells empty\n  background = BG,\n  className = '',\n  style,\n  ...rest_\n}) {\n  const canvasRef = useRef(null)\n  const rootRef = useRef(null)\n\n  /* Live parameters the render loop reads each frame without re-running setup.\n     Synced in an effect declared ahead of the two below, so a rebuild or a\n     baseline repaint triggered by the same render already sees the new values. */\n  const params = useRef({ cursorRadius, speed, color, opacity, spacing, rest, background })\n  useEffect(() => {\n    params.current = { cursorRadius, speed, color, opacity, spacing, rest, background }\n  })\n\n  /* Repaint the baseline when anything baked into it changes. The background\n     is part of the glyph sprite atlas, so a colour change has to rebuild\n     rather than wait for cells to turn over. */\n  const redrawStatic = useRef(() => {})\n  useEffect(() => {\n    redrawStatic.current()\n  }, [color, opacity, rest])\n\n  // Rebuild the whole grid (cell size, buffers, baseline) when spacing changes.\n  const rebuild = useRef(() => {})\n  useEffect(() => {\n    rebuild.current()\n  }, [spacing, background])\n\n  useGSAP(\n    () => {\n      const canvas = canvasRef.current\n      const ctx = canvas.getContext('2d')\n\n      // Offscreen baseline: the whole field rendered as \"0\"s.\n      const staticCanvas = document.createElement('canvas')\n      const sctx = staticCanvas.getContext('2d')\n\n      const cursor = { x: -9999, y: -9999 }\n      const xTo = gsap.quickTo(cursor, 'x', { duration: 0.2, ease: 'power3' })\n      const yTo = gsap.quickTo(cursor, 'y', { duration: 0.2, ease: 'power3' })\n\n      let cols = 0\n      let rows = 0\n      let w = 0\n      let h = 0\n      let dpr = 1\n      let cellW = BASE_CELL_W\n      let cellH = BASE_CELL_H\n      let glyphIdx = new Uint8Array(0)\n      let accum = new Float32Array(0)\n\n      // A random glyph that is never the current resting char, so the disc\n      // never flashes a stray \"resting\" glyph while it's scrambling.\n      const rand = () => {\n        const pool = GLYPHS.length\n        const g = Math.floor(Math.random() * pool)\n        const excl = GLYPHS.indexOf(params.current.rest)\n        return g === excl ? (g + 1) % pool : g\n      }\n\n      // Paint the static \"0\" baseline — centered in each cell, dimmed to the\n      // chosen opacity so the field can read as sparse instead of \"so full\".\n      const buildStatic = () => {\n        sctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n        sctx.font = FONT\n        sctx.textBaseline = 'middle'\n        sctx.textAlign = 'center'\n        sctx.fillStyle = params.current.background\n        sctx.fillRect(0, 0, w, h)\n        sctx.globalAlpha = params.current.opacity\n        sctx.fillStyle = params.current.color\n        const restChar = params.current.rest || ' '\n        for (let row = 0; row < rows; row++) {\n          const y = row * cellH + cellH / 2\n          for (let col = 0; col < cols; col++) sctx.fillText(restChar, col * cellW + cellW / 2, y)\n        }\n        sctx.globalAlpha = 1\n      }\n      redrawStatic.current = buildStatic\n\n      // Size everything to the viewport and rebuild the buffers + baseline.\n      const build = () => {\n        dpr = Math.min(window.devicePixelRatio || 1, 2)\n        w = rootRef.current.clientWidth\n        h = rootRef.current.clientHeight\n\n        /* Scale both cell dimensions by the same spacing factor → the gap\n           between glyphs grows horizontally and vertically at the 9:16 ratio.\n           Floored, because spacing=0 divides to Infinity and `new\n           Uint8Array(Infinity)` throws RangeError before anything renders. */\n        const sp = Math.max(0.05, params.current.spacing)\n        cellW = BASE_CELL_W * sp\n        cellH = BASE_CELL_H * sp\n        cols = Math.ceil(w / cellW)\n        rows = Math.ceil(h / cellH)\n\n        canvas.width = w * dpr\n        canvas.height = h * dpr\n        canvas.style.width = w + 'px'\n        canvas.style.height = h + 'px'\n        staticCanvas.width = w * dpr\n        staticCanvas.height = h * dpr\n\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n        ctx.font = FONT\n        ctx.textBaseline = 'middle'\n        ctx.textAlign = 'center'\n\n        const count = cols * rows\n        glyphIdx = new Uint8Array(count)\n        accum = new Float32Array(count)\n        glyphIdx.fill(REST)\n\n        buildStatic()\n      }\n      rebuild.current = build\n\n      const tick = (time, deltaMs) => {\n        const dt = Math.min(deltaMs, 50) / 1000\n        const { cursorRadius, speed, color } = params.current\n\n        // Blit the full baseline: instantly resets the entire field to \"0\",\n        // erasing wherever last frame's circle was — this is what makes fast\n        // movement tear-free.\n        ctx.drawImage(staticCanvas, 0, 0, w, h)\n\n        const cx0 = cursor.x\n        const cy0 = cursor.y\n        const c0 = Math.max(0, Math.floor((cx0 - cursorRadius) / cellW))\n        const c1 = Math.min(cols - 1, Math.ceil((cx0 + cursorRadius) / cellW))\n        const r0 = Math.max(0, Math.floor((cy0 - cursorRadius) / cellH))\n        const r1 = Math.min(rows - 1, Math.ceil((cy0 + cursorRadius) / cellH))\n        if (c1 < c0 || r1 < r0) return\n\n        const r2 = cursorRadius * cursorRadius\n        for (let row = r0; row <= r1; row++) {\n          const cy = row * cellH + cellH / 2\n          for (let col = c0; col <= c1; col++) {\n            const cx = col * cellW + cellW / 2\n            const dx = cx - cx0\n            const dy = cy - cy0\n            const d2 = dx * dx + dy * dy\n            if (d2 >= r2) continue\n\n            const f = Math.pow(1 - Math.sqrt(d2) / cursorRadius, FALLOFF)\n            const i = row * cols + col\n\n            // The moment a resting cell enters the disc, activate it with a\n            // random glyph. This makes the whole circle scramble on contact —\n            // independent of pointer speed — so a fast sweep can no longer\n            // leave un-flipped \"0\"s behind. Re-randomisation speed still falls\n            // off from center (fastest) to circumference (slowest).\n            if (glyphIdx[i] === REST) glyphIdx[i] = rand()\n\n            accum[i] += speed * f * dt\n            if (accum[i] >= 1) {\n              glyphIdx[i] = rand()\n              accum[i] -= Math.floor(accum[i])\n            }\n\n            // Overwrite the baseline \"0\" with the scrambled glyph (full\n            // opacity — the disc stays bright even when the field is dimmed).\n            // Cells inside the disc are always non-zero now, so this never\n            // re-draws (and blurs) a resting \"0\" over the crisp baseline.\n            ctx.fillStyle = params.current.background\n            ctx.fillRect(col * cellW, row * cellH, cellW, cellH)\n            ctx.fillStyle = color\n            ctx.fillText(GLYPHS[glyphIdx[i]], cx, cy)\n          }\n        }\n      }\n\n      build()\n\n      const onMove = (e) => {\n        const rect = rootRef.current.getBoundingClientRect()\n        xTo(e.clientX - rect.left)\n        yTo(e.clientY - rect.top)\n      }\n      const onLeave = () => {\n        xTo(-9999)\n        yTo(-9999)\n      }\n\n      /* The field was the one component that ignored reduced motion outright.\n         With it set, the static glyph grid is still drawn — the component is\n         not blank — but nothing scrambles and the ticker never starts. */\n      const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches\n\n      if (!still) {\n        window.addEventListener('mousemove', onMove)\n        rootRef.current.addEventListener('mouseleave', onLeave)\n        gsap.ticker.add(tick)\n      }\n      window.addEventListener('resize', build)\n\n      return () => {\n        window.removeEventListener('mousemove', onMove)\n        rootRef.current?.removeEventListener('mouseleave', onLeave)\n        window.removeEventListener('resize', build)\n        gsap.ticker.remove(tick)\n        redrawStatic.current = () => {}\n      }\n    },\n    { scope: rootRef }\n  )\n\n  return (\n    <div\n      className={`ascii-field ${className}`.trim()}\n      ref={rootRef}\n      style={{ background, ...style }}\n      {...rest_}\n    >\n      {/* Purely decorative: the glyphs carry no meaning to convey. */}\n      <canvas className=\"ascii-field__canvas\" ref={canvasRef} aria-hidden=\"true\" />\n    </div>\n  )\n}\n","type":"registry:component","target":"components/ascii-field/AsciiField.jsx"},{"path":"src/components/AsciiField/AsciiField.css","content":".ascii-field {\n  position: fixed;\n  inset: 0;\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n  background: #0a0b0e;\n  cursor: crosshair;\n}\n\n.ascii-field__canvas {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n","type":"registry:file","target":"components/ascii-field/AsciiField.css"},{"path":"src/components/AsciiField/AsciiField.d.ts","content":"import type { ComponentPropsWithoutRef, ReactElement } from 'react'\n\nexport interface AsciiFieldProps extends ComponentPropsWithoutRef<'div'> {\n  /** Reach of the pointer's influence, in px. */\n  cursorRadius?: number\n  /** Glyph changes per second at the centre of that reach. */\n  speed?: number\n  /** Glyph colour. */\n  color?: string\n  /** Resting baseline opacity, before the cursor touches a cell. */\n  opacity?: number\n  /** Cell scale, holding the 9:16 ratio. Floored just above zero. */\n  spacing?: number\n  /** The resting glyph. An empty string leaves cells blank. */\n  rest?: string\n  /** Fill behind the glyphs. */\n  background?: string\n}\n\ndeclare const AsciiField: (props: AsciiFieldProps) => ReactElement\nexport default AsciiField\n","type":"registry:lib","target":"components/ascii-field/AsciiField.d.ts"}],"type":"registry:component"}