{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ghost",
  "title": "Ghost Guide",
  "description": "A tiny tour guide that floats by the cursor and reacts to your UI in one word.",
  "dependencies": [
    "motion",
    "class-variance-authority"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/clover-ui/ghost.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport {\n  AnimatePresence,\n  animate as animateValue,\n  motion,\n  type MotionValue,\n  useMotionValue,\n  useReducedMotion,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst ghostVariants = cva(\"shrink-0 overflow-visible\", {\n  variants: {\n    color: {\n      lime: \"[--ghost-top:var(--color-lime-300)] [--ghost-body:var(--color-lime-500)] [--ghost-line:var(--color-lime-900)]\",\n      white:\n        \"[--ghost-top:var(--color-white)] [--ghost-body:var(--color-zinc-300)] [--ghost-line:var(--color-zinc-900)]\",\n      pink: \"[--ghost-top:var(--color-pink-300)] [--ghost-body:var(--color-pink-500)] [--ghost-line:var(--color-pink-900)]\",\n      peach:\n        \"[--ghost-top:var(--color-orange-300)] [--ghost-body:var(--color-orange-500)] [--ghost-line:var(--color-orange-900)]\",\n      lemon:\n        \"[--ghost-top:var(--color-yellow-200)] [--ghost-body:var(--color-yellow-400)] [--ghost-line:var(--color-yellow-900)]\",\n      mint: \"[--ghost-top:var(--color-emerald-300)] [--ghost-body:var(--color-emerald-500)] [--ghost-line:var(--color-emerald-900)]\",\n      sky: \"[--ghost-top:var(--color-sky-300)] [--ghost-body:var(--color-sky-500)] [--ghost-line:var(--color-sky-900)]\",\n      lilac:\n        \"[--ghost-top:var(--color-violet-300)] [--ghost-body:var(--color-violet-500)] [--ghost-line:var(--color-violet-900)]\",\n    },\n    size: {\n      sm: \"size-12\",\n      md: \"size-18\",\n      lg: \"size-26\",\n    },\n  },\n  defaultVariants: {\n    color: \"lime\",\n    size: \"md\",\n  },\n});\n\nexport type GhostColor = NonNullable<VariantProps<typeof ghostVariants>[\"color\"]>;\nexport type GhostSize = NonNullable<VariantProps<typeof ghostVariants>[\"size\"]>;\nexport type GhostBoundary = \"parent\" | \"page\";\nexport type GhostWord = string | { hover?: string; press?: string };\nexport type GhostReaction = (target: Element, kind: \"hover\" | \"press\") => string | null | undefined;\nexport type GhostReactions = Record<string, GhostWord> | GhostReaction;\n\nexport interface GhostProps extends Omit<React.ComponentProps<\"div\">, \"color\"> {\n  color?: GhostColor;\n  size?: GhostSize;\n  boundary?: GhostBoundary;\n  thoughts?: boolean | string[];\n  onStateChange?: (state: GhostState) => void;\n  reactions?: GhostReactions;\n  message?: string;\n}\n\ntype GhostBodyProps = Omit<React.ComponentProps<\"svg\">, \"color\"> & {\n  color?: GhostColor;\n  size?: GhostSize;\n  facing?: MotionValue<number>;\n};\n\nconst sheet = (w: number) =>\n  [\n    \"M515 632\",\n    \"C470 632 445 668 438 720\",\n    \"C430 780 432 850 420 900\",\n    `C410 935 ${385 - w * 4} ${950 + w * 3} ${362 - w * 6} ${968 + w * 4}`,\n    `C390 ${985 + w * 2} 425 990 450 1000`,\n    `C480 1012 ${500 + w * 4} 1040 ${520 + w * 6} 1075`,\n    `C${540 + w * 8} 1110 ${575 + w * 12} 1125 ${612 + w * 16} ${1118 + w * 4}`,\n    `C${592 + w * 10} 1104 ${585 + w * 6} 1080 600 1050`,\n    \"C615 1020 650 1000 680 960\",\n    \"C665 935 650 900 645 860\",\n    \"C640 800 600 760 595 720\",\n    \"C588 668 560 632 515 632Z\",\n  ].join(\"\");\n\nconst between = (min: number, max: number) => min + Math.random() * (max - min);\n\nconst DRIPS = [\n  { cx: 452, cy: 1004, rx: 13, ry: 16, time: 2.8 },\n  { cx: 560, cy: 1092, rx: 10, ry: 13, time: 3.4 },\n  { cx: 650, cy: 990, rx: 11, ry: 14, time: 3.1 },\n];\n\nfunction GhostBody({ color, size, facing, className, ref, ...props }: GhostBodyProps) {\n  const still = Boolean(useReducedMotion());\n  const id = React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\");\n  const svgRef = React.useRef<SVGSVGElement | null>(null);\n  const lookX = useSpring(0, { stiffness: 700, damping: 40 });\n  const lookY = useSpring(0, { stiffness: 700, damping: 40 });\n  const blink = useMotionValue(1);\n  const turn = (v: number, near: boolean) =>\n    near ? 1 + (Math.abs(v) / 10) * 0.08 : 1 - (Math.abs(v) / 10) * 0.3;\n  const leftEye = useTransform(lookX, (v) => turn(v, v <= 0));\n  const rightEye = useTransform(lookX, (v) => turn(v, v >= 0));\n  const leftShift = useTransform(lookX, (v) => Math.max(0, v) * 0.3);\n  const rightShift = useTransform(lookX, (v) => Math.min(0, v) * 0.3);\n\n  const setRef = (node: SVGSVGElement | null) => {\n    svgRef.current = node;\n    if (typeof ref === \"function\") ref(node);\n    else if (ref) ref.current = node;\n  };\n\n  React.useEffect(() => {\n    if (still) return;\n    let lastMove = 0;\n    let glance = 0;\n    let blinking = 0;\n    let running: ReturnType<typeof animateValue> | null = null;\n\n    const onMove = (event: PointerEvent) => {\n      const box = svgRef.current?.getBoundingClientRect();\n      if (!box) return;\n      const dx = event.clientX - (box.left + box.width * 0.48);\n      const dy = event.clientY - (box.top + box.height * 0.22);\n      const reach = Math.min(1, Math.hypot(dx, dy) / (box.width * 1.5));\n      const angle = Math.atan2(dy, dx);\n      const side = (facing?.get() ?? 1) < 0 ? -1 : 1;\n      lookX.set(Math.max(-10, Math.min(9, Math.cos(angle) * 15 * reach * side)));\n      lookY.set(Math.sin(angle) * 10 * reach);\n      lastMove = performance.now();\n    };\n\n    const look = () => {\n      if (performance.now() - lastMove > 1600) {\n        const center = Math.random() < 0.3;\n        lookX.set(center ? 0 : between(-10, 9));\n        lookY.set(center ? 0 : between(-8, 9));\n      }\n      glance = window.setTimeout(look, between(700, 2600));\n    };\n\n    const shut = () => {\n      const twice = Math.random() < 0.2;\n      running = animateValue(blink, twice ? [1, 0.08, 1, 0.08, 1] : [1, 0.08, 1], {\n        duration: twice ? 0.38 : 0.18,\n        ease: \"easeInOut\",\n      });\n      blinking = window.setTimeout(shut, between(2200, 6000));\n    };\n\n    window.addEventListener(\"pointermove\", onMove);\n    glance = window.setTimeout(look, between(400, 1200));\n    blinking = window.setTimeout(shut, between(1500, 3500));\n\n    return () => {\n      window.removeEventListener(\"pointermove\", onMove);\n      window.clearTimeout(glance);\n      window.clearTimeout(blinking);\n      running?.stop();\n    };\n  }, [still, lookX, lookY, blink, facing]);\n\n  const ripple = { duration: 2.2, repeat: Infinity, ease: \"easeInOut\" as const };\n\n  return (\n    <svg\n      ref={setRef}\n      aria-hidden\n      viewBox=\"320 600 420 560\"\n      className={cn(ghostVariants({ color, size }), className)}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      {...props}\n    >\n      <defs>\n        <clipPath id={`${id}-body`}>\n          <path d={sheet(0)} />\n        </clipPath>\n        <linearGradient id={`${id}-fill`} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n          <stop offset=\"0\" stopColor=\"var(--ghost-top)\" />\n          <stop offset=\"0.75\" stopColor=\"var(--ghost-body)\" />\n        </linearGradient>\n      </defs>\n      <motion.g\n        animate={still ? undefined : { y: [0, -14, 0], rotate: [-1.5, 1.5, -1.5] }}\n        transition={{ duration: 3.2, repeat: Infinity, ease: \"easeInOut\" }}\n      >\n        <motion.g\n          style={{ transformBox: \"view-box\", transformOrigin: \"520px 1080px\" }}\n          animate={still ? undefined : { scaleX: [1, 1.035, 0.98, 1], scaleY: [1, 0.97, 1.02, 1] }}\n          transition={{ duration: 2.6, repeat: Infinity, ease: \"easeInOut\" }}\n        >\n          <g fill=\"var(--ghost-body)\">\n            {DRIPS.map((drip) => (\n              <motion.ellipse\n                key={drip.cx}\n                cx={drip.cx}\n                cy={drip.cy}\n                rx={drip.rx}\n                ry={drip.ry}\n                initial={{ ry: drip.ry, cy: drip.cy }}\n                animate={\n                  still\n                    ? undefined\n                    : {\n                        ry: [drip.ry, drip.ry * 1.9, drip.ry],\n                        cy: [drip.cy, drip.cy + drip.ry * 0.9, drip.cy],\n                      }\n                }\n                transition={{ duration: drip.time, repeat: Infinity, ease: \"easeInOut\" }}\n              />\n            ))}\n          </g>\n          <motion.path\n            d={sheet(0)}\n            fill={`url(#${id}-fill)`}\n            initial={{ d: sheet(0) }}\n            animate={still ? undefined : { d: [sheet(0), sheet(1), sheet(-1), sheet(0)] }}\n            transition={ripple}\n          />\n          <g clipPath={`url(#${id}-body)`}>\n            <ellipse cx={612} cy={960} rx={10} ry={26} fill=\"white\" opacity={0.35} />\n            <motion.g style={{ x: lookX, y: lookY, scaleY: blink }}>\n              <motion.g style={{ scaleX: leftEye, x: leftShift }}>\n                <ellipse\n                  cx={518}\n                  cy={708}\n                  rx={18}\n                  ry={33}\n                  fill=\"var(--ghost-line)\"\n                  transform=\"rotate(-4 518 708)\"\n                />\n                <circle cx={524} cy={693} r={7} fill=\"white\" />\n              </motion.g>\n              <motion.g style={{ scaleX: rightEye, x: rightShift }}>\n                <ellipse\n                  cx={561}\n                  cy={706}\n                  rx={18}\n                  ry={33}\n                  fill=\"var(--ghost-line)\"\n                  transform=\"rotate(4 561 706)\"\n                />\n                <circle cx={567} cy={691} r={7} fill=\"white\" />\n              </motion.g>\n            </motion.g>\n          </g>\n        </motion.g>\n      </motion.g>\n    </svg>\n  );\n}\n\nconst SIZES: Record<GhostSize, number> = { sm: 48, md: 72, lg: 104 };\nconst HEAD_X = 0.47;\nconst HEAD_TOP = 0.08;\n\nconst IDLE_THOUGHTS = [\"hmm\", \"boo\", \"👻\", \"lalala\", \"nice\"];\nconst GIGGLES = [\"hehe\", \"missed\", \"nope\", \"heh\", \"boo\"];\nconst CLICKABLE = [\"ooh\", \"click\", \"shiny\"];\n\nconst react = (reactions: GhostReactions | undefined, target: Element, kind: \"hover\" | \"press\") => {\n  if (!reactions) return null;\n  if (typeof reactions === \"function\") return reactions(target, kind);\n  for (const [selector, word] of Object.entries(reactions)) {\n    let match: Element | null = null;\n    try {\n      match = target.closest(selector);\n    } catch {\n      continue;\n    }\n    if (!match) continue;\n    if (typeof word === \"string\") return word;\n    return (kind === \"press\" ? word.press : word.hover) ?? word.hover ?? word.press ?? null;\n  }\n  return null;\n};\n\nconst pick = (list: string[]) => list[Math.floor(Math.random() * list.length)];\n\nconst cloud = (() => {\n  const points = Array.from({ length: 10 }, (_, i) => {\n    const angle = (i / 10) * Math.PI * 2 - Math.PI / 2;\n    return [100 + Math.cos(angle) * 84, 60 + Math.sin(angle) * 46];\n  });\n  return (\n    points\n      .map(([px, py], i) => {\n        const [nx, ny] = points[(i + 1) % points.length];\n        const radius = Math.hypot(nx - px, ny - py) * 0.6;\n        return `${i === 0 ? `M${px} ${py}` : \"\"}A${radius} ${radius} 0 0 1 ${nx} ${ny}`;\n      })\n      .join(\"\") + \"Z\"\n  );\n})();\n\ntype Thought = { id: number; text: string; shift: number };\n\nfunction ThoughtBubble({ thought, box }: { thought: Thought; box: number }) {\n  const dots = [7, 5, 3.5];\n  return (\n    <motion.div\n      className=\"absolute flex -translate-x-1/2 flex-col items-center\"\n      style={{ left: box * HEAD_X, bottom: box * (1 - HEAD_TOP) + 2, transformOrigin: \"50% 100%\" }}\n      initial={{ opacity: 0, scale: 0.5 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0, scale: 0.7 }}\n      transition={{ type: \"spring\", stiffness: 380, damping: 24 }}\n    >\n      <div className=\"relative px-6 py-5\" style={{ translate: `${thought.shift}px 0` }}>\n        <svg\n          aria-hidden\n          viewBox=\"0 0 200 120\"\n          preserveAspectRatio=\"none\"\n          className=\"fill-background stroke-foreground absolute inset-0 size-full overflow-visible\"\n        >\n          <path d={cloud} strokeWidth={2} vectorEffect=\"non-scaling-stroke\" />\n        </svg>\n        <span className=\"text-foreground relative block text-sm font-medium whitespace-nowrap\">\n          {thought.text}\n        </span>\n      </div>\n      {dots.map((dot, index) => (\n        <motion.span\n          key={dot}\n          className=\"bg-background border-foreground mt-0.5 block shrink-0 rounded-full border-[1.5px]\"\n          style={{ width: dot, height: dot, translateX: (2 - index) * 2 }}\n          initial={{ scale: 0 }}\n          animate={{ scale: 1 }}\n          transition={{ delay: (2 - index) * 0.06 }}\n        />\n      ))}\n    </motion.div>\n  );\n}\n\nexport type GhostState = { thought: string | null; visible: boolean; hopping: boolean };\n\nexport type GhostApi = {\n  state: GhostState;\n  say: (word: string) => void;\n  poof: () => void;\n  visit: (target: Element | null, word?: string) => void;\n  hide: () => void;\n  show: () => void;\n};\n\ntype GhostCommands = Omit<GhostApi, \"state\">;\n\nconst IDLE_STATE: GhostState = { thought: null, visible: true, hopping: false };\n\nfunction createGhostStore() {\n  let state = IDLE_STATE;\n  let commands: Partial<GhostCommands> = {};\n  const listeners = new Set<() => void>();\n  return {\n    get: () => state,\n    set: (next: Partial<GhostState>) => {\n      state = { ...state, ...next };\n      listeners.forEach((listener) => listener());\n    },\n    subscribe: (listener: () => void) => {\n      listeners.add(listener);\n      return () => {\n        listeners.delete(listener);\n      };\n    },\n    bind: (next: Partial<GhostCommands>) => {\n      commands = next;\n      return () => {\n        if (commands === next) commands = {};\n      };\n    },\n    run: <K extends keyof GhostCommands>(name: K, ...args: Parameters<GhostCommands[K]>) =>\n      (commands[name] as ((...input: Parameters<GhostCommands[K]>) => void) | undefined)?.(...args),\n  };\n}\n\ntype GhostStore = ReturnType<typeof createGhostStore>;\n\nconst GhostContext = React.createContext<GhostStore | null>(null);\n\nconst ignore = () => () => {};\nconst idle = () => IDLE_STATE;\n\nfunction useGhost(): GhostApi {\n  const store = React.useContext(GhostContext);\n  const state = React.useSyncExternalStore(store?.subscribe ?? ignore, store?.get ?? idle, idle);\n  const commands = React.useMemo<GhostCommands>(\n    () => ({\n      say: (word) => store?.run(\"say\", word),\n      poof: () => store?.run(\"poof\"),\n      visit: (target, word) => store?.run(\"visit\", target, word),\n      hide: () => store?.run(\"hide\"),\n      show: () => store?.run(\"show\"),\n    }),\n    [store],\n  );\n  return React.useMemo(() => ({ ...commands, state }), [commands, state]);\n}\n\ntype GhostProviderProps = GhostProps & { ghost?: boolean };\n\nfunction GhostProvider({ children, ghost = true, ...props }: GhostProviderProps) {\n  const [store] = React.useState(createGhostStore);\n  return (\n    <GhostContext.Provider value={store}>\n      {children}\n      {ghost && <Ghost {...props} />}\n    </GhostContext.Provider>\n  );\n}\n\nconst subscribeNever = () => () => {};\nconst isClient = () => true;\nconst isServer = () => false;\n\nfunction Ghost({\n  color,\n  size = \"md\",\n  boundary = \"page\",\n  thoughts = true,\n  reactions,\n  message,\n  onStateChange,\n  className,\n  style,\n  ref,\n  ...props\n}: GhostProps) {\n  const still = Boolean(useReducedMotion());\n  const reactionsRef = React.useRef(reactions);\n  const store = React.useContext(GhostContext);\n  const onStateRef = React.useRef(onStateChange);\n  const sayRef = React.useRef<((text: string, force?: boolean) => void) | null>(null);\n  const messageRef = React.useRef(message);\n  const layerRef = React.useRef<HTMLDivElement>(null);\n  const anchorRef = React.useRef<HTMLSpanElement>(null);\n  const mounted = React.useSyncExternalStore(subscribeNever, isClient, isServer);\n  React.useImperativeHandle(\n    ref,\n    () => (mounted || boundary === \"parent\" ? layerRef.current! : null!),\n    [mounted, boundary],\n  );\n  const box = SIZES[size];\n  const x = useMotionValue(0);\n  const y = useMotionValue(0);\n  const face = useSpring(1, { stiffness: 60, damping: 14 });\n  const wiggle = useMotionValue(0);\n  const squashX = useMotionValue(1);\n  const squashY = useMotionValue(1);\n  const shown = useMotionValue(1);\n  const turnY = useTransform(() => (1 - face.get()) * 90);\n  const [thought, setThought] = React.useState<Thought | null>(null);\n  const ideas = Array.isArray(thoughts) && thoughts.length ? thoughts : IDLE_THOUGHTS;\n  const ideasRef = React.useRef(ideas);\n  const chattyRef = React.useRef(thoughts !== false);\n\n  React.useEffect(() => {\n    ideasRef.current = ideas;\n    chattyRef.current = thoughts !== false;\n    reactionsRef.current = reactions;\n    onStateRef.current = onStateChange;\n    messageRef.current = message;\n  });\n\n  React.useEffect(() => {\n    if (!message) return;\n    const t = window.setTimeout(() => sayRef.current?.(message, true), 600);\n    return () => window.clearTimeout(t);\n  }, [message]);\n\n  React.useEffect(() => {\n    const layer = layerRef.current;\n    if (!mounted || !layer) return;\n    const edge = 8;\n    const headroom = 72;\n    const gap = box * 0.85;\n    const comfort = box * 2.6;\n    const area = () => layer.getBoundingClientRect();\n    const pos = { x: 0, y: 0 };\n    const place = (px: number, py: number) => {\n      const { width, height } = area();\n      pos.x = Math.min(width - box - edge, Math.max(edge, px));\n      pos.y = Math.min(height - box - edge, Math.max(Math.min(headroom, height - box - edge), py));\n      x.set(pos.x);\n      y.set(pos.y);\n    };\n\n    const host = (boundary === \"page\" ? anchorRef.current : layer)?.parentElement;\n    if (host) {\n      const spot = host.getBoundingClientRect();\n      const frame = area();\n      place(\n        spot.left + spot.width / 2 - frame.left - box / 2,\n        spot.top + spot.height / 2 - frame.top - box / 2,\n      );\n    } else {\n      place(area().width - box - 24, area().height - box - 24);\n    }\n\n    let current = IDLE_STATE;\n    const publish = (next: Partial<GhostState>) => {\n      current = { ...current, ...next };\n      store?.set(next);\n      onStateRef.current?.(current);\n    };\n\n    if (still) {\n      const unbind = store?.bind({\n        say: () => {},\n        poof: () => {},\n        visit: (target) => {\n          if (!target) return;\n          const spot = target.getBoundingClientRect();\n          const frame = area();\n          place(\n            spot.left + spot.width / 2 - frame.left - box / 2,\n            spot.top + spot.height / 2 - frame.top - box / 2,\n          );\n        },\n        hide: () => {\n          shown.set(0);\n          publish({ visible: false });\n        },\n        show: () => {\n          shown.set(1);\n          publish({ visible: true });\n        },\n      });\n      return () => unbind?.();\n    }\n\n    const running: ReturnType<typeof animateValue>[] = [];\n    const timers = new Set<number>();\n    const later = (fn: () => void, ms: number) => {\n      const t = window.setTimeout(() => {\n        timers.delete(t);\n        fn();\n      }, ms);\n      timers.add(t);\n      return t;\n    };\n    let cursor: { x: number; y: number } | null = null;\n    let pointer = { x: -1, y: -1 };\n    let scrolling = 0;\n    let settle = 0;\n    let busy = false;\n    let lastMove = 0;\n    let hush = 0;\n    let idle = 0;\n    let lastSaid = 0;\n    let lastText = \"\";\n    let count = 0;\n    let away = false;\n    let guide: unknown = null;\n    let dwell = 0;\n    let shy = 0;\n    const close = () =>\n      !!cursor && Math.hypot(pos.x + box / 2 - cursor.x, pos.y + box / 2 - cursor.y) < box * 1.1;\n    let hidden = false;\n\n    const think = (next: Thought | null) => {\n      setThought(next);\n      publish({ thought: next?.text ?? null });\n    };\n\n    const say = (text: string, force = false) => {\n      const now = performance.now();\n      if (hidden || !chattyRef.current || (!force && now - lastSaid < 3500)) return;\n      lastSaid = now;\n      lastText = text;\n      count += 1;\n      const { width } = area();\n      const half = (text.length * 7.5 + 60) / 2;\n      const head = pos.x + box * HEAD_X + 12;\n      const shift = 12 + Math.max(edge - (head - half), Math.min(0, width - edge - (head + half)));\n      think({ id: count, text, shift });\n      window.clearTimeout(hush);\n      hush = window.setTimeout(() => think(null), 2600);\n    };\n    sayRef.current = say;\n\n    const play = (value: typeof x, keyframes: number[], duration: number) =>\n      new Promise<void>((resolve) => {\n        running.push(\n          animateValue(value, keyframes, { duration, ease: \"easeInOut\", onComplete: resolve }),\n        );\n      });\n\n    const melt = () =>\n      Promise.all([\n        play(squashY, [1, 1.08, 0.25], 0.32),\n        play(squashX, [1, 0.94, 1.35], 0.32),\n        play(shown, [1, 1, 0], 0.32),\n      ]);\n\n    const pop = () =>\n      Promise.all([\n        play(squashY, [0.25, 1.15, 0.94, 1], 0.55),\n        play(squashX, [1.35, 0.9, 1.04, 1], 0.55),\n        play(shown, [0, 1, 1, 1], 0.55),\n      ]);\n\n    const beside = () => {\n      if (!cursor) return { x: pos.x, y: pos.y };\n      const { width } = area();\n      const right = cursor.x + gap + box < width - edge;\n      return { x: right ? cursor.x + gap : cursor.x - gap - box, y: cursor.y - gap * 0.9 };\n    };\n\n    const nearby = () => {\n      const { width, height } = area();\n      const from = cursor ?? { x: width / 2, y: height / 2 };\n      for (let i = 0; i < 12; i++) {\n        const angle = between(0, Math.PI * 2);\n        const radius = between(box * 1.6, box * 2.4);\n        const spot = {\n          x: from.x + Math.cos(angle) * radius - box / 2,\n          y: from.y + Math.sin(angle) * radius - box / 2,\n        };\n        if (\n          spot.x > edge &&\n          spot.x < width - box - edge &&\n          spot.y > headroom &&\n          spot.y < height - box - edge\n        )\n          return spot;\n      }\n      return beside();\n    };\n\n    const hop = async (to: { x: number; y: number }, line?: string) => {\n      if (busy || hidden) return;\n      busy = true;\n      publish({ hopping: true });\n      const carry = line ?? (performance.now() - lastSaid < 1500 ? lastText : undefined);\n      think(null);\n      await melt();\n      place(to.x, to.y);\n      if (cursor) face.jump(cursor.x < pos.x + box / 2 ? -1 : 1);\n      await pop();\n      busy = false;\n      publish({ hopping: false });\n      if (carry) say(carry, true);\n      if (line) {\n        running.push(\n          animateValue(wiggle, [0, -9, 9, -7, 7, -4, 0], { duration: 0.6, ease: \"easeInOut\" }),\n        );\n      }\n    };\n\n    const check = () => {\n      if (!cursor || busy || hidden) return;\n      const far = Math.hypot(pos.x + box / 2 - cursor.x, pos.y + box / 2 - cursor.y) > comfort;\n      if (far) hop(beside());\n      else face.set(cursor.x < pos.x + box / 2 ? -1 : 1);\n    };\n\n    const onScroll = () => {\n      scrolling = performance.now() + 300;\n    };\n\n    const onPointer = (event: PointerEvent) => {\n      const moved = event.clientX !== pointer.x || event.clientY !== pointer.y;\n      pointer = { x: event.clientX, y: event.clientY };\n      if (event.type === \"pointermove\" && (!moved || performance.now() < scrolling)) return;\n      const rect = area();\n      const inside =\n        event.clientX >= rect.left &&\n        event.clientX <= rect.right &&\n        event.clientY >= rect.top &&\n        event.clientY <= rect.bottom;\n      if (!inside) {\n        if (cursor) {\n          cursor = null;\n          away = true;\n          say(\"bye\", true);\n        }\n        return;\n      }\n      cursor = { x: event.clientX - rect.left, y: event.clientY - rect.top };\n      lastMove = performance.now();\n\n      if (!close()) {\n        window.clearTimeout(shy);\n        shy = 0;\n      } else if (!shy) {\n        shy = later(() => {\n          shy = 0;\n          if (close() && !busy && !hidden) hop(nearby(), pick(GIGGLES));\n        }, 3000);\n      }\n\n      if (\n        event.type === \"pointerdown\" &&\n        !hidden &&\n        cursor.x >= pos.x &&\n        cursor.x <= pos.x + box &&\n        cursor.y >= pos.y &&\n        cursor.y <= pos.y + box\n      ) {\n        hop(nearby(), pick(GIGGLES));\n        return;\n      }\n\n      const target = event.target instanceof Element ? event.target : null;\n      const kind = event.type === \"pointerdown\" ? \"press\" : \"hover\";\n      const marked = target?.closest(\"[data-ghost]\") ?? null;\n      const custom = target && !marked ? react(reactionsRef.current, target, kind) : null;\n      const clickable =\n        marked || custom ? null : (target?.closest(\"a, button, [role='button']\") ?? null);\n      const text = marked\n        ? (kind === \"press\" && marked.getAttribute(\"data-ghost-press\")) ||\n          marked.getAttribute(\"data-ghost\") ||\n          \"ooh\"\n        : custom;\n      const key: unknown = text ?? clickable;\n      if (kind === \"press\") {\n        guide = key;\n        window.clearTimeout(dwell);\n        if (text || clickable) say(text ?? pick([\"click\", \"boop\", \"tap\"]), true);\n      } else if (key !== guide) {\n        guide = key;\n        window.clearTimeout(dwell);\n        if (text || clickable) {\n          dwell = later(() => {\n            if (guide !== key) return;\n            if (text) say(text, true);\n            else say(pick(CLICKABLE));\n          }, 300);\n        }\n      }\n\n      if (away) {\n        away = false;\n        say(\"yay\", true);\n      }\n\n      window.clearTimeout(settle);\n      settle = later(check, 350);\n    };\n\n    const daydream = () => {\n      if (cursor && performance.now() - lastMove > 9000) say(\"zzz\");\n      else if (Math.random() < 0.2) say(pick(ideasRef.current));\n      idle = window.setTimeout(daydream, between(3000, 5000));\n    };\n\n    const onResize = () => place(pos.x, pos.y);\n\n    const unbind = store?.bind({\n      say: (word) => {\n        window.clearTimeout(dwell);\n        say(word, true);\n      },\n      poof: () => hop(nearby(), pick(GIGGLES)),\n      visit: (target, word) => {\n        if (!target) return;\n        const spot = target.getBoundingClientRect();\n        const frame = area();\n        hop(\n          {\n            x: spot.left + spot.width / 2 - frame.left - box / 2,\n            y: spot.top + spot.height / 2 - frame.top - box / 2,\n          },\n          word,\n        );\n      },\n      hide: async () => {\n        if (hidden || busy) return;\n        busy = true;\n        think(null);\n        await melt();\n        hidden = true;\n        busy = false;\n        publish({ visible: false });\n      },\n      show: async () => {\n        if (!hidden) return;\n        hidden = false;\n        busy = true;\n        const spot = beside();\n        place(spot.x, spot.y);\n        await pop();\n        busy = false;\n        publish({ visible: true });\n      },\n    });\n\n    window.addEventListener(\"pointermove\", onPointer);\n    window.addEventListener(\"pointerdown\", onPointer);\n    window.addEventListener(\"resize\", onResize);\n    window.addEventListener(\"scroll\", onScroll, { capture: true, passive: true });\n    idle = window.setTimeout(daydream, 3000);\n    if (!messageRef.current) later(() => say(\"boo\", true), 1200);\n\n    return () => {\n      unbind?.();\n      sayRef.current = null;\n      window.removeEventListener(\"pointermove\", onPointer);\n      window.removeEventListener(\"pointerdown\", onPointer);\n      window.removeEventListener(\"resize\", onResize);\n      window.removeEventListener(\"scroll\", onScroll, { capture: true });\n      window.clearTimeout(idle);\n      window.clearTimeout(hush);\n      timers.forEach((t) => window.clearTimeout(t));\n      running.forEach((animation) => animation.stop());\n    };\n  }, [mounted, still, box, boundary, store, x, y, face, wiggle, squashX, squashY, shown]);\n\n  const layer = (\n    <div\n      ref={layerRef}\n      aria-hidden\n      className={cn(\n        \"pointer-events-none inset-0 z-50 overflow-visible\",\n        boundary === \"page\" ? \"fixed\" : \"absolute\",\n        className,\n      )}\n      style={style}\n      {...props}\n    >\n      <motion.div\n        className=\"absolute top-0 left-0\"\n        style={{ x, y, width: box, height: box, perspective: box * 8 }}\n      >\n        <motion.div\n          style={{\n            rotateY: turnY,\n            rotateZ: wiggle,\n            scaleX: squashX,\n            scaleY: squashY,\n            opacity: shown,\n            transformOrigin: \"50% 100%\",\n          }}\n        >\n          <GhostBody color={color} size={size} facing={face} />\n        </motion.div>\n        <AnimatePresence>\n          {thought && <ThoughtBubble key={thought.id} thought={thought} box={box} />}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n\n  if (boundary === \"parent\") return layer;\n  return (\n    <>\n      <span ref={anchorRef} hidden />\n      {mounted && createPortal(layer, document.body)}\n    </>\n  );\n}\n\nGhost.displayName = \"Ghost\";\nGhostProvider.displayName = \"GhostProvider\";\n\nexport { Ghost, GhostProvider, ghostVariants, useGhost };\n",
      "type": "registry:component"
    }
  ],
  "type": "registry:component"
}