{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "light-provider",
  "type": "registry:ui",
  "title": "Light provider",
  "description": "A page wide light source that follows the pointer and drifts when idle, driving shadows via CSS variables, plus a hairline, glass, metal and paper switcher.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/light-provider.tsx",
      "type": "registry:ui",
      "target": "components/ui/light-provider.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\n/*\n * Light & Material.\n *\n * <LightProvider /> runs one light source for the whole page. It follows the\n * pointer (eased), drifts on its own when nobody is pointing (phones, idle),\n * and writes five CSS variables on <html>: --lx --ly (position), --sx --sy\n * (shadow direction) and --la (angle toward the light). Every shadow-*,\n * surface and sheen in the kit reads them, so all components catch the same\n * light. With reduced motion the light stays still.\n *\n * Materials are a data attribute: <html data-material=\"glass\"> or on any\n * subtree. hairline (default) · glass · metal · paper.\n */\n\ntype Material = \"hairline\" | \"glass\" | \"metal\" | \"paper\"\nconst MATERIALS: Material[] = [\"hairline\", \"glass\", \"metal\", \"paper\"]\n\nfunction LightProvider({ idle = true }: { /** Drift when the pointer is idle or absent. */ idle?: boolean }) {\n  const reduce = useReducedMotion()\n  React.useEffect(() => {\n    const root = document.documentElement\n    if (reduce) return\n    let tx = window.innerWidth * 0.3\n    let ty = -window.innerHeight * 0.1\n    let cx = tx\n    let cy = ty\n    let last = performance.now()\n    let lastMove = 0\n    let raf = 0\n    let lastWrite = \"\"\n    const coarse = window.matchMedia(\"(pointer: coarse)\").matches\n\n    const write = () => {\n      const w = window.innerWidth\n      const h = window.innerHeight\n      const sx = Math.max(-1, Math.min(1, (w / 2 - cx) / (w / 2)))\n      const sy = Math.max(-1, Math.min(1, (h / 2 - cy) / (h / 2)))\n      const la = (Math.atan2(-sx, sy) * 180) / Math.PI // toward the light\n      const key = `${Math.round(cx)}|${Math.round(cy)}`\n      if (key === lastWrite) return\n      lastWrite = key\n      root.style.setProperty(\"--lx\", `${Math.round(cx)}px`)\n      root.style.setProperty(\"--ly\", `${Math.round(cy)}px`)\n      root.style.setProperty(\"--sx\", sx.toFixed(3))\n      root.style.setProperty(\"--sy\", sy.toFixed(3))\n      root.style.setProperty(\"--la\", `${Math.round(la)}deg`)\n    }\n\n    const tick = (now: number) => {\n      const dt = Math.min(64, now - last)\n      last = now\n      if (idle && (coarse || now - lastMove > 5000)) {\n        const t = now / 1000\n        tx = window.innerWidth * (0.5 + 0.34 * Math.cos(t * 0.32))\n        ty = window.innerHeight * (0.28 + 0.26 * Math.sin(t * 0.32))\n      }\n      const k = 1 - Math.pow(0.001, dt / 1000) // frame-rate independent ease\n      cx += (tx - cx) * Math.min(1, k * 1.6)\n      cy += (ty - cy) * Math.min(1, k * 1.6)\n      write()\n      raf = requestAnimationFrame(tick)\n    }\n\n    const move = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\") return\n      tx = e.clientX\n      ty = e.clientY\n      lastMove = performance.now()\n    }\n    const vis = () => {\n      cancelAnimationFrame(raf)\n      if (!document.hidden) {\n        last = performance.now()\n        raf = requestAnimationFrame(tick)\n      }\n    }\n    window.addEventListener(\"pointermove\", move, { passive: true })\n    document.addEventListener(\"visibilitychange\", vis)\n    raf = requestAnimationFrame(tick)\n    return () => {\n      cancelAnimationFrame(raf)\n      window.removeEventListener(\"pointermove\", move)\n      document.removeEventListener(\"visibilitychange\", vis)\n    }\n  }, [reduce, idle])\n  return null\n}\n\n/* ── material store (reads and writes <html data-material>) ───────────── */\nconst subs = new Set<() => void>()\nfunction readMaterial(): Material {\n  if (typeof document === \"undefined\") return \"hairline\"\n  const m = document.documentElement.getAttribute(\"data-material\") as Material | null\n  return m && MATERIALS.includes(m) ? m : \"hairline\"\n}\nfunction useMaterial() {\n  const material = React.useSyncExternalStore(\n    (f) => {\n      subs.add(f)\n      const mo = new MutationObserver(f)\n      mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"data-material\"] })\n      return () => {\n        subs.delete(f)\n        mo.disconnect()\n      }\n    },\n    readMaterial,\n    () => \"hairline\" as Material\n  )\n  const setMaterial = React.useCallback((m: Material, from?: { x: number; y: number }) => {\n    const apply = () => {\n      document.documentElement.setAttribute(\"data-material\", m)\n      try {\n        localStorage.setItem(\"material\", m)\n      } catch {}\n      subs.forEach((f) => f())\n    }\n    const doc = document as Document & { startViewTransition?: (cb: () => void) => { ready: Promise<void> } }\n    const reduce = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches\n    if (!doc.startViewTransition || reduce) return apply()\n    const x = from?.x ?? window.innerWidth / 2\n    const y = from?.y ?? window.innerHeight / 2\n    const r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y))\n    const vt = doc.startViewTransition(apply)\n    vt.ready\n      .then(() => {\n        document.documentElement.animate(\n          { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] },\n          { duration: 720, easing: \"cubic-bezier(0.2, 0, 0, 1)\", pseudoElement: \"::view-transition-new(root)\" }\n        )\n      })\n      .catch(() => {})\n  }, [])\n  return { material, setMaterial }\n}\n\n/* ── switcher ──────────────────────────────────────────────────────────── */\nconst SWATCH: Record<Material, string> = {\n  hairline: \"bg-[linear-gradient(135deg,oklch(1_0_0),oklch(0.93_0.004_264))] shadow-[inset_0_0_0_1px_oklch(0.2_0.02_264/0.18)]\",\n  glass: \"bg-[radial-gradient(circle_at_30%_25%,oklch(1_0_0/0.9),transparent_45%),conic-gradient(from_200deg,oklch(0.8_0.12_300),oklch(0.85_0.1_220),oklch(0.88_0.1_350),oklch(0.8_0.12_300))] shadow-[inset_0_0_0_1px_oklch(1_0_0/0.6)]\",\n  metal: \"bg-[conic-gradient(from_210deg,oklch(0.96_0_0),oklch(0.7_0.005_250),oklch(0.98_0_0),oklch(0.62_0.005_250),oklch(0.96_0_0))] shadow-[inset_0_0_0_1px_oklch(0_0_0/0.2)]\",\n  paper: \"bg-[oklch(0.95_0.02_85)] shadow-[inset_0_0_0_1px_oklch(0.5_0.04_70/0.3)]\",\n}\nconst LABEL: Record<Material, string> = { hairline: \"Hairline\", glass: \"Glass\", metal: \"Metal\", paper: \"Paper\" }\n\nfunction MaterialSwitcher({ size = \"default\", labels = true, className }: { size?: \"sm\" | \"default\" | \"lg\"; labels?: boolean; className?: string }) {\n  const { material, setMaterial } = useMaterial()\n  const reduce = useReducedMotion()\n  const id = React.useId()\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([])\n  const h = size === \"lg\" ? \"h-11 p-1\" : size === \"sm\" ? \"h-8 p-[3px]\" : \"h-9 p-[3px]\"\n  const pick = (m: Material, el?: HTMLElement | null) => {\n    const r = el?.getBoundingClientRect()\n    setMaterial(m, r ? { x: r.left + r.width / 2, y: r.top + r.height / 2 } : undefined)\n  }\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label=\"Material\"\n      data-slot=\"material-switcher\"\n      className={cn(\"relative isolate inline-flex items-stretch rounded-full bg-sunken shadow-[inset_0_0_0_1px_var(--border)]\", h, className)}\n    >\n      {MATERIALS.map((m, i) => {\n        const on = m === material\n        return (\n          <button\n            key={m}\n            ref={(el) => {\n              refs.current[i] = el\n            }}\n            type=\"button\"\n            role=\"radio\"\n            aria-checked={on}\n            aria-label={labels ? undefined : LABEL[m]}\n            tabIndex={on ? 0 : -1}\n            onClick={(e) => pick(m, e.currentTarget)}\n            onKeyDown={(e) => {\n              const d = e.key === \"ArrowRight\" || e.key === \"ArrowDown\" ? 1 : e.key === \"ArrowLeft\" || e.key === \"ArrowUp\" ? -1 : 0\n              if (!d) return\n              e.preventDefault()\n              const n = (i + d + MATERIALS.length) % MATERIALS.length\n              refs.current[n]?.focus()\n              pick(MATERIALS[n], refs.current[n])\n            }}\n            className={cn(\n              \"relative inline-flex items-center gap-2 rounded-full font-medium whitespace-nowrap outline-none transition-colors duration-[70ms]\",\n              \"focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-sunken\",\n              size === \"lg\" ? \"px-4 text-sm\" : \"px-3 text-[0.8125rem]\",\n              !labels && \"px-1.5\",\n              on ? \"text-fg\" : \"text-fg-muted hover:text-fg\"\n            )}\n          >\n            {on ? (\n              <motion.span\n                layoutId={`${id}-mat`}\n                aria-hidden\n                className=\"absolute inset-0 -z-10 rounded-full bg-raised shadow-[0_1px_2px_0_oklch(0_0_0/0.1),0_0_0_1px_var(--border),inset_0_1px_0_0_var(--highlight)]\"\n                transition={reduce ? { duration: 0 } : { type: \"spring\", bounce: 0.18, duration: 0.42 }}\n              />\n            ) : null}\n            <span aria-hidden className={cn(\"size-4 shrink-0 rounded-full\", size === \"lg\" && \"size-5\", SWATCH[m])} />\n            {labels ? LABEL[m] : null}\n          </button>\n        )\n      })}\n    </div>\n  )\n}\n\nexport { LightProvider, MaterialSwitcher, useMaterial, MATERIALS }\nexport type { Material }\n"
    }
  ]
}
