{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"signal-field","type":"registry:component","title":"Signal Field","description":"An ambient glyph field that the pointer quiets, not creates.","dependencies":[],"categories":["motion","canvas","ambient","pointer","background"],"files":[{"path":"components/lab/signal-field/signal-field.tsx","target":"components/lab/signal-field/signal-field.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport \"./signal-field.css\";\n\nexport type SignalFieldMode = \"glyph\" | \"dither\" | \"pixel\";\n\nexport type SignalFieldProps = {\n  /** Character ramp, quietest first. */\n  glyphs?: string;\n  /** Grid cell size in CSS pixels. */\n  cell?: number;\n  /** 0–1 ambient resolve level at rest. */\n  density?: number;\n  /** 0–1 contrast curve; higher resolves more of the field. */\n  intensity?: number;\n  /** Suppression radius around the pointer, in cells. 0 disables it. */\n  pointerRadius?: number;\n  /** Time scale for the underlying noise. 0 freezes the field. */\n  speed?: number;\n  /** How cells resolve: characters, halftone dots or solid fragments. */\n  mode?: SignalFieldMode;\n  /**\n   * Ink for the densest cells, as a hex value. Without it the field\n   * reads the element's own computed `color`.\n   */\n  accent?: string;\n  /** Changes the noise field's composition. Same seed, same field. */\n  seed?: number;\n  /** Freeze the field externally; the last composed frame stays. */\n  paused?: boolean;\n  className?: string;\n};\n\nconst BANDS = 7;\nconst BAYER = [\n  [0, 8, 2, 10],\n  [12, 4, 14, 6],\n  [3, 11, 1, 9],\n  [15, 7, 13, 5],\n].map((row) => row.map((value) => (value + 0.5) / 16));\n\n/* One time origin per page, so fields mounted together share a phase. */\nlet SHARED_EPOCH = 0;\n\nfunction hash3(x: number, y: number, z: number, seed: number): number {\n  let h = seed;\n  h = Math.imul(h ^ (x | 0), 0x27d4eb2d);\n  h = Math.imul(h ^ (y | 0), 0x165667b1);\n  h = Math.imul(h ^ (z | 0), 0x9e3779b9);\n  h ^= h >>> 15;\n  return ((h >>> 0) % 100000) / 100000;\n}\n\nfunction smooth(edge: number): number {\n  return edge * edge * (3 - 2 * edge);\n}\n\nfunction lattice(gx: number, gy: number, gz: number, seed: number): number {\n  const x0 = Math.floor(gx);\n  const y0 = Math.floor(gy);\n  const fx = gx - x0;\n  const fy = gy - y0;\n  const ux = smooth(fx);\n  const uy = smooth(fy);\n  const z0 = Math.floor(gz);\n  const fz = gz - z0;\n  const corner = (dx: number, dy: number): number => {\n    const a = hash3(x0 + dx, y0 + dy, z0, seed);\n    const b = hash3(x0 + dx, y0 + dy, z0 + 1, seed);\n    return a + (b - a) * fz;\n  };\n  const top = corner(0, 0) * (1 - ux) + corner(1, 0) * ux;\n  const bottom = corner(0, 1) * (1 - ux) + corner(1, 1) * ux;\n  return top * (1 - uy) + bottom * uy;\n}\n\nfunction parseColor(value: string): [number, number, number] {\n  const match = value.match(/(\\d+(?:\\.\\d+)?)/g);\n  if (!match || match.length < 3) return [20, 20, 24];\n  return [Number(match[0]), Number(match[1]), Number(match[2])];\n}\n\nfunction hexToRgb(hex: string): [number, number, number] | null {\n  const raw = hex.replace(\"#\", \"\");\n  const full =\n    raw.length === 3\n      ? raw\n          .split(\"\")\n          .map((ch) => ch + ch)\n          .join(\"\")\n      : raw;\n  if (!/^[0-9a-fA-F]{6}$/.test(full)) return null;\n  const n = parseInt(full, 16);\n  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n/**\n * SIGNAL FIELD — an ambient glyph field that the pointer quiets.\n *\n * One Canvas 2D primitive. A low-resolution grid resolves into density\n * glyphs, ordered-halftone dots or sparse pixel fragments. The field is\n * already alive at rest; the pointer subtracts density in a soft radius,\n * so attention reads as a quieting rather than a gimmick that only\n * exists while the pointer moves.\n *\n * Engine contract: at most one rAF loop, running only while the canvas\n * is in the viewport, the tab is visible, motion is allowed and the\n * host has not paused it. Cells repaint only when their glyph or level\n * changes; glyphs are pre-rendered sprites, so a frame costs a grid of\n * integer field evaluations plus a few drawImage calls. Geometry\n * coarsens on narrow viewports, DPR is capped, the paint rate adapts to\n * cell count, and touch pointers get no pointer field.\n */\nexport function SignalField({\n  glyphs = \"·:+*#\",\n  cell = 14,\n  density = 0.4,\n  intensity = 0.5,\n  pointerRadius = 9,\n  speed = 1,\n  mode = \"glyph\",\n  accent,\n  seed = 1,\n  paused = false,\n  className,\n}: SignalFieldProps) {\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  /* Props that only affect appearance are read through a ref so inline\n     values never rebind the engine mid-paint. */\n  const lookRef = useRef({ density, intensity, accent });\n\n  useEffect(() => {\n    lookRef.current = { density, intensity, accent };\n  });\n\n  useEffect(() => {\n    const el = canvasRef.current;\n    if (!el) return;\n    const ctx0 = el.getContext(\"2d\");\n    if (!ctx0) return;\n    /* aliased after the null checks so the nested painters below keep\n       their non-null types */\n    const canvas = el;\n    const ctx = ctx0;\n    lookRef.current = { density, intensity, accent };\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    const coarse = window.matchMedia(\"(pointer: coarse)\");\n\n    let width = 1;\n    let height = 1;\n    let cols = 1;\n    let rows = 1;\n    let step = cell;\n    let dpr = 1;\n    let sprite: HTMLCanvasElement | null = null;\n    let lastPaint = new Int32Array(1);\n    const inkCache: string[] = [];\n    const pointerCell = { x: -9999, y: -9999 };\n    const pointerClient = { x: -9999, y: -9999 };\n    let inView = false;\n    let raf = 0;\n    const clockStart = SHARED_EPOCH || (SHARED_EPOCH = performance.now());\n    let frameMs = 1000 / 24;\n    let nextPaintAt = 0;\n\n    const rgb: [number, number, number] = accent\n      ? (hexToRgb(accent) ?? parseColor(getComputedStyle(el).color))\n      : parseColor(getComputedStyle(el).color);\n\n    const inkFor = (t: number): string => {\n      const q = Math.min(12, Math.max(0, Math.round(t * 12)));\n      const cached = inkCache[q];\n      if (cached !== undefined) return cached;\n      const alpha = Math.min(1, 0.09 + 0.68 * (q / 12));\n      const built = `rgba(${rgb[0]}, ${rgb[1]}, ${rgb[2]}, ${alpha.toFixed(3)})`;\n      inkCache[q] = built;\n      return built;\n    };\n\n    function buildSprites(): void {\n      sprite = document.createElement(\"canvas\");\n      const size = Math.ceil(step * dpr);\n      sprite.width = Math.max(1, size * glyphs.length);\n      sprite.height = Math.max(1, size * BANDS);\n      const sctx = sprite.getContext(\"2d\");\n      if (!sctx) return;\n      sctx.font = `${Math.round(size * 0.94)}px ui-monospace, \"SF Mono\", \"Cascadia Mono\", Consolas, \"Liberation Mono\", Menlo, monospace`;\n      sctx.textAlign = \"center\";\n      sctx.textBaseline = \"middle\";\n      for (let g = 0; g < glyphs.length; g++) {\n        for (let b = 0; b < BANDS; b++) {\n          sctx.fillStyle = inkFor(b / (BANDS - 1));\n          sctx.fillText(\n            glyphs.charAt(g),\n            size * g + size / 2,\n            size * b + size * 0.55,\n          );\n        }\n      }\n    }\n\n    function measure(): void {\n      /* Never pin an inline size on the canvas: the stylesheet stays\n         authoritative, so a later layout change can give the field room\n         again. A zero-sized field paints nothing and stays quiet. */\n      canvas.style.width = \"\";\n      canvas.style.height = \"\";\n      const box = canvas.getBoundingClientRect();\n      width = Math.max(0, Math.round(box.width));\n      height = Math.max(0, Math.round(box.height));\n      if (width < 1 || height < 1) {\n        canvas.width = 0;\n        canvas.height = 0;\n        cols = 0;\n        rows = 0;\n        lastPaint = new Int32Array(0);\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n      step = width < 760 ? Math.max(cell, 16) : cell;\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      canvas.style.width = `${width}px`;\n      canvas.style.height = `${height}px`;\n      cols = Math.ceil(width / step) + 1;\n      rows = Math.ceil(height / step) + 1;\n      frameMs = cols * rows > 4000 ? 1000 / 15 : 1000 / 24;\n      nextPaintAt = 0;\n      lastPaint = new Int32Array(cols * rows).fill(-1);\n      buildSprites();\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      paint(performance.now(), true);\n    }\n\n    function field(\n      x: number,\n      y: number,\n      clock: number,\n      tNow: number,\n    ): number {\n      const look = lookRef.current;\n      const nq = Math.floor(clock * 8) / 8;\n      const gx = (x / step) * 0.055;\n      const gy = (y / step) * 0.055;\n      let v =\n        lattice(gx, gy, nq, seed) * 0.62 +\n        lattice((x / step) * 0.19, (y / step) * 0.19, nq * 1.6, seed + 7) * 0.33;\n      /* Contrast: a thresholded field reads as sampled material —\n         structure with quiet pockets — instead of uniform mush. The\n         gamma lift keeps the midtones (and therefore the ambient\n         texture) present at rest instead of collapsing to nothing. */\n      const offset = 0.62 - 0.34 * look.intensity;\n      const gain = 1 + 3 * look.intensity;\n      v = (v - offset) * gain;\n      v = v < 0 ? 0 : v > 1 ? 1 : v;\n      v = Math.pow(v, 0.62);\n      if (!reduced.matches && tNow > 0) {\n        if (\n          hash3(\n            Math.floor(x / step),\n            Math.floor(y / step),\n            Math.floor(nq * 3),\n            seed + 31,\n          ) < 0.05\n        ) {\n          v = Math.min(1, v + 0.2);\n        }\n      }\n      if (pointerRadius > 0 && pointerCell.x > -9000) {\n        const d = Math.hypot(pointerCell.x - x, pointerCell.y - y);\n        if (d < pointerRadius * step) {\n          v -= smooth(1 - d / (pointerRadius * step)) * 0.92;\n        }\n      }\n      v *= 0.45 + 1.25 * look.density;\n      v = v < 0 ? 0 : v > 1 ? 1 : v;\n      return v;\n    }\n\n    function paint(now: number, force = false): void {\n      if (!sprite || cols === 0 || rows === 0) return;\n      if (pointerRadius > 0 && pointerClient.x > -9000) {\n        const box = canvas.getBoundingClientRect();\n        pointerCell.x = pointerClient.x - box.left;\n        pointerCell.y = pointerClient.y - box.top;\n      }\n      const frozen = reduced.matches || paused || speed <= 0;\n      const clock = frozen\n        ? Math.floor(seed * 13)\n        : ((now - clockStart) / 24000) * speed + Math.floor(seed * 13);\n      const densityScale = width < 760 ? (mode === \"pixel\" ? 1 : 0.7) : 1;\n\n      const clearPrev = (index: number, c: number, r: number): void => {\n        if (lastPaint[index] === -1) return;\n        ctx.clearRect(c * step, r * step, step, step);\n        lastPaint[index] = -1;\n      };\n\n      for (let r = 0; r < rows; r++) {\n        for (let c = 0; c < cols; c++) {\n          const index = r * cols + c;\n          const x = c * step + step / 2;\n          const y = r * step + step / 2;\n          const v = field(x, y, clock, now - clockStart) * densityScale;\n          if (v <= 0.05) {\n            clearPrev(index, c, r);\n            continue;\n          }\n\n          if (mode === \"pixel\") {\n            const on = v > 0.5;\n            const strength = on ? Math.min(1, (v - 0.5) / 0.5) : 0;\n            const key = on ? 1 + Math.round(strength * 12) : 0;\n            if (!force && lastPaint[index] === key) continue;\n            clearPrev(index, c, r);\n            lastPaint[index] = key;\n            if (!on) continue;\n            const s = step * (0.24 + 0.6 * strength);\n            ctx.fillStyle = inkFor(strength * 0.9 + 0.1);\n            ctx.fillRect(x - s / 2, y - s / 2, s, s);\n            continue;\n          }\n\n          if (mode === \"dither\") {\n            const on = v > BAYER[r & 3][c & 3];\n            const key = on ? Math.min(11, 1 + Math.floor(v * 11)) : 0;\n            if (!force && lastPaint[index] === key) continue;\n            clearPrev(index, c, r);\n            lastPaint[index] = key;\n            if (!on) continue;\n            const size = step * (0.18 + 0.4 * v);\n            ctx.fillStyle = inkFor(Math.min(1, 0.35 + v * 0.65));\n            ctx.fillRect(x - size / 2, y - size / 2, size, size);\n            continue;\n          }\n\n          const band = Math.min(BANDS - 1, Math.floor(v * BANDS));\n          const glyph = Math.min(\n            glyphs.length - 1,\n            Math.floor(v * glyphs.length),\n          );\n          const key = glyph * BANDS + band;\n          if (!force && lastPaint[index] === key) continue;\n          clearPrev(index, c, r);\n          lastPaint[index] = key;\n          const sw = sprite.width / glyphs.length;\n          const sh = sprite.height / BANDS;\n          ctx.drawImage(\n            sprite,\n            glyph * sw,\n            band * sh,\n            sw,\n            sh,\n            c * step,\n            r * step,\n            step,\n            step,\n          );\n          continue;\n        }\n      }\n    }\n\n    const active = (): boolean =>\n      !reduced.matches && !paused && speed > 0 && inView && !document.hidden;\n\n    const tick = (now: number): void => {\n      raf = 0;\n      if (now < nextPaintAt) {\n        raf = window.requestAnimationFrame(tick);\n        return;\n      }\n      nextPaintAt = now + frameMs;\n      paint(now);\n      if (active()) raf = window.requestAnimationFrame(tick);\n    };\n\n    const schedule = (): void => {\n      if (raf || !inView || document.hidden || reduced.matches || paused) return;\n      if (speed <= 0) {\n        paint(performance.now(), true);\n        return;\n      }\n      raf = window.requestAnimationFrame(tick);\n    };\n\n    const stop = (): void => {\n      if (raf) {\n        window.cancelAnimationFrame(raf);\n        raf = 0;\n      }\n    };\n\n    const io = new IntersectionObserver((entries) => {\n      inView = entries.some((entry) => entry.isIntersecting);\n      if (inView) {\n        measure();\n        schedule();\n      } else {\n        stop();\n      }\n    });\n    io.observe(canvas);\n\n    const onVisibility = (): void => {\n      if (document.hidden) stop();\n      else schedule();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    const wantsPointer = pointerRadius > 0;\n    const onMove = (event: PointerEvent): void => {\n      if (event.pointerType === \"touch\" || coarse.matches) return;\n      pointerClient.x = event.clientX;\n      pointerClient.y = event.clientY;\n      schedule();\n    };\n    const onOut = (): void => {\n      pointerClient.x = -9999;\n      pointerClient.y = -9999;\n      pointerCell.x = -9999;\n      pointerCell.y = -9999;\n      schedule();\n    };\n    if (wantsPointer) {\n      window.addEventListener(\"pointermove\", onMove, { passive: true });\n      window.addEventListener(\"pointerout\", onOut);\n    }\n\n    let measureQueued = false;\n    const onResize = (): void => {\n      if (measureQueued) return;\n      measureQueued = true;\n      window.requestAnimationFrame(() => {\n        measureQueued = false;\n        measure();\n        schedule();\n      });\n    };\n    window.addEventListener(\"resize\", onResize);\n    const ro =\n      \"ResizeObserver\" in window ? new ResizeObserver(onResize) : null;\n    if (ro) ro.observe(canvas);\n\n    const onMotionChange = (): void => {\n      measure();\n      schedule();\n    };\n    reduced.addEventListener(\"change\", onMotionChange);\n\n    measure();\n    schedule();\n\n    return () => {\n      stop();\n      io.disconnect();\n      if (ro) ro.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      if (wantsPointer) {\n        window.removeEventListener(\"pointermove\", onMove);\n        window.removeEventListener(\"pointerout\", onOut);\n      }\n      window.removeEventListener(\"resize\", onResize);\n      reduced.removeEventListener(\"change\", onMotionChange);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [glyphs, cell, seed, pointerRadius, mode, speed, paused]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      className={`lab-signal-field${className ? ` ${className}` : \"\"}`}\n      aria-hidden=\"true\"\n      role=\"presentation\"\n      data-mode={mode}\n    />\n  );\n}\n"},{"path":"components/lab/signal-field/signal-field.css","target":"components/lab/signal-field/signal-field.css","type":"registry:file","content":"/* ============================================================\n   SIGNAL FIELD — self-contained styles.\n   The canvas fills its container; everything else is the host's job.\n   ============================================================ */\n\n.lab-signal-field {\n  display: block;\n  width: 100%;\n  height: 100%;\n  pointer-events: none;\n  /* The first composed frame arrives as a short fade on the element\n     itself, never as a half-drawn canvas. */\n  animation: lab-signal-field-in 640ms ease-out both;\n}\n\n@keyframes lab-signal-field-in {\n  from {\n    opacity: 0;\n  }\n  to {\n    opacity: 1;\n  }\n}\n\n.lab-signal-field[data-mode=\"glyph\"] {\n  font-smooth: never;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .lab-signal-field {\n    animation: none;\n  }\n}\n"}]}