{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "meteors",
  "type": "registry:ui",
  "title": "Meteors",
  "description": "@interlace/ui — meteors primitive (shadcn-compatible).",
  "dependencies": [],
  "registryDependencies": [
    "theme"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/meteors.tsx",
      "target": "components/ui/meteors.tsx",
      "type": "registry:ui",
      "content": "'use client';\n\n/**\n * @interlace/ui — Meteors (decorative animation primitive)\n *\n * Aceternity-style falling-meteor effect with per-meteor variety: viewport-\n * distributed origins (some above the fold), trail lengths randomized\n * between 50–140px, opacities 0.45–1.0, durations 3–9s. The variety is the\n * difference between \"meteors\" and \"looks like a CSS animation\" — a flat\n * shower of identical streaks reads as synthetic; uneven trails read as a\n * real night sky.\n *\n * | Rule | Concept                          | Where in this file                                                          |\n * | ---- | -------------------------------- | --------------------------------------------------------------------------- |\n * | R4   | Extends native el                | `React.HTMLAttributes<HTMLDivElement>`                                      |\n * | R5   | data-slot on the root           | `data-slot=\"meteors\"` on the wrapper                                        |\n * | R7   | className merged + ...rest      | `cn(...)` + `{...rest}`                                                     |\n * | R9   | Honors `prefers-reduced-motion` | Inline `useReducedMotion()` returns null when reduce is requested           |\n * | R18  | Tailwind only                   | Inline `style` is per-meteor randomized values only (top/left/duration/etc) |\n * | R19  | Tokens                          | Head rim consumes `--color-meteor-glow`. Rod uses slate-300/dark:slate-200. |\n * | R26  | a11y                            | `aria-hidden` + `pointer-events-none` — decorative, not announced           |\n *\n * Out of scope: this primitive does not own a `data-testid` default (R6) —\n * consumers supply one. Theming for the head rim ships via this registry\n * item's `cssVars` block; rod color is a Tailwind utility that resolves\n * against the consumer's neutral palette without extra wiring.\n */\n\nimport { useEffect, useState } from 'react';\n\nimport { cn } from '@/lib/utils';\n\ninterface MeteorsProps extends React.HTMLAttributes<HTMLDivElement> {\n  /** Total number of meteors to render. Higher = denser shower. */\n  number?: number;\n  /** Stable selector for E2E tests; consumer provides — no default. */\n  'data-testid'?: string;\n}\n\ninterface MeteorMeta {\n  /** Vertical origin as a viewport percentage. Some start above the fold (-15vh)\n   * so they fall into the hero naturally instead of all popping in at top:0. */\n  top: string;\n  /** Horizontal origin spread across the full viewport. */\n  left: string;\n  /** Per-meteor animation duration. */\n  animationDuration: string;\n  /** Stagger so the shower doesn't pulse in synchronized waves. */\n  animationDelay: string;\n  /** Trail length in pixels — picked once per meteor so the shower has variety. */\n  trail: number;\n  /** Tail opacity (0.45–1.0) — makes the field feel deep instead of flat. */\n  opacity: number;\n}\n\nconst TRAIL_MIN_PX = 50;\nconst TRAIL_MAX_PX = 140;\nconst DURATION_MIN_S = 3;\nconst DURATION_MAX_S = 9;\nconst OPACITY_MIN = 0.45;\nconst OPACITY_MAX = 1.0;\nconst DELAY_MAX_S = 4;\nconst TOP_MIN_VH = -15;\nconst TOP_MAX_VH = 55;\n\nconst rand = (min: number, max: number): number =>\n  Math.random() * (max - min) + min;\n\nfunction useReducedMotion(): boolean {\n  const [reduced, setReduced] = useState(false);\n  useEffect(() => {\n    if (typeof window === 'undefined' || !window.matchMedia) return;\n    const mql = window.matchMedia('(prefers-reduced-motion: reduce)');\n    setReduced(mql.matches);\n    const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);\n    mql.addEventListener('change', onChange);\n    return () => mql.removeEventListener('change', onChange);\n  }, []);\n  return reduced;\n}\n\nfunction Meteors({\n  number = 22,\n  className,\n  'data-testid': testId,\n  ...rest\n}: MeteorsProps) {\n  const reduced = useReducedMotion();\n  const [meteors, setMeteors] = useState<MeteorMeta[]>([]);\n\n  useEffect(() => {\n    if (reduced) return;\n    // Positions are randomized client-side once per mount to avoid a\n    // hydration mismatch. The state is intentionally never updated again.\n    setMeteors(\n      Array.from({ length: number }, () => ({\n        top: `${rand(TOP_MIN_VH, TOP_MAX_VH).toFixed(2)}vh`,\n        left: `${rand(0, 100).toFixed(2)}vw`,\n        animationDuration: `${rand(DURATION_MIN_S, DURATION_MAX_S).toFixed(2)}s`,\n        animationDelay: `${rand(0, DELAY_MAX_S).toFixed(2)}s`,\n        trail: Math.round(rand(TRAIL_MIN_PX, TRAIL_MAX_PX)),\n        opacity: Number(rand(OPACITY_MIN, OPACITY_MAX).toFixed(2)),\n      })),\n    );\n    // The flagged names (length/top/left/animationDuration/animationDelay/\n    // trail/opacity) are object-literal property keys inside the generated\n    // MeteorMeta above, not free-variable references, and `rand` is a\n    // module-level stable function — none of them are real reactive\n    // dependencies.\n    // eslint-disable-next-line react-features/hooks-exhaustive-deps\n  }, [number, reduced]);\n\n  if (reduced) return null;\n\n  return (\n    <div\n      data-slot=\"meteors\"\n      data-testid={testId}\n      aria-hidden\n      className={cn(\n        'pointer-events-none absolute inset-0 overflow-hidden',\n        className,\n      )}\n      {...rest}\n    >\n      {/* The array is fully regenerated (never spliced/reordered) on every\n          `number`/`reduced` change, and meteors carry no natural id — the\n          position itself is a stable key for this list's lifetime. */}\n      {meteors.map((m, meteorIndex) => (\n        <span\n          key={meteorIndex}\n          className={cn(\n            'pointer-events-none absolute rotate-[215deg] animate-meteor rounded-full bg-slate-300 dark:bg-slate-200',\n            'shadow-[0_0_2px_1px_var(--color-meteor-glow)]',\n            'h-[1px] w-[1px]',\n            'before:absolute before:top-1/2 before:left-0 before:h-px before:w-[var(--trail)] before:-translate-y-1/2',\n            'before:bg-linear-to-r before:from-slate-300 before:via-slate-300/70 before:to-transparent',\n            'dark:before:from-slate-200 dark:before:via-slate-200/80',\n            \"before:content-['']\",\n          )}\n          style={\n            {\n              top: m.top,\n              left: m.left,\n              animationDuration: m.animationDuration,\n              animationDelay: m.animationDelay,\n              opacity: m.opacity,\n              ['--trail' as string]: `${m.trail}px`,\n            } as React.CSSProperties\n          }\n        />\n      ))}\n    </div>\n  );\n}\n\nexport { Meteors };\nexport type { MeteorsProps };\n"
    }
  ],
  "cssVars": {
    "light": {
      "meteor-glow": "oklch(1 0 0 / 0.06)"
    },
    "dark": {
      "meteor-glow": "oklch(1 0 0 / 0.1)"
    }
  },
  "css": {
    "@theme inline": {
      "--animate-meteor": "meteor 5s linear infinite"
    },
    "@keyframes meteor": {
      "0%": {
        "transform": "rotate(215deg) translateX(0)",
        "opacity": "1"
      },
      "70%": {
        "opacity": "1"
      },
      "100%": {
        "transform": "rotate(215deg) translateX(-500px)",
        "opacity": "0"
      }
    },
    "@media (prefers-reduced-motion: reduce)": {
      ".animate-meteor": {
        "animation": "none !important"
      }
    }
  }
}
