{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hotspot",
  "type": "registry:ui",
  "title": "Hotspot",
  "description": "A beacon on a new feature that explains itself and stays dismissed.",
  "categories": [
    "onboarding"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/hotspot.tsx",
      "content": "\"use client\";\nimport { Popover } from \"@base-ui/react/popover\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\n// Dismissals live in localStorage, with an in-memory fallback for when storage\n// is blocked (private windows, strict embeds). Every hotspot on the page, and\n// every tab, hears about a change.\nconst PREFIX = \"hotspot:\";\nconst memory = new Map<string, string>();\nconst EVENT = \"hotspot-change\";\n\nfunction read(key: string) {\n  try {\n    return window.localStorage.getItem(PREFIX + key) ?? memory.get(key) ?? null;\n  } catch {\n    return memory.get(key) ?? null;\n  }\n}\nfunction write(key: string, value: string | null) {\n  if (value === null) memory.delete(key);\n  else memory.set(key, value);\n  try {\n    if (value === null) window.localStorage.removeItem(PREFIX + key);\n    else window.localStorage.setItem(PREFIX + key, value);\n  } catch {\n    // Storage refused: the in-memory copy still holds for this page.\n  }\n  window.dispatchEvent(new Event(EVENT));\n}\nfunction subscribe(notify: () => void) {\n  window.addEventListener(EVENT, notify);\n  window.addEventListener(\"storage\", notify);\n  return () => {\n    window.removeEventListener(EVENT, notify);\n    window.removeEventListener(\"storage\", notify);\n  };\n}\n\n/** Forget a dismissal, so the hotspot shows again. */\nexport function resetHotspot(storageKey: string) {\n  write(storageKey, null);\n}\n\n/**\n * The dismissal state on its own. `ready` is false on the server and during\n * hydration, so nothing renders until the browser has been asked.\n */\nexport function useHotspot(storageKey: string) {\n  const value = useSyncExternalStore(\n    subscribe,\n    () => read(storageKey) ?? \"\",\n    () => null,\n  );\n  const dismiss = useCallback(() => write(storageKey, new Date().toISOString()), [storageKey]);\n  const reset = useCallback(() => write(storageKey, null), [storageKey]);\n  return { ready: value !== null, dismissed: !!value, dismiss, reset };\n}\n\ntype Corner = \"top-right\" | \"top-left\" | \"bottom-right\" | \"bottom-left\";\n\nexport type HotspotProps = Omit<React.ComponentProps<\"span\">, \"title\"> & {\n  /** Where the dismissal is remembered. Change it to show the hotspot again for a new release. */\n  storageKey: string;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** Link to the docs or changelog entry, opened in a new tab. */\n  learnMoreHref?: string;\n  learnMoreLabel?: string;\n  dismissLabel?: string;\n  /** Short label above the title. */\n  eyebrow?: string;\n  /** Wrap a control to pin the beacon to one of its corners. Without children the beacon sits inline. */\n  children?: React.ReactNode;\n  corner?: Corner;\n  /** How far inside the corner the beacon's center sits, in px. */\n  inset?: number;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Portal target for the popover, e.g. a scroll container. Defaults to document.body. */\n  container?: HTMLElement | null;\n  onDismiss?: () => void;\n};\n\n// The beacon's center sits on the corner, pulled in by --hotspot-inset so it overlaps rounded controls.\nconst corners: Record<Corner, string> = {\n  \"top-right\": \"right-(--hotspot-inset) top-(--hotspot-inset) translate-x-1/2 -translate-y-1/2\",\n  \"top-left\": \"left-(--hotspot-inset) top-(--hotspot-inset) -translate-x-1/2 -translate-y-1/2\",\n  \"bottom-right\": \"right-(--hotspot-inset) bottom-(--hotspot-inset) translate-x-1/2 translate-y-1/2\",\n  \"bottom-left\": \"left-(--hotspot-inset) bottom-(--hotspot-inset) -translate-x-1/2 translate-y-1/2\",\n};\n\n/**\n * A pulsing beacon on something new. It pulses until it's been looked at,\n * explains itself in a small popover, and once dismissed stays gone.\n */\nexport function Hotspot({\n  storageKey,\n  title,\n  description,\n  learnMoreHref,\n  learnMoreLabel = \"Learn more\",\n  dismissLabel = \"Got it\",\n  eyebrow = \"New\",\n  children,\n  corner = \"top-right\",\n  inset = 4,\n  side = \"bottom\",\n  align = \"center\",\n  container,\n  onDismiss,\n  className,\n  ...rest\n}: HotspotProps) {\n  const { ready, dismissed, dismiss } = useHotspot(storageKey);\n  const [open, setOpen] = useState(false);\n  const [seen, setSeen] = useState(false);\n  const [visible, setVisible] = useState(true);\n  const leaving = useRef(false);\n  const wrapper = useRef<HTMLSpanElement>(null);\n  const beacon = useRef<HTMLButtonElement>(null);\n  const gotIt = useRef<HTMLButtonElement>(null);\n  const reduce = useReducedMotion();\n\n  // The pulse is a loop: stop it when the beacon scrolls away.\n  useEffect(() => {\n    const el = beacon.current;\n    if (!el) return;\n    const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    io.observe(el);\n    return () => io.disconnect();\n  }, [ready, dismissed]);\n\n  const show = ready && !dismissed;\n  const plain = typeof title === \"string\" ? title : \"New feature\";\n\n  const trigger = (\n    <AnimatePresence>\n      {show && (\n        <Popover.Root\n          key=\"beacon\"\n          open={open}\n          onOpenChange={(next) => {\n            setOpen(next);\n            if (next) setSeen(true);\n          }}\n        >\n          <Popover.Trigger\n            ref={beacon}\n            aria-label={`${eyebrow}: ${plain}`}\n            data-seen={seen ? \"\" : undefined}\n            render={\n              <motion.button\n                type=\"button\"\n                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                animate={{ opacity: 1, scale: 1 }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, transition: { duration: 0.16, ease: ease.in } }}\n                transition={reduce ? { duration: 0.15 } : spring.pop}\n              />\n            }\n            className={cn(\n              \"group/beacon grid size-5 place-items-center rounded-full outline-none\",\n              \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n              // 20px on a mouse, 44px under a finger.\n              \"before:absolute before:-inset-3 before:rounded-full before:content-[''] pointer-fine:before:hidden\",\n              children ? cn(\"absolute z-[1]\", corners[corner]) : \"relative inline-grid align-middle\",\n            )}\n          >\n            {/* The ring pulses until the hotspot has been opened once, and pauses off screen. */}\n            <span\n              aria-hidden\n              className={cn(\n                \"absolute size-2 rounded-full bg-fg/50\",\n                \"animate-ping-soft motion-reduce:hidden group-data-seen/beacon:hidden group-data-popup-open/beacon:hidden\",\n                !visible && \"[animation-play-state:paused]\",\n              )}\n            />\n            <span\n              aria-hidden\n              className={cn(\n                \"relative size-2 rounded-full bg-fg ring-2 ring-raised\",\n                \"transition-[scale] duration-150 ease-out-expo group-hover/beacon:scale-125 group-active/beacon:scale-90 group-data-popup-open/beacon:scale-125\",\n              )}\n            />\n          </Popover.Trigger>\n          <Popover.Portal container={container ?? undefined}>\n            <Popover.Positioner side={side} align={align} sideOffset={10} collisionPadding={12} arrowPadding={14} className=\"z-(--z-popover)\">\n              <Popover.Popup\n                initialFocus={gotIt}\n                finalFocus={() => {\n                  if (!leaving.current) return true;\n                  leaving.current = false;\n                  // The beacon is gone; land on the thing it pointed at instead of the page.\n                  return wrapper.current?.querySelector<HTMLElement>(\"button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])\") ?? false;\n                }}\n                className={cn(\n                  \"relative w-64 max-w-[var(--available-width)] rounded-xl border border-line-2 bg-raised p-3.5 text-fg shadow-pop outline-none\",\n                  \"origin-[var(--transform-origin)] transition-[opacity,scale,translate,filter] duration-200 ease-out-expo\",\n                  \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-starting-style:blur-[2px]\",\n                  \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n                  \"data-[side=left]:data-starting-style:translate-x-1 data-[side=right]:data-starting-style:-translate-x-1\",\n                  \"data-ending-style:scale-[0.98] data-ending-style:opacity-0 data-ending-style:duration-150 data-ending-style:ease-out-quart\",\n                  \"data-instant:transition-none\",\n                  \"motion-reduce:data-starting-style:translate-0 motion-reduce:data-starting-style:scale-100 motion-reduce:data-starting-style:blur-none motion-reduce:data-ending-style:scale-100\",\n                )}\n              >\n                <HotspotArrow />\n                {eyebrow && <p className=\"mb-1 font-mono text-[10.5px] uppercase tracking-[0.08em] text-fg-3\">{eyebrow}</p>}\n                <Popover.Title className=\"text-[13.5px] font-medium leading-[1.3] tracking-[-0.01em] text-fg text-balance\">{title}</Popover.Title>\n                {description && <Popover.Description className=\"mt-1 text-[12.5px] leading-[1.5] text-fg-2 text-pretty\">{description}</Popover.Description>}\n                <div className=\"mt-3 flex items-center justify-between gap-2\">\n                  {learnMoreHref ? (\n                    <a\n                      href={learnMoreHref}\n                      target=\"_blank\"\n                      rel=\"noopener noreferrer\"\n                      className={cn(\n                        \"group/link -ml-1 inline-flex h-7 items-center gap-1 rounded-md px-1 text-[12px] text-fg-2 outline-none\",\n                        \"transition-colors duration-150 hover:text-fg\",\n                        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n                      )}\n                    >\n                      {learnMoreLabel}\n                      <ArrowUpRight className=\"size-3.5 transition-transform duration-150 ease-out-expo group-hover/link:-translate-y-px group-hover/link:translate-x-px\" />\n                    </a>\n                  ) : (\n                    <span />\n                  )}\n                  <button\n                    ref={gotIt}\n                    type=\"button\"\n                    onClick={() => {\n                      leaving.current = true;\n                      setOpen(false);\n                      dismiss();\n                      onDismiss?.();\n                    }}\n                    className={cn(\n                      \"inline-flex h-7 items-center rounded-md bg-fg px-2.5 text-[12px] font-medium text-frame shadow-[var(--shadow)] outline-none\",\n                      \"transition-[background-color,scale] duration-150 hover:bg-fg/90 active:scale-[0.97] active:duration-75\",\n                      \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n                    )}\n                  >\n                    {dismissLabel}\n                  </button>\n                </div>\n              </Popover.Popup>\n            </Popover.Positioner>\n          </Popover.Portal>\n        </Popover.Root>\n      )}\n    </AnimatePresence>\n  );\n\n  if (!children) return trigger;\n  return (\n    <span\n      ref={wrapper}\n      data-hotspot={show ? \"active\" : \"idle\"}\n      className={cn(\"relative inline-flex\", className)}\n      {...rest}\n      style={{ \"--hotspot-inset\": `${inset}px`, ...rest.style } as React.CSSProperties}\n    >\n      {children}\n      {trigger}\n    </span>\n  );\n}\n\nfunction HotspotArrow() {\n  return (\n    <Popover.Arrow\n      className={cn(\n        \"pointer-events-none flex h-2.5 w-5\",\n        \"data-[side=bottom]:-top-[9px]\",\n        \"data-[side=top]:-bottom-[9px] data-[side=top]:rotate-180\",\n        \"data-[side=left]:-right-3.5 data-[side=left]:rotate-90\",\n        \"data-[side=right]:-left-3.5 data-[side=right]:-rotate-90\",\n      )}\n    >\n      <svg width=\"20\" height=\"10\" viewBox=\"0 0 20 10\" fill=\"none\" aria-hidden className=\"block overflow-visible\">\n        <path d=\"M0 10c3.6 0 5.7-1.4 7.4-5.2L8.2 3c.8-1.6 2.8-1.6 3.6 0l.8 1.8c1.7 3.8 3.8 5.2 7.4 5.2z\" className=\"fill-raised\" />\n        <path d=\"M0 10c3.6 0 5.7-1.4 7.4-5.2L8.2 3c.8-1.6 2.8-1.6 3.6 0l.8 1.8c1.7 3.8 3.8 5.2 7.4 5.2\" className=\"stroke-line-2\" vectorEffect=\"non-scaling-stroke\" />\n      </svg>\n    </Popover.Arrow>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/hotspot"
}
