{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ds-star-shower",
  "title": "Star Shower",
  "description": "Céu estrelado vivo em canvas 2D: estrelas pulsantes com cintilação, 18% em cruz com halo e estrelas cadentes com cauda degradê. Pausa fora da viewport e frame estático sob prefers-reduced-motion.",
  "dependencies": [
    "class-variance-authority"
  ],
  "registryDependencies": [
    "@lema-ds/tokens"
  ],
  "files": [
    {
      "path": "components/ds/ds-star-shower.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 StarShowerProps\n  extends\n    React.HTMLAttributes<HTMLDivElement>,\n    VariantProps<typeof starShowerVariants> {\n  twinkle?: boolean\n  shootingStars?: boolean\n  interactive?: boolean\n}\n\n// ── Variants ──\n\nexport const starShowerVariants = cva(\n  \"pointer-events-none absolute inset-0 overflow-hidden\",\n  {\n    variants: {\n      tone: {\n        primary: \"[--star-color:var(--primary)]\",\n        violet: \"[--star-color:var(--highlight-violet)]\",\n        sky: \"[--star-color:var(--highlight-sky)]\",\n        warm: \"[--star-color:var(--warning)]\",\n        cool: \"[--star-color:var(--highlight-sky)]\",\n        neutral: \"[--star-color:var(--foreground)]\",\n      },\n      density: {\n        sm: \"\",\n        md: \"\",\n        lg: \"\",\n      },\n      speed: {\n        slow: \"[--star-speed:0.55]\",\n        normal: \"\",\n        fast: \"[--star-speed:1.65]\",\n      },\n    },\n    defaultVariants: { tone: \"primary\", density: \"md\", speed: \"normal\" },\n  }\n)\n\nconst DENSITY_COUNT = { sm: 45, md: 90, lg: 140 } as const\n\ninterface Star {\n  x: number\n  y: number\n  r: number\n  baseOpacity: number\n  phase: number\n  twinkleSpeed: number\n  twinklePhase: number\n  cross: boolean\n  depth: number\n}\n\ninterface ShootingStar {\n  x: number\n  y: number\n  vx: number\n  vy: number\n  len: number\n  opacity: number\n  life: number\n}\n\n// ── Component ──\n\n/**\n * Chuva de estrelas pulsantes — canvas 2D com estrelas que respiram,\n * twinkle and occasionally streak across the sky like shooting stars.\n * 100% decorativo (`aria-hidden`), pausa fora da viewport / aba inativa\n * and becomes a static frame under `prefers-reduced-motion`.\n *\n * Surprise: 18% of stars are \"cross\" (4 points) with glow, and the mode\n * `interactive` aplica parallax sutil no mousemove com profundidade por raio.\n */\nexport const StarShower = React.forwardRef<HTMLDivElement, StarShowerProps>(\n  (\n    {\n      className,\n      tone = \"primary\",\n      density = \"md\",\n      speed = \"normal\",\n      twinkle = true,\n      shootingStars = true,\n      interactive = false,\n      ...props\n    },\n    ref\n  ) => {\n    const containerRef = React.useRef<HTMLDivElement>(null)\n    const canvasRef = React.useRef<HTMLCanvasElement>(null)\n    const mouseRef = React.useRef({ x: 0, y: 0, tx: 0, ty: 0 })\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 isDarkBg =\n        container.closest(\".bg-zinc-950, .bg-slate-950, .bg-black\") !== null\n\n      // Cor via CSS variable --star-color computada\n      const rawColor =\n        getComputedStyle(container).getPropertyValue(\"--star-color\").trim() ||\n        \"currentColor\"\n\n      // If the background is dark and the computed color is too dark (primary in light), lighten to white\n      const starColor = (() => {\n        if (!isDarkBg) return rawColor\n        // rawColor pode ser oklch(0.205 0 0) — escuro; troca para branco em hero dark\n        if (rawColor.includes(\"0.205\") || rawColor.includes(\"0.145\"))\n          return \"oklch(0.985 0 0)\"\n        return rawColor\n      })()\n\n      const speedMul = speed === \"slow\" ? 0.55 : speed === \"fast\" ? 1.65 : 1\n      const count = DENSITY_COUNT[density ?? \"md\"]\n      const shootingInterval =\n        density === \"lg\" ? 2200 : density === \"sm\" ? 5200 : 3600\n\n      let stars: Star[] = []\n      let shooting: ShootingStar | null = null\n      let nextShootingAt = performance.now() + 1200 + Math.random() * 2000\n      let width = 0\n      let height = 0\n      let dpr = 1\n      let frameId = 0\n      let running = false\n\n      const seedStars = () => {\n        stars = Array.from({ length: count }, () => {\n          const r = Math.random() * 1.4 + 0.6\n          const isCross = Math.random() < 0.18\n          const crossR = isCross ? r * 1.9 : r\n          return {\n            x: Math.random() * width,\n            y: Math.random() * height,\n            r: crossR,\n            baseOpacity: isCross\n              ? 0.55 + Math.random() * 0.35\n              : 0.35 + Math.random() * 0.45,\n            phase: Math.random() * Math.PI * 2,\n            twinkleSpeed: 0.8 + Math.random() * 1.8,\n            twinklePhase: Math.random() * Math.PI * 2,\n            cross: isCross,\n            depth: 0.4 + (crossR / 2.8) * 0.6 + Math.random() * 0.2,\n          }\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        seedStars()\n      }\n\n      const spawnShooting = () => {\n        const fromTop = Math.random() < 0.7\n        const sx = fromTop\n          ? Math.random() * width * 0.85 + width * 0.1\n          : width + 20\n        const sy = fromTop ? -20 : Math.random() * height * 0.4\n        const angle = fromTop\n          ? Math.PI / 3.5 + (Math.random() - 0.5) * 0.4\n          : Math.PI * 0.82\n        const spd = 7 + Math.random() * 5\n        shooting = {\n          x: sx,\n          y: sy,\n          vx: Math.cos(angle) * spd,\n          vy: Math.sin(angle) * spd,\n          len: 90 + Math.random() * 80,\n          opacity: 1,\n          life: 0,\n        }\n      }\n\n      const drawStar = (s: Star, t: number, mx: number, my: number) => {\n        const pulse = Math.sin(t * 0.0012 * speedMul + s.phase) * 0.5 + 0.5\n        const flicker = twinkle\n          ? Math.sin(t * 0.0025 * s.twinkleSpeed + s.twinklePhase) * 0.15 + 0.85\n          : 1\n        const opacity = s.baseOpacity * (0.55 + pulse * 0.45) * flicker\n\n        // Parallax\n        const px = s.x + mx * s.depth * 6\n        const py = s.y + my * s.depth * 6\n\n        ctx.globalAlpha = opacity\n        ctx.fillStyle = starColor\n\n        if (s.cross) {\n          // 4-point cross + central dot + glow\n          const r = s.r\n          ctx.shadowBlur = 10\n          ctx.shadowColor = starColor\n          ctx.beginPath()\n          ctx.arc(px, py, r * 0.55, 0, Math.PI * 2)\n          ctx.fill()\n          ctx.shadowBlur = 0\n          // cross arms\n          ctx.beginPath()\n          ctx.moveTo(px - r * 1.8, py)\n          ctx.lineTo(px + r * 1.8, py)\n          ctx.moveTo(px, py - r * 1.8)\n          ctx.lineTo(px, py + r * 1.8)\n          ctx.lineWidth = 0.7\n          ctx.strokeStyle = starColor\n          ctx.globalAlpha = opacity * 0.55\n          ctx.stroke()\n          ctx.globalAlpha = opacity\n        } else {\n          // soft glow dot\n          ctx.shadowBlur = s.r * 3.5\n          ctx.shadowColor = starColor\n          ctx.beginPath()\n          ctx.arc(px, py, s.r * 0.9, 0, Math.PI * 2)\n          ctx.fill()\n          ctx.shadowBlur = 0\n          // core bright\n          ctx.globalAlpha = opacity * 1.1\n          ctx.beginPath()\n          ctx.arc(px, py, s.r * 0.35, 0, Math.PI * 2)\n          ctx.fill()\n        }\n      }\n\n      const drawStatic = () => {\n        ctx.clearRect(0, 0, width, height)\n        // subtle vignette\n        const grad = ctx.createRadialGradient(\n          width * 0.5,\n          height * 0.2,\n          0,\n          width * 0.5,\n          height * 0.2,\n          Math.max(width, height) * 0.9\n        )\n        grad.addColorStop(0, \"transparent\")\n        grad.addColorStop(1, \"rgba(0,0,0,0.18)\")\n        ctx.fillStyle = grad\n        ctx.fillRect(0, 0, width, height)\n\n        for (const s of stars) drawStar(s, 0, 0, 0)\n      }\n\n      const tick = (now: number) => {\n        if (!running) return\n        ctx.clearRect(0, 0, width, height)\n\n        // vignette\n        const grad = ctx.createRadialGradient(\n          width * 0.5,\n          height * 0.15,\n          0,\n          width * 0.5,\n          height * 0.15,\n          Math.max(width, height) * 0.95\n        )\n        grad.addColorStop(0, \"transparent\")\n        grad.addColorStop(1, \"rgba(0,0,0,0.22)\")\n        ctx.fillStyle = grad\n        ctx.fillRect(0, 0, width, height)\n\n        // lerp mouse\n        const m = mouseRef.current\n        m.x += (m.tx - m.x) * 0.04\n        m.y += (m.ty - m.y) * 0.04\n\n        for (const s of stars) drawStar(s, now, m.x, m.y)\n\n        // shooting star\n        if (shootingStars) {\n          if (!shooting && now > nextShootingAt) spawnShooting()\n          if (shooting) {\n            shooting.x += shooting.vx\n            shooting.y += shooting.vy\n            shooting.life += 16\n            // fade after 60% of travel\n            const traveled = shooting.life\n            if (traveled > 900) shooting.opacity -= 0.04\n            if (\n              shooting.opacity <= 0 ||\n              shooting.x < -180 ||\n              shooting.y > height + 180\n            ) {\n              shooting = null\n              nextShootingAt = now + shootingInterval + Math.random() * 1800\n            } else {\n              const tailX = shooting.x - (shooting.vx / 8) * (shooting.len / 10)\n              const tailY = shooting.y - (shooting.vy / 8) * (shooting.len / 10)\n              const g = ctx.createLinearGradient(\n                tailX,\n                tailY,\n                shooting.x,\n                shooting.y\n              )\n              g.addColorStop(0, \"transparent\")\n              g.addColorStop(0.5, starColor)\n              g.addColorStop(1, \"#fff\")\n              ctx.strokeStyle = g\n              ctx.lineWidth = 1.6\n              ctx.lineCap = \"round\"\n              ctx.shadowBlur = 8\n              ctx.shadowColor = starColor\n              ctx.globalAlpha = Math.max(0, shooting.opacity * 0.95)\n              ctx.beginPath()\n              ctx.moveTo(tailX, tailY)\n              ctx.lineTo(shooting.x, shooting.y)\n              ctx.stroke()\n              ctx.shadowBlur = 0\n              // head\n              ctx.fillStyle = \"#fff\"\n              ctx.globalAlpha = shooting.opacity\n              ctx.beginPath()\n              ctx.arc(shooting.x, shooting.y, 1.8, 0, Math.PI * 2)\n              ctx.fill()\n            }\n          }\n        }\n\n        ctx.globalAlpha = 1\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      const handleMouse = (e: MouseEvent) => {\n        if (!interactive) return\n        const rect = container.getBoundingClientRect()\n        const nx = ((e.clientX - rect.left) / rect.width - 0.5) * 2\n        const ny = ((e.clientY - rect.top) / rect.height - 0.5) * 2\n        mouseRef.current.tx = nx\n        mouseRef.current.ty = ny\n      }\n      const handleLeave = () => {\n        mouseRef.current.tx = 0\n        mouseRef.current.ty = 0\n      }\n      if (interactive) {\n        container.addEventListener(\"mousemove\", handleMouse)\n        container.addEventListener(\"mouseleave\", handleLeave)\n      }\n\n      return () => {\n        stop()\n        resizeObserver.disconnect()\n        intersectionObserver.disconnect()\n        document.removeEventListener(\"visibilitychange\", handleVisibility)\n        if (interactive) {\n          container.removeEventListener(\"mousemove\", handleMouse)\n          container.removeEventListener(\"mouseleave\", handleLeave)\n        }\n      }\n    }, [density, tone, speed, twinkle, shootingStars, interactive])\n\n    return (\n      <div\n        ref={setRefs}\n        aria-hidden=\"true\"\n        data-slot=\"star-shower\"\n        className={cn(starShowerVariants({ tone, density, speed }), className)}\n        {...props}\n      >\n        <canvas ref={canvasRef} className=\"block size-full\" />\n      </div>\n    )\n  }\n)\nStarShower.displayName = \"StarShower\"\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}
