{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"popover","type":"registry:component","title":"Popover Gooey Popover","description":"Popover, PopoverTrigger and PopoverContent with side, align, gap and corner radius props; trigger and panel melt together through a blur-based goo filter (gooStrength sets how runny), while the real content fades in sharp on top. Opens on click or hover.","author":"kara <ztzyrant@gmail.com>","dependencies":["clsx","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/popover.tsx","type":"registry:component","target":"@components/motion/popover.tsx","content":"\"use client\";\n// karaui.kamisama-v0.my.id/components/motion/popover\n\nimport {\n  animate,\n  type MotionValue,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  createContext,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { usePopoverPortalPosition } from \"@/components/motion/popover-position\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport {\n  type HoverGesture,\n  useHoverGesture,\n} from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\ntype Side = \"top\" | \"bottom\";\ntype Align = \"start\" | \"center\" | \"end\";\ntype TriggerMode = \"click\" | \"hover\";\n\n// This morph needs less bounce than layout motion: too much overshoot makes\n// the liquid neck balloon past the final panel edges.\nconst GOO_OPEN_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.3,\n  bounce: 0.15,\n} as const;\nconst GOO_CLOSE_SPRING = {\n  type: \"spring\",\n  visualDuration: 0.21,\n  bounce: 0.15,\n} as const;\nconst HOVER_CLOSE_DELAY = 120;\nconst CIRCLE_KAPPA = 0.5523;\n\n// `onPointerEnter`/`onPointerLeave` rather than the mouse pair: a tap fires\n// compatibility mouseenter/mouseleave that carry no pointerType at all, and\n// they are what made the panel flicker open and shut under a finger. The\n// gesture pairs the two, so the panel a pen tap opened is not closed again by\n// the boundary event that ends the same tap.\nfunction makeHoverHandlers(\n  hover: HoverGesture,\n  enter: () => void,\n  leave: () => void,\n) {\n  return {\n    onPointerEnter: (event: React.PointerEvent) => {\n      if (hover.enter(event)) enter();\n    },\n    onPointerLeave: (event: React.PointerEvent) => {\n      if (hover.leave(event)) leave();\n    },\n  };\n}\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\n\ninterface Rect {\n  x: number;\n  y: number;\n  w: number;\n  h: number;\n  r: number;\n}\ninterface Geo {\n  layerW: number;\n  layerH: number;\n  left: number;\n  top: number;\n  trigger: Rect;\n  panel: Rect;\n}\n\n// Trigger rect and panel rect in a shared local coordinate box.\nfunction buildGeo(\n  tW: number,\n  tH: number,\n  cW: number,\n  cH: number,\n  side: Side,\n  align: Align,\n  gap: number,\n  panelRadius: number,\n): Geo {\n  const py = side === \"bottom\" ? tH + gap : -(gap + cH);\n  const px = align === \"start\" ? 0 : align === \"end\" ? tW - cW : (tW - cW) / 2;\n\n  const left = Math.min(0, px);\n  const top = Math.min(0, py);\n  const layerW = Math.max(tW, px + cW) - left;\n  const layerH = Math.max(tH, py + cH) - top;\n\n  const triggerRadius = Math.min(tH / 2, panelRadius);\n\n  return {\n    layerW,\n    layerH,\n    left,\n    top,\n    trigger: { x: -left, y: -top, w: tW, h: tH, r: triggerRadius },\n    panel: { x: px - left, y: py - top, w: cW, h: cH, r: panelRadius },\n  };\n}\n\nfunction rectAtProgress(geo: Geo, progress: number): Rect {\n  const trigger = geo.trigger;\n  const panel = geo.panel;\n\n  return {\n    x: lerp(trigger.x, panel.x, progress),\n    y: lerp(trigger.y, panel.y, progress),\n    w: lerp(trigger.w, panel.w, progress),\n    h: lerp(trigger.h, panel.h, progress),\n    r: lerp(trigger.r, panel.r, progress),\n  };\n}\n\nfunction insetFor(rect: Rect, layerW: number, layerH: number) {\n  const top = rect.y;\n  const right = layerW - (rect.x + rect.w);\n  const bottom = layerH - (rect.y + rect.h);\n  const left = rect.x;\n  return `inset(${top}px ${right}px ${bottom}px ${left}px round ${rect.r}px)`;\n}\n\nfunction roundedRectShape(rect: Rect) {\n  const radius = Math.max(0, Math.min(rect.r, rect.w / 2, rect.h / 2));\n  const control = radius * CIRCLE_KAPPA;\n  const x1 = rect.x;\n  const y1 = rect.y;\n  const x2 = rect.x + rect.w;\n  const y2 = rect.y + rect.h;\n  const px = (value: number) => `${value.toFixed(3)}px`;\n\n  return (\n    `shape(from ${px(x1 + radius)} ${px(y1)}, ` +\n    `line to ${px(x2 - radius)} ${px(y1)}, ` +\n    `curve to ${px(x2)} ${px(y1 + radius)} with ${px(x2 - radius + control)} ${px(y1)} / ${px(x2)} ${px(y1 + radius - control)}, ` +\n    `line to ${px(x2)} ${px(y2 - radius)}, ` +\n    `curve to ${px(x2 - radius)} ${px(y2)} with ${px(x2)} ${px(y2 - radius + control)} / ${px(x2 - radius + control)} ${px(y2)}, ` +\n    `line to ${px(x1 + radius)} ${px(y2)}, ` +\n    `curve to ${px(x1)} ${px(y2 - radius)} with ${px(x1 + radius - control)} ${px(y2)} / ${px(x1)} ${px(y2 - radius + control)}, ` +\n    `line to ${px(x1)} ${px(y1 + radius)}, ` +\n    `curve to ${px(x1 + radius)} ${px(y1)} with ${px(x1)} ${px(y1 + radius - control)} / ${px(x1 + radius - control)} ${px(y1)}, ` +\n    \"close)\"\n  );\n}\n\nfunction clipForProgress(geo: Geo, progress: number, supportsShape: boolean) {\n  const rect = rectAtProgress(geo, progress);\n  return supportsShape\n    ? roundedRectShape(rect)\n    : insetFor(rect, geo.layerW, geo.layerH);\n}\n\nfunction roundedRectPath(rect: Rect) {\n  const radius = Math.max(0, Math.min(rect.r, rect.w / 2, rect.h / 2));\n  const n = (value: number) => value.toFixed(3);\n  const x1 = rect.x;\n  const y1 = rect.y;\n  const x2 = rect.x + rect.w;\n  const y2 = rect.y + rect.h;\n  const arc = `A${n(radius)} ${n(radius)} 0 0 1`;\n\n  // A zero radius makes every arc degenerate to a line, so this also draws\n  // plain rectangles.\n  return (\n    `M${n(x1 + radius)} ${n(y1)}` +\n    `H${n(x2 - radius)}${arc} ${n(x2)} ${n(y1 + radius)}` +\n    `V${n(y2 - radius)}${arc} ${n(x2 - radius)} ${n(y2)}` +\n    `H${n(x1 + radius)}${arc} ${n(x1)} ${n(y2 - radius)}` +\n    `V${n(y1 + radius)}${arc} ${n(x1 + radius)} ${n(y1)}Z`\n  );\n}\n\n// The goo layer is portalled above the page, so its copy of the trigger pill\n// would cover the real trigger's label and focus ring. Punching the trigger\n// back out keeps the real one visible and clips the blur to the layer box.\n// This is a clip path rather than a CSS mask on purpose: WebKit silently\n// ignores `mask: url(#id)` pointing at an SVG <mask> element, which left the\n// label hidden behind the goo in Safari.\nfunction triggerCutout(geo: Geo) {\n  const layer = { x: 0, y: 0, w: geo.layerW, h: geo.layerH, r: 0 };\n  return `path(evenodd, \"${roundedRectPath(layer)} ${roundedRectPath(geo.trigger)}\")`;\n}\n\ninterface PopoverContextValue {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  toggle: () => void;\n  openHover: () => void;\n  scheduleClose: () => void;\n  triggerMode: TriggerMode;\n  side: Side;\n  align: Align;\n  gap: number;\n  panelRadius: number;\n  gooStrength: number;\n  reduce: boolean;\n  gooId: string;\n  contentId: string;\n  progress: MotionValue<number>;\n  triggerRef: React.MutableRefObject<HTMLElement | null>;\n  contentRef: React.MutableRefObject<HTMLDivElement | null>;\n}\n\nconst PopoverContext = createContext<PopoverContextValue | null>(null);\n\nfunction usePopoverContext(component: string) {\n  const ctx = useContext(PopoverContext);\n  if (!ctx) throw new Error(`${component} must be used within <Popover>`);\n  return ctx;\n}\n\nexport interface PopoverProps {\n  children: ReactNode;\n  /** Controlled open state. */\n  open?: boolean;\n  /** Uncontrolled initial open state. */\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** How the popover is summoned. Default \"click\". */\n  trigger?: TriggerMode;\n  /** Which side of the trigger the panel oozes out of. Default \"bottom\". */\n  side?: Side;\n  /** Alignment along the trigger's edge. Default \"center\". */\n  align?: Align;\n  /** Gap between trigger and panel, in px — the length of the gooey neck. Default 14. */\n  sideOffset?: number;\n  /** Corner radius of the open panel, in px. Default 16. */\n  panelRadius?: number;\n  /** Blur radius feeding the goo filter — higher melts more. Default 8. */\n  gooStrength?: number;\n  className?: string;\n}\n\nexport function Popover({\n  children,\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  trigger = \"click\",\n  side = \"bottom\",\n  align = \"center\",\n  sideOffset = 14,\n  panelRadius = 16,\n  gooStrength = 8,\n  className,\n}: PopoverProps) {\n  const reduce = useReducedMotion() ?? false;\n  const gooId = useId().replace(/:/g, \"\");\n  const contentId = useId();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLElement | null>(null);\n  const contentRef = useRef<HTMLDivElement | null>(null);\n  const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const rootHover = useHoverGesture();\n  const progress = useMotionValue(defaultOpen ? 1 : 0);\n\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const controlled = controlledOpen !== undefined;\n  const open = controlled ? controlledOpen : internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (!controlled) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlled, onOpenChange],\n  );\n\n  const cancelClose = useCallback(() => {\n    if (closeTimer.current) {\n      clearTimeout(closeTimer.current);\n      closeTimer.current = null;\n    }\n  }, []);\n\n  const openHover = useCallback(() => {\n    cancelClose();\n    setOpen(true);\n  }, [cancelClose, setOpen]);\n\n  const scheduleClose = useCallback(() => {\n    cancelClose();\n    closeTimer.current = setTimeout(() => setOpen(false), HOVER_CLOSE_DELAY);\n  }, [cancelClose, setOpen]);\n\n  const toggle = useCallback(() => setOpen(!open), [setOpen, open]);\n\n  useEffect(() => () => cancelClose(), [cancelClose]);\n\n  useEffect(() => {\n    const animation = animate(\n      progress,\n      open ? 1 : 0,\n      reduce\n        ? { duration: 0 }\n        : open\n          ? GOO_OPEN_SPRING\n          : GOO_CLOSE_SPRING,\n    );\n    return () => animation.stop();\n  }, [open, progress, reduce]);\n\n  // The panel is a `role=\"dialog\"` and goes inert the moment it closes, so\n  // focus cannot be left sitting inside it: Escape hands it back to the\n  // trigger, the way the ARIA dialog pattern asks. A pointer dismissal takes\n  // the focus onward itself when it lands on something focusable — this only\n  // catches the case where it would otherwise be stranded.\n  const close = useCallback(() => {\n    setOpen(false);\n    const focused = document.activeElement;\n    const inPanel =\n      focused instanceof HTMLElement && contentRef.current?.contains(focused);\n    if (inPanel) triggerRef.current?.focus();\n  }, [setOpen]);\n  // The panel is portalled, so both trees participate in outside detection.\n  const ignoreContent = useCallback(\n    (target: Element) => Boolean(contentRef.current?.contains(target)),\n    [],\n  );\n  // A hover trigger opens on tap as well now, so it needs the same outside\n  // dismissal the click trigger always had. The gesture passes through to\n  // whatever it landed on, which is the light-dismiss bargain the platform's\n  // own popovers strike.\n  useDismiss(open, close, rootRef, { ignore: ignoreContent });\n\n  const ctx = useMemo<PopoverContextValue>(\n    () => ({\n      open,\n      setOpen,\n      toggle,\n      openHover,\n      scheduleClose,\n      triggerMode: trigger,\n      side,\n      align,\n      gap: sideOffset,\n      panelRadius,\n      gooStrength,\n      reduce,\n      gooId,\n      contentId,\n      progress,\n      triggerRef,\n      contentRef,\n    }),\n    [\n      open,\n      setOpen,\n      toggle,\n      openHover,\n      scheduleClose,\n      trigger,\n      side,\n      align,\n      sideOffset,\n      panelRadius,\n      gooStrength,\n      reduce,\n      gooId,\n      contentId,\n      progress,\n    ],\n  );\n\n  const hoverHandlers =\n    trigger === \"hover\"\n      ? makeHoverHandlers(rootHover, openHover, scheduleClose)\n      : {};\n\n  return (\n    <PopoverContext.Provider value={ctx}>\n      <div\n        ref={rootRef}\n        className={cn(\"relative inline-flex isolate\", className)}\n        {...hoverHandlers}\n      >\n        {children}\n      </div>\n    </PopoverContext.Provider>\n  );\n}\n\nfunction mergeRefs<T>(...refs: Array<Ref<T> | undefined>) {\n  return (node: T | null) => {\n    for (const ref of refs) {\n      if (typeof ref === \"function\") ref(node);\n      else if (ref && typeof ref === \"object\")\n        (ref as React.MutableRefObject<T | null>).current = node;\n    }\n  };\n}\n\nexport interface PopoverTriggerProps {\n  /** A single focusable element (e.g. a Button) that opens the popover. */\n  children: ReactElement;\n}\n\nexport function PopoverTrigger({ children }: PopoverTriggerProps) {\n  const ctx = usePopoverContext(\"PopoverTrigger\");\n  // What the last gesture on the trigger was, and whether the panel was\n  // already open when it started. A click reports neither.\n  const tap = useTapGesture<boolean>();\n\n  if (!isValidElement(children)) return children;\n\n  const child = children as ReactElement<Record<string, unknown>>;\n  const childProps = child.props;\n  const childRef = (childProps as { ref?: Ref<HTMLElement> }).ref;\n\n  const compose =\n    <E extends { defaultPrevented?: boolean }>(\n      name: string,\n      handler: (event: E) => void,\n    ) =>\n    (event: E) => {\n      (childProps[name] as ((e: unknown) => void) | undefined)?.(event);\n      if (!event.defaultPrevented) handler(event);\n    };\n\n  // Observation, not action. `compose` steps aside for a child that handled\n  // the event itself, which is right for anything that *does* something — but\n  // a child preventing the pointerdown default (to hold focus, say) has not\n  // said the gesture didn't happen. Skipping the record there left the panel\n  // reading whatever the gesture before it had put in.\n  const observe =\n    <E,>(name: string, handler: (event: E) => void) =>\n    (event: E) => {\n      (childProps[name] as ((e: unknown) => void) | undefined)?.(event);\n      handler(event);\n    };\n\n  // The hover trigger keeps its hover path and *adds* a tap one, rather than\n  // swapping mode on a device that reports a touchscreen: a touchscreen laptop\n  // has both inputs and the mouse must keep working. A hovering pointer has\n  // already opened the panel on its way in, and a keyboard press arrives with\n  // no pointerdown behind it, so only a tap toggles here. Which panel state\n  // the tap acts on is read from the gesture's start, because a browser that\n  // focuses the trigger on contact would otherwise open it mid-gesture and let\n  // the click close it again.\n  const handlers: Record<string, unknown> =\n    ctx.triggerMode === \"hover\"\n      ? {\n          onFocus: compose(\"onFocus\", ctx.openHover),\n          onBlur: compose(\"onBlur\", ctx.scheduleClose),\n          onPointerDown: observe<React.PointerEvent>(\n            \"onPointerDown\",\n            (event) => tap.start(event, ctx.open),\n          ),\n          onPointerCancel: observe(\"onPointerCancel\", tap.drop),\n          onKeyDown: observe(\"onKeyDown\", tap.drop),\n          onClick: compose(\"onClick\", () => {\n            const gesture = tap.take();\n            if (!gesture || gesture.pointerType === \"mouse\") return;\n            ctx.setOpen(!gesture.state);\n          }),\n        }\n      : { onClick: compose(\"onClick\", ctx.toggle) };\n\n  return cloneElement(child, {\n    ...handlers,\n    ref: mergeRefs(childRef, (node: HTMLElement | null) => {\n      ctx.triggerRef.current = node;\n    }),\n    // Above the goo layer (z-[-1]) so the neck reads behind it.\n    className: cn(\"relative z-0\", childProps.className as string | undefined),\n    \"aria-haspopup\": \"dialog\",\n    \"aria-expanded\": ctx.open,\n    \"aria-controls\": ctx.open ? ctx.contentId : undefined,\n    \"data-state\": ctx.open ? \"open\" : \"closed\",\n  });\n}\n\nconst ALIGN_ORIGIN: Record<Align, string> = {\n  start: \"left\",\n  center: \"center\",\n  end: \"right\",\n};\n\nexport interface PopoverContentProps {\n  children: ReactNode;\n  className?: string;\n}\n\nexport function PopoverContent({ children, className }: PopoverContentProps) {\n  const ctx = usePopoverContext(\"PopoverContent\");\n  const [portalReady, setPortalReady] = useState(false);\n  const {\n    side,\n    align,\n    gap,\n    panelRadius,\n    gooStrength,\n    reduce,\n    gooId,\n    contentId,\n    progress,\n    triggerRef,\n    contentRef,\n    open,\n    triggerMode,\n    openHover,\n    scheduleClose,\n  } = ctx;\n\n  const measureRef = contentRef;\n  const panelHover = useHoverGesture();\n  const blobRef = useRef<HTMLDivElement>(null);\n  const clipRef = useRef<HTMLDivElement>(null);\n  const geoRef = useRef<Geo | null>(null);\n  const supportsShapeRef = useRef(false);\n  const layout = usePopoverPortalPosition(\n    triggerRef,\n    measureRef,\n    portalReady,\n  );\n\n  useEffect(() => setPortalReady(true), []);\n\n  const geo = useMemo(\n    () =>\n      buildGeo(\n        layout?.trigger.width ?? 0,\n        layout?.trigger.height ?? 0,\n        layout?.content.width ?? 0,\n        layout?.content.height ?? 0,\n        side,\n        align,\n        gap,\n        panelRadius,\n      ),\n    [layout, side, align, gap, panelRadius],\n  );\n\n  // Morph the same clip on the goo body and the content, so the whole popover\n  // oozes as one and the text reveals with it.\n  const render = useCallback((g: Geo | null, p: number) => {\n    if (!g || g.layerW === 0) return;\n    const clip = clipForProgress(g, p, supportsShapeRef.current);\n    if (blobRef.current) blobRef.current.style.clipPath = clip;\n    if (clipRef.current) clipRef.current.style.clipPath = clip;\n  }, []);\n\n  useLayoutEffect(() => {\n    supportsShapeRef.current =\n      typeof CSS !== \"undefined\" &&\n      typeof CSS.supports === \"function\" &&\n      CSS.supports(\n        \"clip-path\",\n        \"shape(from 0px 0px, line to 1px 1px, close)\",\n      );\n    geoRef.current = geo;\n    render(geo, progress.get());\n  }, [geo, progress, render]);\n\n  useMotionValueEvent(progress, \"change\", (p) => render(geoRef.current, p));\n\n  const hoverHandlers =\n    triggerMode === \"hover\"\n      ? makeHoverHandlers(panelHover, openHover, scheduleClose)\n      : {};\n\n  // Match the server and first client render, then attach the portal after\n  // hydration. This preserves SSR without regenerating the page on the client.\n  if (!portalReady) return null;\n\n  return createPortal(\n    <div\n      data-popover-portal=\"\"\n      className=\"pointer-events-none fixed left-0 top-0 z-[9999] isolate size-0\"\n      style={{\n        visibility: layout ? \"visible\" : \"hidden\",\n        transform: `translate3d(${layout?.trigger.left ?? 0}px, ${layout?.trigger.top ?? 0}px, 0)`,\n      }}\n    >\n      {/* Goo filter: blur, sharpen the alpha back into solid shapes, then lay\n          the crisp original on top so blobs merge with liquid edges. */}\n      <svg aria-hidden width=\"0\" height=\"0\" className=\"absolute\">\n        <title>Popover visual effects</title>\n        <defs>\n          <filter id={gooId} x=\"-50%\" y=\"-50%\" width=\"200%\" height=\"200%\">\n            <feGaussianBlur\n              in=\"SourceGraphic\"\n              stdDeviation={gooStrength}\n              result=\"blur\"\n            />\n            <feColorMatrix\n              in=\"blur\"\n              mode=\"matrix\"\n              values=\"1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 22 -10\"\n              result=\"goo\"\n            />\n            <feComposite in=\"SourceGraphic\" in2=\"goo\" operator=\"atop\" />\n          </filter>\n        </defs>\n      </svg>\n\n      {/* Goo body: static trigger pill + morphing blob. */}\n      <div\n        aria-hidden\n        className=\"pointer-events-none absolute z-[-1]\"\n        style={{\n          left: geo.left,\n          top: geo.top,\n          width: geo.layerW,\n          height: geo.layerH,\n          filter: reduce ? undefined : `url(#${gooId})`,\n          clipPath: triggerCutout(geo),\n        }}\n      >\n        <div\n          className=\"absolute bg-popover\"\n          style={{\n            left: geo.trigger.x,\n            top: geo.trigger.y,\n            width: geo.trigger.w,\n            height: geo.trigger.h,\n            borderRadius: geo.trigger.r,\n          }}\n        />\n        <div\n          ref={blobRef}\n          className=\"absolute inset-0 bg-popover\"\n          style={{\n            clipPath: clipForProgress(geo, progress.get(), false),\n          }}\n        />\n      </div>\n\n      {/* Content is clipped by the same morph. The portal wrapper stays\n          pointer-transparent; only the fully open panel accepts interaction. */}\n      <div\n        className=\"pointer-events-none absolute z-10\"\n        style={{\n          left: geo.left,\n          top: geo.top,\n          width: geo.layerW,\n          height: geo.layerH,\n        }}\n      >\n        <div\n          ref={clipRef}\n          inert={!open}\n          className=\"absolute inset-0\"\n          style={{\n            clipPath: clipForProgress(geo, progress.get(), false),\n            pointerEvents: open ? \"auto\" : \"none\",\n          }}\n        >\n          <div\n            ref={measureRef}\n            id={contentId}\n            role=\"dialog\"\n            {...hoverHandlers}\n            style={{\n              position: \"absolute\",\n              left: geo.panel.x,\n              top: geo.panel.y,\n              transformOrigin: `${ALIGN_ORIGIN[align]} ${side === \"bottom\" ? \"top\" : \"bottom\"}`,\n            }}\n            className={cn(\n              \"w-max max-w-[min(92vw,20rem)] p-4 text-popover-foreground outline-none\",\n              className,\n            )}\n          >\n            {children}\n          </div>\n        </div>\n      </div>\n    </div>,\n    document.body,\n  );\n}\n"},{"path":"components/motion/popover-position.ts","type":"registry:component","target":"@components/motion/popover-position.ts","content":"\"use client\";\n\nimport {\n  type MutableRefObject,\n  useCallback,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\nexport type PortalLayout = {\n  trigger: {\n    left: number;\n    top: number;\n    width: number;\n    height: number;\n  };\n  content: {\n    width: number;\n    height: number;\n  };\n};\n\nfunction sameLayout(a: PortalLayout | null, b: PortalLayout) {\n  return (\n    a?.trigger.left === b.trigger.left &&\n    a.trigger.top === b.trigger.top &&\n    a.trigger.width === b.trigger.width &&\n    a.trigger.height === b.trigger.height &&\n    a.content.width === b.content.width &&\n    a.content.height === b.content.height\n  );\n}\n\n/** Measures a trigger and portalled panel in viewport coordinates. */\nexport function usePopoverPortalPosition<\n  TriggerElement extends HTMLElement,\n  ContentElement extends HTMLElement,\n>(\n  triggerRef: MutableRefObject<TriggerElement | null>,\n  contentRef: MutableRefObject<ContentElement | null>,\n  active: boolean,\n) {\n  const [layout, setLayout] = useState<PortalLayout | null>(null);\n\n  const update = useCallback(() => {\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    if (!trigger || !content) return;\n\n    const rect = trigger.getBoundingClientRect();\n    const next: PortalLayout = {\n      trigger: {\n        left: rect.left,\n        top: rect.top,\n        width: rect.width,\n        height: rect.height,\n      },\n      content: {\n        width: content.offsetWidth,\n        height: content.offsetHeight,\n      },\n    };\n    setLayout((current) => (sameLayout(current, next) ? current : next));\n  }, [contentRef, triggerRef]);\n\n  useLayoutEffect(() => {\n    update();\n    if (!active) return;\n\n    const trigger = triggerRef.current;\n    const content = contentRef.current;\n    const observer = new ResizeObserver(update);\n    if (trigger) observer.observe(trigger);\n    if (content) observer.observe(content);\n\n    window.addEventListener(\"scroll\", update, true);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      observer.disconnect();\n      window.removeEventListener(\"scroll\", update, true);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [active, contentRef, triggerRef, update]);\n\n  return layout;\n}\n"},{"path":"lib/hooks/use-dismiss.ts","type":"registry:hook","target":"@lib/hooks/use-dismiss.ts","content":"\"use client\";\n\nimport { type RefObject, useEffect } from \"react\";\n\n/**\n * What the dismissing gesture does to the control it landed on.\n *\n * `\"pass-through\"` is the platform norm (native popover light-dismiss): the\n * tap closes the overlay *and* activates whatever was under it. Use\n * `\"consume\"` where the open overlay sits over or beside controls that would\n * be costly to trigger by accident — the dismissal then swallows the\n * activation too, so the gesture only closes.\n */\nexport type DismissBehavior = \"pass-through\" | \"consume\";\n\nexport interface DismissOptions {\n  /** Default `\"pass-through\"`. */\n  behavior?: DismissBehavior;\n  /** Dismiss on Escape as well. Default true. */\n  escape?: boolean;\n  /** Return true for an outside target that should *not* dismiss. Must be stable. */\n  ignore?: (target: Element) => boolean;\n}\n\n/**\n * What every currently open dismiss scope counts as inside itself. A consumed\n * dismissal reads this to tell a stray gesture from one that belongs to an\n * overlay in front of it: overlays have no shared z-order to consult, but the\n * one the gesture landed in has said as much by registering it.\n */\nconst openScopes = new Set<(target: Element) => boolean>();\n\nfunction claimedByAnotherScope(\n  self: (target: Element) => boolean,\n  target: Element,\n) {\n  for (const scope of openScopes) {\n    if (scope !== self && scope(target)) return true;\n  }\n  return false;\n}\n\n// preventDefault on pointerdown does not suppress the click that follows, so\n// consuming a gesture means swallowing that click itself. The swallower\n// deliberately outlives the effect that installed it — the dismissal it\n// belongs to has already unmounted or re-rendered by the time the click lands.\n// It releases on that click, or on the next gesture if the pointer is dragged\n// away and no click ever arrives, so it can never eat a later one. A keydown\n// releases it too: a gesture that ends with neither a click nor a cancel would\n// otherwise leave it armed, and the click Enter synthesizes on some focused\n// control is not the one this dismissal was owed.\nfunction consumeActivation(source: Event) {\n  const swallow = (event: MouseEvent) => {\n    event.preventDefault();\n    event.stopPropagation();\n    release();\n  };\n  const restart = (event: Event) => {\n    if (event !== source) release();\n  };\n  const release = () => {\n    window.removeEventListener(\"click\", swallow, true);\n    window.removeEventListener(\"pointerdown\", restart, true);\n    window.removeEventListener(\"pointercancel\", restart, true);\n    window.removeEventListener(\"keydown\", release, true);\n  };\n  window.addEventListener(\"click\", swallow, true);\n  window.addEventListener(\"pointerdown\", restart, true);\n  window.addEventListener(\"pointercancel\", restart, true);\n  window.addEventListener(\"keydown\", release, true);\n}\n\n/**\n * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`\n * for `ref` when what counts as inside isn't one element, and say so with\n * `ignore` instead.\n *\n * The pointerdown listener is capture-phase: a bubble-phase one is blinded by\n * any handler in between that stops propagation, and an overlay cannot know\n * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in\n * useCallback) so the listeners aren't re-bound every render while open.\n */\nexport function useDismiss(\n  open: boolean,\n  onDismiss: () => void,\n  ref: RefObject<HTMLElement | null> | null,\n  {\n    behavior = \"pass-through\",\n    escape: dismissOnEscape = true,\n    ignore,\n  }: DismissOptions = {},\n) {\n  useEffect(() => {\n    if (!open) return;\n    const inside = (target: Element) =>\n      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));\n    const onKey = (event: KeyboardEvent) => {\n      if (dismissOnEscape && event.key === \"Escape\") onDismiss();\n    };\n    const onPointer = (event: PointerEvent) => {\n      const target = event.target as Element | null;\n      if (!target || inside(target)) return;\n      // Outside this overlay, but inside one that is also open: the gesture is\n      // that overlay's to answer, and swallowing its click from behind would\n      // cost the user the control they actually aimed at.\n      if (behavior === \"consume\" && !claimedByAnotherScope(inside, target)) {\n        consumeActivation(event);\n      }\n      onDismiss();\n    };\n    openScopes.add(inside);\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer, true);\n    return () => {\n      openScopes.delete(inside);\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer, true);\n    };\n  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);\n}\n"},{"path":"lib/hooks/use-hover-gesture.ts","type":"registry:hook","target":"@lib/hooks/use-hover-gesture.ts","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\nimport { isHoveringPointer } from \"@/lib/touch\";\n\ninterface BoundaryEvent {\n  pointerId: number;\n  pointerType: string;\n  buttons: number;\n}\n\nexport interface HoverGesture {\n  /** True when this enter starts a hover: the pointer arrived resting, not pressing. */\n  enter: (event: BoundaryEvent) => boolean;\n  /** True when this leave ends a hover that entered as one. */\n  leave: (event: BoundaryEvent) => boolean;\n}\n\n/**\n * Pairs a surface's enter with its leave, per pointer.\n *\n * `isHoveringPointer` answers the question the *enter* asks — is this pointer\n * resting on the surface or pressing it — and both boundary cases go wrong if\n * the leave is asked the same question again:\n *\n * - A pen with no hover never rests. It arrives in contact, taps, and the spec\n *   then requires its boundary events after `pointerup`, so the leave carries\n *   `buttons: 0` and reads as a mouse gliding off. Hover teardown then undid\n *   the tap — the panel the pen had just opened closed under it.\n * - A mouse pressed on the surface and dragged off leaves with `buttons: 1`.\n *   Skipping teardown there strands the surface open: the release happens\n *   outside, and no second leave ever comes.\n *\n * So the state a hover holds is released by the pointer that took it, whatever\n * the buttons say at the boundary, and a pointer that arrived in contact never\n * took it in the first place. Contact is the exception tracked here, not\n * hover: a leave from a pointer this surface never saw enter — mounted under\n * the cursor, say — still counts, since the alternative is state with no way\n * out.\n */\nexport function useHoverGesture(): HoverGesture {\n  const contact = useRef(new Set<number>());\n\n  return useMemo(\n    () => ({\n      enter: (event) => {\n        if (isHoveringPointer(event)) {\n          contact.current.delete(event.pointerId);\n          return true;\n        }\n        contact.current.add(event.pointerId);\n        return false;\n      },\n      leave: (event) => {\n        const arrivedInContact = contact.current.delete(event.pointerId);\n        return !arrivedInContact && event.pointerType !== \"touch\";\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/hooks/use-tap-gesture.ts","type":"registry:hook","target":"@lib/hooks/use-tap-gesture.ts","content":"\"use client\";\n\nimport { useMemo, useRef } from \"react\";\n\n/** What a pointerdown recorded, read back by the click that ends its gesture. */\nexport interface TapRecord<S> {\n  /** Which input started the gesture. */\n  pointerType: string;\n  /** What the surface was showing when it started. */\n  state: S;\n}\n\nexport interface TapGesture<S> {\n  /** Record the gesture a pointerdown starts, with the state it starts in. */\n  start: (event: { pointerType: string }, state: S) => void;\n  /** Read the record and clear it. `null` when no pointer is behind this click. */\n  take: () => TapRecord<S> | null;\n  /** Drop the record: this gesture will never spend it on a click. */\n  drop: () => void;\n}\n\n/**\n * The pointer gesture behind a click, recorded where the click cannot report\n * it. A `click` carries no `pointerType` in the engines that matter, so the\n * `pointerdown` before it is the only thing that says which input activated\n * the control — and whether one did at all, since keyboard activation\n * synthesizes a click with no pointer behind it.\n *\n * State goes in with the record because a click reports that no better: a\n * browser that focuses a control on contact can open the very panel the tap\n * was meant to open, and reading \"is it open\" at click time then undoes it.\n * What the gesture started against is what it acts on.\n *\n * The record is spent by one click and dropped by everything else, because a\n * record that outlives its gesture is worse than none:\n *\n * - A scroll or an OS gesture takes the touch away — `pointercancel`, no click\n *   ever — and the finger would sit in the record until some later click.\n * - That later click is often `Enter` on a keyboard, which arrives with no\n *   pointerdown of its own and would inherit the abandoned finger. A keydown\n *   is the start of a keyboard activation and never part of a tap, so it drops\n *   the record too.\n *\n * Both ends have to be wired by the surface: `drop` on `onPointerCancel` and\n * on `onKeyDown`.\n */\nexport function useTapGesture<S>(): TapGesture<S> {\n  const record = useRef<TapRecord<S> | null>(null);\n\n  return useMemo(\n    () => ({\n      start: (event, state) => {\n        record.current = { pointerType: event.pointerType, state };\n      },\n      take: () => {\n        const spent = record.current;\n        record.current = null;\n        return spent;\n      },\n      drop: () => {\n        record.current = null;\n      },\n    }),\n    [],\n  );\n}\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"},{"path":"lib/touch.ts","type":"registry:lib","target":"@lib/touch.ts","content":"// Shared touch primitives. iOS and iPadOS run their own gestures on top of the\n// page — the long-press selection callout and the selection it drags in with\n// it — and they win: once the platform claims a touch it cancels ours\n// mid-gesture, so a press-and-hold or a drag simply dies. Surfaces that own\n// their gesture have to opt out.\n//\n// What the two classes below cover, precisely:\n// - `-webkit-touch-callout: none` stops iOS's long-press callout. WebKit-only:\n//   it is not a property other engines have, so it is inert everywhere else.\n// - `user-select: none` stops the long-press selection on every engine,\n//   Android included, and stops a drag from painting a selection under the\n//   cursor. It is inherited, so it reaches every descendant — which is why the\n//   two classes differ only in whether they apply it unconditionally.\n// What neither covers:\n// - Chrome for Android's long-press menu on a link or an image. No CSS\n//   suppresses it; a gesture surface that wraps one needs its own\n//   `onContextMenu` with `preventDefault()`.\n// - The native drag of an `<img>` or `<a>` descendant. `-webkit-user-drag` is\n//   not inherited and plain divs and buttons are not drag sources, so setting\n//   it on the surface does nothing — the child itself needs `draggable={false}`.\n\n/**\n * Classes for a surface that *is* the control: a thumb, a drum, a stage, a\n * handle, a hold button. Selection is suppressed on every input, because a\n * drag that highlights the control's own label is wrong on a mouse too.\n * Compose with `touch-none` when the surface also owns the scroll axis — leave\n * it off when the page must still scroll from there.\n */\nexport const TOUCH_GESTURE_CLASS = \"select-none [-webkit-touch-callout:none]\";\n\n/**\n * The same opt-out for a gesture surface that wraps content the consumer owns:\n * a scroller, a context-menu trigger, a sheet header, a list row. Selection is\n * suppressed only where the platform runs its own press gestures — a coarse\n * pointer — so a mouse user can still select and copy that content. If the\n * gesture itself would paint a selection under the cursor, add `select-none`\n * for the duration of the gesture rather than reaching for\n * `TOUCH_GESTURE_CLASS`.\n *\n * `pointer: coarse` describes the *primary* pointer and nothing else, so a\n * hybrid machine reads it wrong in both directions: a tablet with a mouse\n * plugged in keeps touch as primary and loses mouse selection, and a laptop\n * with a touchscreen keeps the mouse as primary and leaves selection live\n * under a finger. No media query can answer per interaction — the query is\n * about the device, and the question is about the gesture in progress. The\n * default stays here because it is right on the machines that are one thing or\n * the other, and losing a selection is a nuisance; where the miss costs a\n * *gesture* instead, the surface pairs it with `holdSelection` on the press.\n */\nexport const TOUCH_GESTURE_CONTENT_CLASS =\n  \"[-webkit-touch-callout:none] pointer-coarse:select-none\";\n\n/**\n * Grow a small control's *hit area* to at least 44×44px (WCAG 2.5.5, Apple\n * HIG) on a coarse pointer, without changing what it looks like or the space\n * it takes in layout. An invisible `::after` box, centered on the control and\n * `max(100%, 2.75rem)` on each axis, catches the taps: a 32px icon button\n * reaches 6px past each edge, a wide 40px button only grows vertically, and a\n * control already 44px or larger is left as it is. Hit-testing a pseudo\n * element lands on its owner, so handlers, focus and `:active` all behave.\n *\n * Mouse and trackpad rendering is untouched — nothing here applies outside\n * `pointer: coarse`. Requirements on the element it goes on:\n * - It must be the containing block: add `relative` (and keep it mergeable, so\n *   a consumer's `absolute` still wins through `cn`).\n * - It must not clip itself (`overflow-hidden`), or the overhang is clipped\n *   away along with its hit area.\n * - It must render children: `<input>` and other void elements have no\n *   `::after`. Put it on the wrapper or label instead.\n * - It must not already use `::after` for something visible.\n *\n * Centered 44px means two 32px buttons with a 4px gap overlap by 8px; the\n * later one wins that strip, which still ends well short of the earlier\n * button's visual center. Below a ~32px pitch the later neighbour starts\n * eating into that center — use `TOUCH_TARGET_Y_CLASS` for such dense rows.\n *\n * The overhang is real geometry: inside a scroll container it counts as\n * scrollable overflow, so a control flush against a scroller's edge can add a\n * few pixels of scroll on touch screens. Give such a scroller a little padding\n * on that side.\n */\nexport const TOUCH_TARGET_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 pointer-coarse:after:size-[max(100%,2.75rem)] pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * `TOUCH_TARGET_CLASS` on the vertical axis only, for controls packed tighter\n * than ~32px apart in a row (pagination dots, dense swatches), where growing\n * sideways would hand most of each control to its neighbour. Same rules.\n */\nexport const TOUCH_TARGET_Y_CLASS =\n  \"pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:h-[max(100%,2.75rem)] pointer-coarse:after:-translate-y-1/2 pointer-coarse:after:content-['']\";\n\n/**\n * Suppress selection on `element` for as long as a gesture is running on it,\n * whatever the primary pointer of the machine happens to be. Returns the\n * release. Inline, so it wins over the class above and is gone again the\n * moment the gesture ends.\n *\n * For the press gestures a native selection would otherwise steal — a\n * long-press that opens a menu. Elsewhere prefer the classes: a surface that\n * takes selection away for the whole session is a surface whose text nobody\n * can copy.\n */\nexport function holdSelection(element: HTMLElement) {\n  element.style.setProperty(\"user-select\", \"none\");\n  element.style.setProperty(\"-webkit-user-select\", \"none\");\n  return () => {\n    element.style.removeProperty(\"user-select\");\n    element.style.removeProperty(\"-webkit-user-select\");\n  };\n}\n\n/**\n * Pointer capture, best effort. WebKit throws `NotFoundError` when the pointer\n * is already gone by the time the handler runs — routine on iOS, where the\n * system can claim the touch first — and an uncaught throw takes the rest of\n * the handler, the gesture included, down with it. Touch pointers carry\n * implicit capture anyway, so losing it is never fatal.\n */\nexport function capturePointer(element: Element, pointerId: number) {\n  try {\n    element.setPointerCapture(pointerId);\n  } catch {\n    // Pointer is no longer active — implicit capture still applies on touch.\n  }\n}\n\n/** Release a capture taken with `capturePointer`, ignoring a stale pointer. */\nexport function releasePointer(element: Element, pointerId: number) {\n  try {\n    if (element.hasPointerCapture(pointerId)) {\n      element.releasePointerCapture(pointerId);\n    }\n  } catch {\n    // Capture was already dropped by the browser.\n  }\n}\n\n/**\n * Whether this event came from a pointer that is *hovering*: not a touch, and\n * not currently pressed. Which input the user is holding right now is not\n * something a device capability can answer — a touchscreen laptop hovers and\n * taps, and iPadOS reports a fine hovering pointer for a finger — so both\n * paths stay live and each handler branches on the event it was given.\n *\n * A pen resting on the glass is making contact, not hovering: `buttons` is the\n * tell, and it sends a pen tap down the same route a finger takes.\n *\n * This answers what an *enter* asks. A leave is the other half of a pair and\n * has to be read against the enter that started it — `useHoverGesture` in\n * `lib/hooks/use-hover-gesture` does that, and hover surfaces should use it\n * rather than asking this question twice.\n */\nexport const isHoveringPointer = (event: {\n  pointerType: string;\n  buttons: number;\n}) => event.pointerType !== \"touch\" && event.buttons === 0;\n"}]}