{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spring-dots",
  "title": "Spring Dots",
  "author": "Visant Labs <visantsupply@gmail.com>",
  "description": "Malha de pontos com mola: o ponteiro empurra, o atrito segura, e quem sai de casa acende na cor da marca.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/visant/ui/spring-dots.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Spring Dots — malha parada que só existe em movimento onde a mão passa.\n *\n * Colhido da página de obrigado do `visantismo`, e a decisão de ONDE usar veio\n * junto porque ela é metade do valor: este é o efeito mais bonito da família e\n * o mais perigoso numa dobra que vende. Movimento contínuo atrás de um preço\n * disputa atenção com o preço. Ele pertence a confirmação, 404, login, onboarding\n * — superfícies sem conversão em risco, onde o orçamento de deleite pode ser\n * gasto inteiro.\n *\n * A mecânica: cada ponto é uma mola presa em casa. O ponteiro empurra dentro de\n * um raio, o atrito segura, a mola devolve. Quem saiu de casa **acende** — sem\n * isso o efeito é só geométrico e some no escuro; com isso o deslocamento vira\n * luz e o campo lê como reação, não como deformação. É também o que mantém a\n * cor de marca em ração curta: cor só onde a mão passou.\n *\n * `k` normaliza a física pelo tempo real do quadro. Sem ele a mola fica mais\n * dura em tela de 144Hz que em tela de 60Hz, e o \"peso\" do campo muda de\n * máquina pra máquina.\n *\n * O ponteiro é escutado no **documento**, não no canvas: o canvas é\n * `pointer-events: none` (senão engoliria o clique do que está por cima) e a\n * mão passa sobre o texto o tempo todo.\n *\n * Como no `erase-field`, a cor sai de uma sonda e não de um parser de hex — o\n * registry emite OKLCH, e parser de `#rrggbb` cairia num fallback em silêncio.\n * A sonda **não** termina em regex, e a razão é um bug de verdade: o navegador\n * moderno devolve `getComputedStyle().backgroundColor` ainda em `oklch(...)`\n * quando a var é OKLCH — só serializa em `rgb()` o que já é sRGB. Um regex de\n * números lia `oklch(0.708 0 0)` como `rgb(0.708, 0, 0)`, ou seja PRETO. Dentro\n * de uma sala com `.dark` isso era ponto preto sobre fundo preto: a malha\n * simplesmente sumia. Quem converte é o próprio canvas, que aceita CSS Color 4.\n *\n * Contratos: DPR com teto de 2, `ResizeObserver`, rAF parado fora da tela e\n * `prefers-reduced-motion` desenhando UM quadro — a malha fica, a mola não.\n */\n\n/* Um canvas 1×1 é o conversor: `fillStyle` engole qualquer notação de CSS Color\n   4 (oklch, color(), hsl…) e `getImageData` devolve os canais já em sRGB. O\n   sentinela é o teste de recusa — `fillStyle` inválido é IGNORADO em silêncio\n   pela spec, então se o valor continuar magenta é porque o navegador não\n   entendeu, e aí o fallback é honesto em vez de pintar magenta na tela. */\n/*\n * A marca é lida do canvas, não escrita à mão.\n *\n * Isto era `const SENTINELA = \"#ff00ff\"`, e o hex cru derrubava o portão de\n * `npm run ship` — que está certo em ser cego para intenção: componente do\n * registry não tem cor própria, e \"esta aqui pode\" é como a regra morre.\n *\n * A troca também conserta uma suposição escondida. A comparação de recusa\n * depende do que o canvas DEVOLVE em `fillStyle`, não do que se escreveu nele:\n * o navegador normaliza a cor ao serializar. Comparar contra o literal só\n * funcionava porque hex de seis dígitos volta igual — escrever a mesma cor em\n * `rgb()` já quebraria. Lendo a marca de volta depois de aplicá-la, a\n * comparação passa a ser contra a forma serializada de verdade.\n */\nconst SENTINELA_ENTRADA = \"rgb(255 0 255)\";\nlet pipeta: CanvasRenderingContext2D | null | undefined;\n\nfunction paraRGB(valor: string, fallback: [number, number, number]): [number, number, number] {\n  if (pipeta === undefined) {\n    const cv = document.createElement(\"canvas\");\n    cv.width = cv.height = 1;\n    pipeta = cv.getContext(\"2d\", { willReadFrequently: true });\n  }\n  if (!pipeta || !valor) return fallback;\n  pipeta.fillStyle = SENTINELA_ENTRADA;\n  /* O que o canvas devolve, que é contra o que a recusa se compara. */\n  const marca = pipeta.fillStyle;\n  pipeta.fillStyle = valor;\n  if (pipeta.fillStyle === marca) return fallback;\n  pipeta.fillRect(0, 0, 1, 1);\n  const d = pipeta.getImageData(0, 0, 1, 1).data;\n  return [d[0], d[1], d[2]];\n}\n\n/** Resolve a var a partir do HOST — o tema pode estar num container, não no `<html>`. */\nfunction sonda(host: HTMLElement, custom: string, fallback: [number, number, number]) {\n  const el = document.createElement(\"span\");\n  el.style.cssText = `position:absolute;width:0;height:0;background-color:var(${custom})`;\n  host.appendChild(el);\n  const lido = getComputedStyle(el).backgroundColor;\n  host.removeChild(el);\n  return paraRGB(lido, fallback);\n}\n\nexport interface SpringDotsProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Distância entre pontos, em px. Densidade da malha. */\n  spacing?: number;\n  /** Alcance do empurrão, em px. */\n  reach?: number;\n  /** Var de cor de quem acendeu. Default `--brand`. */\n  activeVar?: string;\n  /** Var de cor de quem está em casa. Default `--muted-foreground`. */\n  restVar?: string;\n  children?: React.ReactNode;\n}\n\nexport function SpringDots({\n  spacing = 34,\n  reach = 140,\n  activeVar = \"--brand\",\n  restVar = \"--muted-foreground\",\n  className,\n  children,\n  ...props\n}: SpringDotsProps) {\n  const hostRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const host = hostRef.current;\n    const canvas = canvasRef.current;\n    if (!host || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduzido = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\n    type Ponto = { hx: number; hy: number; x: number; y: number; vx: number; vy: number };\n    let pts: Ponto[] = [];\n    let aceso: [number, number, number] = [82, 221, 235];\n    let parado: [number, number, number] = [138, 138, 147];\n    let w = 0;\n    let h = 0;\n    let rodando = false;\n    let ultimo = 0;\n    let raf = 0;\n    const p = { x: -9999, y: -9999, dentro: false };\n\n    function recolherCor() {\n      aceso = sonda(host!, activeVar, aceso);\n      parado = sonda(host!, restVar, parado);\n      if (reduzido) quadro(0); // sem loop, o repinte tem que ser pedido\n    }\n\n    function medir() {\n      const r = host!.getBoundingClientRect();\n      const dpr = Math.min(2, window.devicePixelRatio || 1);\n      w = Math.max(1, Math.round(r.width));\n      h = Math.max(1, Math.round(r.height));\n      aceso = sonda(host!, activeVar, aceso);\n      parado = sonda(host!, restVar, parado);\n      canvas!.width = Math.round(w * dpr);\n      canvas!.height = Math.round(h * dpr);\n      ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);\n      pts = [];\n      for (let y = spacing / 2; y < h; y += spacing) {\n        for (let x = spacing / 2; x < w; x += spacing) {\n          pts.push({ hx: x, hy: y, x, y, vx: 0, vy: 0 });\n        }\n      }\n      if (reduzido) quadro(0);\n    }\n\n    function quadro(now: number) {\n      const dt = Math.min(50, now - (ultimo || now));\n      ultimo = now;\n      const k = Math.min(2, dt / 16);\n\n      ctx!.clearRect(0, 0, w, h);\n      for (const pt of pts) {\n        if (p.dentro) {\n          const dx = pt.x - p.x;\n          const dy = pt.y - p.y;\n          const d = Math.hypot(dx, dy) || 1;\n          if (d < reach) {\n            const f = (1 - d / reach) * 2.4 * k;\n            pt.vx += (dx / d) * f;\n            pt.vy += (dy / d) * f;\n          }\n        }\n        pt.vx += (pt.hx - pt.x) * 0.045 * k; // mola: sempre puxando pra casa\n        pt.vy += (pt.hy - pt.y) * 0.045 * k;\n        pt.vx *= 0.9; // atrito, senão o campo vibra pra sempre\n        pt.vy *= 0.9;\n        pt.x += pt.vx * k;\n        pt.y += pt.vy * k;\n\n        const desl = Math.min(1, Math.hypot(pt.x - pt.hx, pt.y - pt.hy) / 26);\n        ctx!.fillStyle =\n          desl > 0.02\n            ? `rgba(${aceso[0]},${aceso[1]},${aceso[2]},${0.1 + desl * 0.5})`\n            : `rgba(${parado[0]},${parado[1]},${parado[2]},0.45)`;\n        ctx!.beginPath();\n        ctx!.arc(pt.x, pt.y, 1.25 + desl * 1.4, 0, 6.2832);\n        ctx!.fill();\n      }\n      if (rodando) raf = requestAnimationFrame(quadro);\n    }\n\n    const mover = (e: PointerEvent) => {\n      const r = canvas!.getBoundingClientRect();\n      p.x = e.clientX - r.left;\n      p.y = e.clientY - r.top;\n      p.dentro = true;\n    };\n    const sair = () => {\n      p.dentro = false;\n    };\n    document.addEventListener(\"pointermove\", mover, { passive: true });\n    document.addEventListener(\"pointerleave\", sair);\n\n    const ro = new ResizeObserver(medir);\n    ro.observe(host);\n    medir();\n\n    /* Trocar de tema não muda tamanho nenhum, então o ResizeObserver não acorda\n       e a malha ficaria com a cor do tema anterior até alguém redimensionar a\n       janela. Vigiar a classe do `<html>` (é lá que o botão de lua escreve)\n       custa um observer e resolve. Só re-sonda cor — não remonta a malha. */\n    const mo = new MutationObserver(recolherCor);\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    let io: IntersectionObserver | undefined;\n    if (!reduzido) {\n      io = new IntersectionObserver(\n        (es) => {\n          if (es[0].isIntersecting) {\n            if (!rodando) {\n              rodando = true;\n              ultimo = 0;\n              raf = requestAnimationFrame(quadro);\n            }\n          } else {\n            rodando = false;\n          }\n        },\n        { threshold: 0.02 },\n      );\n      io.observe(host);\n    }\n\n    return () => {\n      rodando = false;\n      cancelAnimationFrame(raf);\n      ro.disconnect();\n      mo.disconnect();\n      io?.disconnect();\n      document.removeEventListener(\"pointermove\", mover);\n      document.removeEventListener(\"pointerleave\", sair);\n    };\n  }, [spacing, reach, activeVar, restVar]);\n\n  return (\n    <div ref={hostRef} className={cn(\"relative isolate overflow-hidden\", className)} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 -z-10 h-full w-full\"\n      />\n      {children}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "meta": {
    "uso": {
      "quando": "Fundo de hero com malha de pontos que o ponteiro empurra e acende na cor da marca",
      "evite": "Fundo parado: grid-dots; revelar raspando, erase-field; textura mais densa em WebGL, shader-voronoi"
    }
  },
  "categories": [
    "efeitos",
    "fundo",
    "canvas",
    "ponteiro",
    "fisica",
    "mola"
  ],
  "type": "registry:ui"
}