{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "lantern",
  "type": "registry:ui",
  "title": "Lantern",
  "description": "A spotlight cursor: wraps any content, dims the surface, and cuts a soft radial light that spring-follows the pointer to reveal what's beneath — ringed by a faint warm falloff and flaring brighter on press (rapid presses stack). Children stay interactive; idles on its own when untouched, and settles to a static centered spotlight under reduced motion.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json",
    "https://crucibleui.com/r/use-visibility-pause.json"
  ],
  "files": [
    {
      "path": "registry/default/cursors/lantern/lantern.tsx",
      "type": "registry:ui",
      "target": "components/crucible/lantern.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useMotionTemplate, useMotionValue, useSpring, useTransform } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { useVisibilityPause } from \"@/hooks/use-visibility-pause\";\n\nexport interface LanternHandle {\n  /**\n   * Flare the light once. Pass container-local coordinates (px from the\n   * top-left) to move the light there first. Repeated calls stack — each adds\n   * to the flare, up to a cap — instead of restarting it.\n   */\n  pulse: (x?: number, y?: number) => void;\n}\n\nexport interface LanternProps {\n  /** Content revealed beneath the light. Everything outside the spotlight is dimmed. */\n  children?: React.ReactNode;\n  className?: string;\n  /** Spotlight radius in px. @default 190 */\n  radius?: number;\n  /** Edge feather, `0` (crisp cut) → `0.95` (very soft falloff). @default 0.6 */\n  softness?: number;\n  /** How dark everything outside the light gets, `0` → `1`. @default 0.9 */\n  intensity?: number;\n  /** Palette of the lantern. */\n  colors?: {\n    /** Tint of the darkened area outside the light. @default \"#050608\" */\n    dim?: string;\n    /** Warm light cast at the spotlight's falloff edge. @default \"#ffce8a\" */\n    glow?: string;\n  };\n  /** Spring physics for the follow. Softer = the light lags further behind the cursor. */\n  spring?: { stiffness?: number; damping?: number; mass?: number };\n  /** Expand + brighten the light on pointer press. @default true */\n  pulseOnClick?: boolean;\n  /** Freeze to the static centered spotlight (no follow). @default false */\n  paused?: boolean;\n  /**\n   * Idle autoplay: after 1.5s with no pointer inside, a ghost hand sweeps the\n   * light along a slow Lissajous path (with the odd gentle flare) so the\n   * surface never looks dead; a real pointer takes over instantly. Also runs on\n   * touch-only devices. Never under reduced motion; sleeps offscreen.\n   * @default true\n   */\n  idle?: boolean;\n  /** React 19 ref prop — imperative `pulse(x?, y?)`. */\n  ref?: React.Ref<LanternHandle>;\n}\n\nconst DEFAULT_SPRING = { stiffness: 200, damping: 28, mass: 0.7 };\nconst PULSE_PEAK = 1.32;\nconst BASE_GLOW = 0.3;\n/** Each stacked press adds this much flare on top of whatever is still glowing. */\nconst PULSE_STEP = PULSE_PEAK - 1;\nconst PULSE_CAP = 1.75;\nconst IDLE_MS = 1500;\nconst GHOST_SPEED = 170; // px/s — an unhurried hand\nconst GHOST_FLARE_EVERY = 5.2; // s between the ghost's gentle flares\n\nconst clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));\n\n/**\n * Lantern — a flashlight / spotlight cursor. Wraps arbitrary children, dims the\n * whole surface, and cuts a soft radial hole of light that spring-follows the\n * pointer to reveal the content beneath, ringed by a faint warm falloff. On\n * press the light expands and brightens in a quick pulse; rapid presses stack\n * into a bigger flare (capped), and `ref.pulse(x?, y?)` fires one imperatively.\n * With no pointer for 1.5s an idle ghost sweeps the light on a slow Lissajous\n * path (offscreen-paused) until a real pointer takes over. Scoped entirely to\n * its wrapper (never hijacks the page cursor); children stay fully interactive\n * because every overlay is `pointer-events-none`. When `paused`, under\n * `prefers-reduced-motion`, and on touch pointers with `idle={false}` it settles to an aesthetic static\n * spotlight centered on the surface — a designed resting frame, never a blank\n * box.\n */\nexport function Lantern({\n  children,\n  className,\n  radius = 190,\n  softness = 0.6,\n  intensity = 0.9,\n  colors,\n  spring,\n  pulseOnClick = true,\n  paused = false,\n  idle = true,\n  ref,\n}: LanternProps) {\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const reducedMotion = useReducedMotion();\n  const [finePointer, setFinePointer] = React.useState(false);\n\n  const dim = colors?.dim ?? \"#050608\";\n  const glow = colors?.glow ?? \"#ffce8a\";\n\n  const r = Math.max(40, radius);\n  const soft = clamp(softness, 0, 0.95);\n  const dimOpacity = clamp(intensity, 0, 1);\n  // Transparent core radius as a % of the gradient; higher softness ⇒ smaller\n  // clear core and a wider feather out to the dim.\n  const innerPct = Math.round((1 - soft) * 100);\n  const glowInner = Math.max(0, innerPct - 14);\n  const glowMid = Math.min(98, innerPct + 4);\n\n  React.useEffect(() => {\n    if (typeof window === \"undefined\" || !window.matchMedia) return;\n    const mql = window.matchMedia(\"(pointer: fine)\");\n    const sync = () => setFinePointer(mql.matches);\n    sync();\n    mql.addEventListener(\"change\", sync);\n    return () => mql.removeEventListener(\"change\", sync);\n  }, []);\n\n  // Follow with a real cursor — or, with `idle`, on any device so the ghost can\n  // drive it — when motion is allowed and not paused. Otherwise we render the\n  // static centered spotlight below.\n  const follow = (finePointer || idle) && !reducedMotion && !paused;\n  const ghostOn = follow && idle;\n\n  const springCfg = { ...DEFAULT_SPRING, ...spring };\n  const mx = useMotionValue(0);\n  const my = useMotionValue(0);\n  const x = useSpring(mx, springCfg);\n  const y = useSpring(my, springCfg);\n\n  // Press pulse: expands the radius and lifts the warm glow, then springs back.\n  const pulse = useSpring(1, { stiffness: 320, damping: 18, mass: 0.5 });\n  const rDim = useTransform(pulse, (p) => r * p);\n  const rGlow = useTransform(pulse, (p) => r * p * 1.05);\n  const glowOpacity = useTransform(pulse, (p) => clamp(BASE_GLOW + (p - 1) * 0.8, 0, 0.65));\n\n  const dimBg = useMotionTemplate`radial-gradient(circle ${rDim}px at ${x}px ${y}px, transparent 0%, transparent ${innerPct}%, ${dim} 100%)`;\n  const glowBg = useMotionTemplate`radial-gradient(circle ${rGlow}px at ${x}px ${y}px, transparent ${glowInner}%, ${glow} ${glowMid}%, transparent 100%)`;\n\n  // Pointer presence for the idle ghost. `inside` = a real pointer is over the\n  // surface; `lastReal` = when it last moved/left (ms, performance.now()).\n  const presenceRef = React.useRef({ inside: false, lastReal: 0 });\n  const ghostRef = React.useRef({ t: 0, last: 0, on: false, nextFlare: 0, flareUntil: 0 });\n  const flareTimerRef = React.useRef(0);\n\n  /** Stack another flare onto whatever is still glowing (capped). */\n  const flare = React.useCallback(() => {\n    pulse.set(Math.min(PULSE_CAP, Math.max(PULSE_PEAK, pulse.get() + PULSE_STEP)));\n  }, [pulse]);\n\n  const tick = React.useCallback(\n    (elapsed: number) => {\n      const el = wrapperRef.current;\n      const g = ghostRef.current;\n      if (elapsed < g.last) g.last = elapsed;\n      const dt = Math.min(elapsed - g.last, 0.05);\n      g.last = elapsed;\n      if (!el) return;\n      const pr = presenceRef.current;\n      if (pr.inside || performance.now() - pr.lastReal < IDLE_MS) {\n        if (g.on) {\n          g.on = false;\n          if (g.flareUntil) pulse.set(1);\n          g.flareUntil = 0;\n        }\n        return;\n      }\n      const w = el.clientWidth;\n      const h = el.clientHeight;\n      if (w < 2 || h < 2) return;\n      if (!g.on) {\n        g.on = true;\n        g.nextFlare = elapsed + GHOST_FLARE_EVERY * 0.6;\n      }\n      // Slow 3:2 Lissajous over the middle of the surface.\n      const ax = w * 0.32;\n      const ay = h * 0.26;\n      g.t += (dt * GHOST_SPEED) / Math.max(90, (ax * 3 + ay * 2) * 0.7);\n      mx.set(w / 2 + ax * Math.sin(3 * g.t + 0.6));\n      my.set(h / 2 + ay * Math.sin(2 * g.t));\n      // The ghost \"presses\" now and then — a softer flare than a real click.\n      if (pulseOnClick && elapsed >= g.nextFlare) {\n        g.nextFlare = elapsed + GHOST_FLARE_EVERY;\n        g.flareUntil = elapsed + 0.32;\n        pulse.set(1 + PULSE_STEP * 0.7);\n      } else if (g.flareUntil && elapsed >= g.flareUntil) {\n        g.flareUntil = 0;\n        pulse.set(1);\n      }\n    },\n    [mx, my, pulse, pulseOnClick]\n  );\n\n  // The ghost loop rides the overlay so it sleeps offscreen / on hidden tabs.\n  const ghostLoopRef = useVisibilityPause<HTMLDivElement>(tick, { paused: !ghostOn });\n\n  React.useEffect(() => {\n    if (!follow) return;\n    const el = wrapperRef.current;\n    if (!el) return;\n\n    // Seat the light at the surface centre until the pointer arrives.\n    const seatCenter = () => {\n      const rect = el.getBoundingClientRect();\n      mx.set(rect.width / 2);\n      my.set(rect.height / 2);\n    };\n    seatCenter();\n\n    const pr = presenceRef.current;\n    pr.lastReal = performance.now();\n    const onMove = (e: PointerEvent) => {\n      pr.inside = true;\n      pr.lastReal = performance.now();\n      const rect = el.getBoundingClientRect();\n      mx.set(e.clientX - rect.left);\n      my.set(e.clientY - rect.top);\n    };\n    const onLeave = () => {\n      pr.inside = false;\n      pr.lastReal = performance.now();\n      seatCenter();\n    };\n    const onDown = (e: PointerEvent) => {\n      pr.inside = true;\n      pr.lastReal = performance.now();\n      const rect = el.getBoundingClientRect();\n      mx.set(e.clientX - rect.left);\n      my.set(e.clientY - rect.top);\n      // Rapid presses stack: each adds to the flare still in flight.\n      if (pulseOnClick) flare();\n    };\n    const onUp = () => pulse.set(1);\n\n    el.addEventListener(\"pointermove\", onMove);\n    el.addEventListener(\"pointerleave\", onLeave);\n    el.addEventListener(\"pointercancel\", onLeave);\n    el.addEventListener(\"pointerdown\", onDown);\n    // Release anywhere so a drag that ends off-surface still relaxes the pulse.\n    window.addEventListener(\"pointerup\", onUp);\n\n    return () => {\n      el.removeEventListener(\"pointermove\", onMove);\n      el.removeEventListener(\"pointerleave\", onLeave);\n      el.removeEventListener(\"pointercancel\", onLeave);\n      el.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.clearTimeout(flareTimerRef.current);\n      pr.inside = false;\n      pulse.set(1);\n    };\n  }, [follow, pulseOnClick, mx, my, pulse, flare]);\n\n  React.useImperativeHandle(\n    ref,\n    () => ({\n      pulse(px, py) {\n        if (!follow) return;\n        if (px !== undefined && py !== undefined) {\n          mx.set(px);\n          my.set(py);\n          // Hold the ghost off so the light stays where it was sent.\n          presenceRef.current.lastReal = performance.now();\n        }\n        flare();\n        window.clearTimeout(flareTimerRef.current);\n        flareTimerRef.current = window.setTimeout(() => pulse.set(1), 180);\n      },\n    }),\n    [follow, flare, mx, my, pulse]\n  );\n\n  // Static resting frame (reduced motion / coarse pointer / paused): a centered\n  // spotlight, fully painted — the same visual language, just anchored.\n  const staticDimBg = `radial-gradient(circle ${r}px at 50% 50%, transparent 0%, transparent ${innerPct}%, ${dim} 100%)`;\n  const staticGlowBg = `radial-gradient(circle ${r * 1.05}px at 50% 50%, transparent ${glowInner}%, ${glow} ${glowMid}%, transparent 100%)`;\n\n  return (\n    <div ref={wrapperRef} data-crucible=\"lantern\" className={cn(\"relative isolate\", className)}>\n      {children}\n      <div ref={ghostLoopRef} aria-hidden className=\"pointer-events-none absolute inset-0 z-30\">\n        {follow ? (\n          <>\n            <motion.div className=\"absolute inset-0\" style={{ background: dimBg, opacity: dimOpacity }} />\n            <motion.div\n              className=\"absolute inset-0\"\n              style={{ background: glowBg, opacity: glowOpacity, mixBlendMode: \"screen\" }}\n            />\n          </>\n        ) : (\n          <>\n            <div className=\"absolute inset-0\" style={{ background: staticDimBg, opacity: dimOpacity }} />\n            <div\n              className=\"absolute inset-0\"\n              style={{ background: staticGlowBg, opacity: BASE_GLOW, mixBlendMode: \"screen\" }}\n            />\n          </>\n        )}\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}