{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ds-particle-field",
  "title": "Particle Field",
  "description": "Fundo de constelação (partículas conectadas por linhas) para hero sections, em canvas 2D com DPR limitado e pausa automática fora da viewport.",
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "@lema-ds/tokens"
  ],
  "files": [
    {
      "path": "components/ds/ds-particle-field.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/lib/utils\"\n\n// ── Types ──\n\nexport interface ParticleFieldProps\n  extends\n    React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof particleFieldVariants> {}\n\n// ── Variants ──\n\nexport const particleFieldVariants = cva(\n  \"pointer-events-none absolute inset-0 overflow-hidden\",\n  {\n    variants: {\n      tone: {\n        primary: \"[--pf-color:var(--primary)]\",\n        violet: \"[--pf-color:var(--highlight-violet)]\",\n        sky: \"[--pf-color:var(--highlight-sky)]\",\n        neutral: \"[--pf-color:var(--foreground)]\",\n      },\n      density: {\n        sm: \"\",\n        md: \"\",\n        lg: \"\",\n      },\n    },\n    defaultVariants: { tone: \"primary\", density: \"md\" },\n  }\n)\n\nconst DENSITY_CONFIG = {\n  sm: { maxParticles: 40, linkDistance: 90 },\n  md: { maxParticles: 70, linkDistance: 110 },\n  lg: { maxParticles: 110, linkDistance: 130 },\n} as const\n\ninterface Particle {\n  x: number\n  y: number\n  vx: number\n  vy: number\n  r: number\n  o: number\n}\n\n// ── Component ──\n\n/**\n * Constellation background (particles connected by lines) for hero\n * sections. Runs on 2D canvas with DPR capped at 2x, pauses via\n * `IntersectionObserver` when out of viewport and via `visibilitychange`\n * when the tab loses focus, and becomes a static frame (no RAF) under\n * `prefers-reduced-motion`.\n */\nexport const ParticleField = React.forwardRef<\n  HTMLDivElement,\n  ParticleFieldProps\n>(({ className, tone = \"primary\", density = \"md\", ...props }, ref) => {\n  const containerRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n\n  const setRefs = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      containerRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref)\n        (ref as React.RefObject<HTMLDivElement | null>).current = node\n    },\n    [ref]\n  )\n\n  React.useEffect(() => {\n    const container = containerRef.current\n    const canvas = canvasRef.current\n    if (!container || !canvas) return\n\n    const ctx = canvas.getContext(\"2d\", { alpha: true })\n    if (!ctx) return\n\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches\n    const { maxParticles, linkDistance } = DENSITY_CONFIG[density ?? \"md\"]\n    const color =\n      getComputedStyle(container).getPropertyValue(\"--pf-color\").trim() ||\n      \"currentColor\"\n\n    let particles: Particle[] = []\n    let width = 0\n    let height = 0\n    let dpr = 1\n    let frameId = 0\n    let running = false\n\n    const seed = () => {\n      const count = Math.min(\n        maxParticles,\n        Math.max(12, Math.floor((width * height) / 9000))\n      )\n      particles = Array.from({ length: count }, () => ({\n        x: Math.random() * width,\n        y: Math.random() * height,\n        vx: (Math.random() - 0.5) * 0.3,\n        vy: (Math.random() - 0.5) * 0.3,\n        r: Math.random() * 1.5 + 0.8,\n        o: Math.random() * 0.5 + 0.2,\n      }))\n    }\n\n    const resize = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2)\n      width = container.clientWidth\n      height = container.clientHeight\n      canvas.width = Math.max(1, Math.floor(width * dpr))\n      canvas.height = Math.max(1, Math.floor(height * dpr))\n      canvas.style.width = `${width}px`\n      canvas.style.height = `${height}px`\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n      seed()\n    }\n\n    const drawStatic = () => {\n      ctx.clearRect(0, 0, width, height)\n      ctx.fillStyle = color\n      for (const p of particles) {\n        ctx.globalAlpha = p.o\n        ctx.beginPath()\n        ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)\n        ctx.fill()\n      }\n      ctx.globalAlpha = 1\n    }\n\n    const tick = () => {\n      if (!running) return\n      ctx.clearRect(0, 0, width, height)\n\n      ctx.strokeStyle = color\n      for (let i = 0; i < particles.length; i++) {\n        const a = particles[i]\n        for (let j = i + 1; j < particles.length; j++) {\n          const b = particles[j]\n          const dx = a.x - b.x\n          const dy = a.y - b.y\n          const dist = Math.sqrt(dx * dx + dy * dy)\n          if (dist < linkDistance) {\n            ctx.globalAlpha = (1 - dist / linkDistance) * 0.15\n            ctx.beginPath()\n            ctx.moveTo(a.x, a.y)\n            ctx.lineTo(b.x, b.y)\n            ctx.stroke()\n          }\n        }\n      }\n\n      ctx.fillStyle = color\n      for (const p of particles) {\n        p.x += p.vx\n        p.y += p.vy\n        if (p.x < 0) p.x = width\n        else if (p.x > width) p.x = 0\n        if (p.y < 0) p.y = height\n        else if (p.y > height) p.y = 0\n\n        ctx.globalAlpha = p.o\n        ctx.beginPath()\n        ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2)\n        ctx.fill()\n      }\n      ctx.globalAlpha = 1\n\n      frameId = requestAnimationFrame(tick)\n    }\n\n    const start = () => {\n      if (running || reduceMotion) return\n      running = true\n      frameId = requestAnimationFrame(tick)\n    }\n\n    const stop = () => {\n      running = false\n      if (frameId) cancelAnimationFrame(frameId)\n    }\n\n    resize()\n    if (reduceMotion) drawStatic()\n\n    const resizeObserver = new ResizeObserver(() => {\n      resize()\n      if (reduceMotion) drawStatic()\n    })\n    resizeObserver.observe(container)\n\n    const intersectionObserver = new IntersectionObserver(\n      ([entry]) => {\n        if (entry.isIntersecting) start()\n        else stop()\n      },\n      { threshold: 0 }\n    )\n    intersectionObserver.observe(container)\n\n    const handleVisibility = () => {\n      if (document.hidden) stop()\n      else start()\n    }\n    document.addEventListener(\"visibilitychange\", handleVisibility)\n\n    return () => {\n      stop()\n      resizeObserver.disconnect()\n      intersectionObserver.disconnect()\n      document.removeEventListener(\"visibilitychange\", handleVisibility)\n    }\n  }, [density, tone])\n\n  return (\n    <div\n      ref={setRefs}\n      aria-hidden=\"true\"\n      data-slot=\"particle-field\"\n      className={cn(particleFieldVariants({ tone, density }), className)}\n      {...props}\n    >\n      <canvas ref={canvasRef} className=\"block size-full\" />\n    </div>\n  )\n})\nParticleField.displayName = \"ParticleField\"\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
