{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-bubble",
  "type": "registry:ui",
  "title": "ChatBubble",
  "description": "A customizable floating chat launcher with an animated, persistent conversation panel.",
  "dependencies": [
    "motion@^12.43.0",
    "lucide-react@^1.47.0",
    "react-aria@^3.52.1"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://components.dethink.co.uk/r/dethink-base.json",
    "https://components.dethink.co.uk/r/chat.json"
  ],
  "files": [
    {
      "path": "packages/components/src/components/chat-bubble/chat-bubble.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/components/chat-bubble/chat-bubble.tsx",
      "content": "\"use client\";\n\nimport {\n  createContext,\n  forwardRef,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n  type ButtonHTMLAttributes,\n  type CSSProperties,\n  type HTMLAttributes,\n  type ReactNode,\n  type RefObject,\n  type MutableRefObject,\n} from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { MessageCircle, Minus } from \"lucide-react\";\nimport { cn } from \"../../utils/cn\";\nimport { useHydrated } from \"../../utils/use-hydrated\";\nimport {\n  DethinkPortalProvider,\n  DethinkOverlayScope,\n  useProviderPortalRoot,\n} from \"../../utils/provider-portal\";\nimport { Chat, type ChatProps } from \"../chat/chat\";\n\nexport type ChatBubblePosition = \"bottom-right\" | \"bottom-left\";\n\nexport interface ChatBubbleProps extends HTMLAttributes<HTMLDivElement> {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  position?: ChatBubblePosition;\n  /** Numbers are CSS pixels. CSS lengths can use rem, clamp(), etc. */\n  offset?: number | string;\n  width?: number | string;\n  height?: number | string;\n  zIndex?: number;\n  /** Disable spatial animation regardless of the user's OS preference. */\n  motion?: \"auto\" | \"none\";\n  /** Supply chat for the default composition, or children for custom parts. */\n  chat?: ChatProps;\n  triggerProps?: ChatBubbleTriggerProps;\n  contentProps?: ChatBubbleContentProps;\n}\n\nexport interface ChatBubbleTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {\n  unreadCount?: number;\n  /** Localizable description, e.g. “3 unread messages”. */\n  unreadLabel?: string;\n}\n\nexport interface ChatBubbleContentProps extends Omit<\n  HTMLAttributes<HTMLDivElement>,\n  \"title\"\n> {\n  title?: ReactNode;\n  subtitle?: ReactNode;\n  avatar?: ReactNode;\n  /** Replaces the default header; include ChatBubbleClose in a custom header. */\n  header?: ReactNode;\n  footer?: ReactNode;\n  closeLabel?: string;\n  initialFocusRef?: RefObject<HTMLElement | null>;\n}\n\nexport interface ChatBubbleCloseProps extends ButtonHTMLAttributes<HTMLButtonElement> {}\n\ninterface BubbleContext {\n  open: boolean;\n  setOpen: (open: boolean) => void;\n  triggerRef: MutableRefObject<HTMLButtonElement | null>;\n  portalContainer: HTMLElement | null;\n  contentId: string;\n  titleId: string;\n  position: ChatBubblePosition;\n  reduceMotion: boolean;\n}\n\nconst Context = createContext<BubbleContext | null>(null);\nfunction useBubble() {\n  const context = useContext(Context);\n  if (!context) throw new Error(\"ChatBubble parts must be inside ChatBubble.\");\n  return context;\n}\nconst length = (value: string | number) =>\n  typeof value === \"number\" ? `${value}px` : value;\n\n/** A persistent, non-modal chat widget. Closing never unmounts the conversation. */\nexport function ChatBubble({\n  open: controlledOpen,\n  defaultOpen = false,\n  onOpenChange,\n  position = \"bottom-right\",\n  offset = \"1.5rem\",\n  width = \"25rem\",\n  height = \"36rem\",\n  zIndex = 50,\n  motion: motionPreference = \"auto\",\n  chat,\n  triggerProps,\n  contentProps,\n  children,\n  className,\n  style,\n  ...props\n}: ChatBubbleProps) {\n  const [localOpen, setLocalOpen] = useState(defaultOpen);\n  const open = controlledOpen ?? localOpen;\n  const hydrated = useHydrated();\n  const prefersReducedMotion = useReducedMotion();\n  const reduceMotion = motionPreference === \"none\" || !!prefersReducedMotion;\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const floating = useRef<HTMLDivElement | null>(null);\n  const id = useId();\n  const { rootRef, portalContainer } = useProviderPortalRoot<HTMLSpanElement>({\n    portalSlot: \"chat-bubble-portal\",\n  });\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (next === open) return;\n      if (controlledOpen === undefined) setLocalOpen(next);\n      onOpenChange?.(next);\n    },\n    [controlledOpen, onOpenChange, open],\n  );\n  const context = useMemo(\n    () => ({\n      open,\n      setOpen,\n      triggerRef,\n      portalContainer,\n      contentId: `${id}-panel`,\n      titleId: `${id}-title`,\n      position,\n      reduceMotion,\n    }),\n    [open, setOpen, id, position, reduceMotion, portalContainer],\n  );\n\n  // Visual viewport accounts for a mobile keyboard without changing chat state.\n  useEffect(() => {\n    if (!hydrated) return;\n    const viewport = window.visualViewport;\n    const update = () => {\n      floating.current?.style.setProperty(\n        \"--chat-bubble-viewport-height\",\n        `${viewport?.height ?? window.innerHeight}px`,\n      );\n      floating.current?.style.setProperty(\n        \"--chat-bubble-keyboard-offset\",\n        `${Math.max(0, window.innerHeight - (viewport?.height ?? window.innerHeight) - (viewport?.offsetTop ?? 0))}px`,\n      );\n    };\n    update();\n    viewport?.addEventListener(\"resize\", update);\n    viewport?.addEventListener(\"scroll\", update);\n    window.addEventListener(\"resize\", update);\n    return () => {\n      viewport?.removeEventListener(\"resize\", update);\n      viewport?.removeEventListener(\"scroll\", update);\n      window.removeEventListener(\"resize\", update);\n    };\n  }, [hydrated]);\n\n  return (\n    <Context.Provider value={context}>\n      <span ref={rootRef} data-slot=\"chat-bubble-root\" />\n      {hydrated &&\n        portalContainer &&\n        createPortal(\n          <DethinkOverlayScope visible={open}>\n            <DethinkPortalProvider container={portalContainer}>\n              <div\n                {...props}\n                ref={floating}\n                data-slot=\"chat-bubble\"\n                data-state={open ? \"open\" : \"closed\"}\n                data-position={position}\n                className={cn(\n                  \"pointer-events-none fixed max-w-full\",\n                  className,\n                )}\n                style={\n                  {\n                    \"--chat-bubble-offset\": length(offset),\n                    \"--chat-bubble-width\": length(width),\n                    \"--chat-bubble-height\": length(height),\n                    \"--chat-bubble-edge\":\n                      \"max(var(--chat-bubble-offset), env(safe-area-inset-left), env(safe-area-inset-right))\",\n                    width:\n                      \"min(var(--chat-bubble-width), calc(100vw - 2 * var(--chat-bubble-edge)))\",\n                    bottom:\n                      \"calc(max(var(--chat-bubble-offset), env(safe-area-inset-bottom)) + var(--chat-bubble-keyboard-offset, 0px))\",\n                    [position === \"bottom-right\" ? \"right\" : \"left\"]:\n                      \"var(--chat-bubble-edge)\",\n                    zIndex,\n                    ...style,\n                  } as CSSProperties\n                }\n              >\n                {children ?? (\n                  <>\n                    <ChatBubbleTrigger {...triggerProps} />\n                    <ChatBubbleContent {...contentProps}>\n                      {chat && (\n                        <Chat\n                          {...chat}\n                          className={cn(\n                            \"h-full flex-1 [&>[data-slot=message-scroller]]:min-h-0\",\n                            chat.className,\n                          )}\n                        />\n                      )}\n                    </ChatBubbleContent>\n                  </>\n                )}\n              </div>\n            </DethinkPortalProvider>\n          </DethinkOverlayScope>,\n          portalContainer,\n        )}\n    </Context.Provider>\n  );\n}\n\nexport const ChatBubbleTrigger = forwardRef<\n  HTMLButtonElement,\n  ChatBubbleTriggerProps\n>(function ChatBubbleTrigger(\n  {\n    children,\n    className,\n    onClick,\n    unreadCount = 0,\n    unreadLabel,\n    \"aria-label\": label = \"Open chat\",\n    \"aria-describedby\": describedBy,\n    ...props\n  },\n  ref,\n) {\n  const { open, setOpen, triggerRef, contentId, position } = useBubble();\n  const unreadId = useId();\n  const count = Number.isFinite(unreadCount)\n    ? Math.max(0, Math.floor(unreadCount))\n    : 0;\n  return (\n    <button\n      {...props}\n      ref={(node) => {\n        triggerRef.current = node;\n        if (typeof ref === \"function\") ref(node);\n        else if (ref) ref.current = node;\n      }}\n      type=\"button\"\n      hidden={open}\n      data-slot=\"chat-bubble-trigger\"\n      aria-label={label}\n      aria-describedby={\n        [describedBy, count > 0 ? unreadId : undefined]\n          .filter(Boolean)\n          .join(\" \") || undefined\n      }\n      aria-expanded={open}\n      aria-controls={contentId}\n      aria-haspopup=\"dialog\"\n      className={cn(\n        \"bg-primary text-primary-foreground hover:bg-primary/90 focus-visible:ring-ring focus-visible:ring-offset-background pointer-events-auto relative flex size-14 items-center justify-center rounded-full shadow-xl transition-colors outline-none focus-visible:ring-2 focus-visible:ring-offset-4 disabled:cursor-not-allowed disabled:opacity-50 [&[hidden]]:hidden\",\n        position === \"bottom-right\" ? \"ml-auto\" : \"mr-auto\",\n        className,\n      )}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOpen(true);\n      }}\n    >\n      {children ?? <MessageCircle aria-hidden=\"true\" className=\"size-6\" />}\n      {count > 0 && (\n        <>\n          <span\n            aria-hidden=\"true\"\n            className=\"bg-destructive text-destructive-foreground border-background absolute -top-1 -right-1 flex min-w-5 items-center justify-center rounded-full border-2 px-1 text-[10px] font-semibold\"\n          >\n            {count > 99 ? \"99+\" : count}\n          </span>\n          <span id={unreadId} className=\"sr-only\">\n            {unreadLabel ?? `${count} unread messages`}\n          </span>\n        </>\n      )}\n    </button>\n  );\n});\n\nexport const ChatBubbleContent = forwardRef<\n  HTMLDivElement,\n  ChatBubbleContentProps\n>(function ChatBubbleContent(\n  {\n    title = \"Let’s talk\",\n    subtitle = \"How can we help?\",\n    avatar,\n    header,\n    footer,\n    closeLabel = \"Minimize chat\",\n    initialFocusRef,\n    children,\n    className,\n    style,\n    onKeyDown,\n    onFocusCapture,\n    \"aria-label\": label,\n    \"aria-labelledby\": labelledBy,\n    ...props\n  },\n  ref,\n) {\n  const {\n    open,\n    setOpen,\n    triggerRef,\n    contentId,\n    titleId,\n    position,\n    reduceMotion,\n    portalContainer,\n  } = useBubble();\n  const panel = useRef<HTMLDivElement | null>(null);\n  const lastFocused = useRef<HTMLElement | null>(null);\n  const wasOpen = useRef(false);\n  const [exited, setExited] = useState(!open);\n  useEffect(() => {\n    const node = panel.current;\n    if (!node) return;\n    if (open && !wasOpen.current) {\n      // Reveal before focusing; the opening animation can then run independently.\n      node.removeAttribute(\"inert\");\n      (initialFocusRef?.current ?? node).focus({ preventScroll: true });\n    }\n    const restoreFocus =\n      !open &&\n      wasOpen.current &&\n      (portalContainer?.contains(document.activeElement) ||\n        (document.activeElement === document.body &&\n          lastFocused.current !== null &&\n          !lastFocused.current.isConnected));\n    if (!open) node.setAttribute(\"inert\", \"\");\n    wasOpen.current = open;\n    if (restoreFocus) {\n      triggerRef.current?.focus({ preventScroll: true });\n      // Nested focus scopes finish restoring focus on their next animation frame.\n      const frame = requestAnimationFrame(() => {\n        if (\n          document.activeElement === document.body ||\n          portalContainer?.contains(document.activeElement)\n        ) {\n          triggerRef.current?.focus({ preventScroll: true });\n        }\n      });\n      return () => cancelAnimationFrame(frame);\n    }\n  }, [open, initialFocusRef, triggerRef, portalContainer]);\n  return (\n    <motion.div\n      initial={false}\n      animate={\n        open\n          ? { opacity: 1, scale: 1, y: 0 }\n          : {\n              opacity: 0,\n              scale: reduceMotion ? 1 : 0.88,\n              y: reduceMotion ? 0 : 16,\n            }\n      }\n      transition={{\n        duration: reduceMotion ? 0 : 0.22,\n        ease: [0.22, 1, 0.36, 1],\n      }}\n      onAnimationStart={() => {\n        if (open) setExited(false);\n      }}\n      onAnimationComplete={() => {\n        if (!open) setExited(true);\n      }}\n      style={{\n        visibility: open || !exited ? \"visible\" : \"hidden\",\n        transformOrigin:\n          position === \"bottom-right\" ? \"bottom right\" : \"bottom left\",\n        position: open ? \"relative\" : \"absolute\",\n        bottom: 0,\n        width: \"100%\",\n        pointerEvents: open ? \"auto\" : \"none\",\n      }}\n    >\n      {/* The non-modal dialog owns Escape dismissal, not a click interaction. */}\n      {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}\n      <div\n        {...props}\n        ref={(node) => {\n          panel.current = node;\n          // Attribute form works with both React 18 and React 19.\n          if (node) {\n            if (open) node.removeAttribute(\"inert\");\n            else if (!wasOpen.current) node.setAttribute(\"inert\", \"\");\n          }\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) ref.current = node;\n        }}\n        id={contentId}\n        role=\"dialog\"\n        tabIndex={-1}\n        aria-label={label}\n        aria-labelledby={labelledBy ?? (label ? undefined : titleId)}\n        aria-hidden={!open || undefined}\n        onFocusCapture={(event) => {\n          lastFocused.current = event.target;\n          onFocusCapture?.(event);\n        }}\n        data-slot=\"chat-bubble-content\"\n        data-state={open ? \"open\" : \"closed\"}\n        className={cn(\n          \"bg-background text-foreground border-border focus-visible:ring-ring flex min-h-0 flex-col overflow-hidden rounded-3xl border shadow-2xl outline-none focus-visible:ring-2\",\n          className,\n        )}\n        style={{\n          height:\n            \"min(var(--chat-bubble-height), calc(var(--chat-bubble-viewport-height, 100dvh) - 2 * var(--chat-bubble-edge) - env(safe-area-inset-top) - env(safe-area-inset-bottom)))\",\n          ...style,\n        }}\n        onKeyDown={(event) => {\n          onKeyDown?.(event);\n          if (\n            !event.defaultPrevented &&\n            event.key === \"Escape\" &&\n            !event.nativeEvent.isComposing &&\n            open\n          ) {\n            event.preventDefault();\n            event.stopPropagation();\n            setOpen(false);\n          }\n        }}\n      >\n        {header ?? (\n          <div\n            data-slot=\"chat-bubble-header\"\n            className=\"border-border flex shrink-0 items-center gap-3 border-b px-4 py-[calc(var(--dt-density-gap,0.5rem)*2)]\"\n          >\n            {avatar ?? (\n              <div className=\"bg-primary/10 text-primary flex size-10 shrink-0 items-center justify-center rounded-2xl\">\n                <MessageCircle aria-hidden=\"true\" className=\"size-5\" />\n              </div>\n            )}\n            <div className=\"min-w-0 flex-1\">\n              <h2 id={titleId} className=\"truncate text-sm font-semibold\">\n                {title}\n              </h2>\n              {subtitle && (\n                <p className=\"text-muted-foreground mt-0.5 text-xs\">\n                  {subtitle}\n                </p>\n              )}\n            </div>\n            <ChatBubbleClose aria-label={closeLabel} />\n          </div>\n        )}\n        <div className=\"flex min-h-0 flex-1 flex-col [&>[data-slot=chat]]:h-full [&>[data-slot=chat]]:min-h-0\">\n          {children}\n        </div>\n        {footer && (\n          <div\n            data-slot=\"chat-bubble-footer\"\n            className=\"border-border text-muted-foreground shrink-0 border-t px-4 py-2 text-center text-xs\"\n          >\n            {footer}\n          </div>\n        )}\n      </div>\n    </motion.div>\n  );\n});\n\nexport const ChatBubbleClose = forwardRef<\n  HTMLButtonElement,\n  ChatBubbleCloseProps\n>(function ChatBubbleClose(\n  {\n    children,\n    className,\n    onClick,\n    \"aria-label\": label = \"Minimize chat\",\n    ...props\n  },\n  ref,\n) {\n  const { setOpen } = useBubble();\n  return (\n    <button\n      {...props}\n      ref={ref}\n      type=\"button\"\n      aria-label={label}\n      data-slot=\"chat-bubble-close\"\n      className={cn(\n        \"hover:bg-muted focus-visible:ring-ring inline-flex size-10 shrink-0 items-center justify-center rounded-full outline-none focus-visible:ring-2 disabled:opacity-50\",\n        className,\n      )}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) setOpen(false);\n      }}\n    >\n      {children ?? <Minus aria-hidden=\"true\" className=\"size-4\" />}\n    </button>\n  );\n});\n"
    },
    {
      "path": "packages/components/src/components/chat-bubble/index.ts",
      "type": "registry:file",
      "target": "~/components/dethink/components/chat-bubble/index.ts",
      "content": "export * from \"./chat-bubble\";\n"
    },
    {
      "path": "packages/components/src/utils/provider-portal.tsx",
      "type": "registry:file",
      "target": "~/components/dethink/utils/provider-portal.tsx",
      "content": "import {\n  createContext,\n  type ForwardedRef,\n  type ReactNode,\n  type RefCallback,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { UNSAFE_PortalProvider } from \"react-aria/PortalProvider\";\nimport { setDethinkPortalOwner } from \"./portal-owner\";\n\nconst providerPortalDefaultClasses = \"bg-background font-sans text-foreground\";\nconst providerPortalMirroredAttributes = [\n  \"data-theme\",\n  \"data-density\",\n  \"dir\",\n] as const;\n\nexport type ProviderPortalMirroredAttribute =\n  (typeof providerPortalMirroredAttributes)[number];\n\nexport interface ProviderPortalRootOptions<T extends HTMLElement> {\n  forwardedRef?: ForwardedRef<T>;\n  portalSlot: string;\n}\n\nexport interface ProviderPortalRootState<T extends HTMLElement> {\n  portalContainer: HTMLElement | null;\n  rootRef: RefCallback<T>;\n}\n\nexport interface DethinkPortalProviderProps {\n  children?: ReactNode;\n  container: HTMLElement | null;\n}\n\nconst DethinkPortalContext = createContext(false);\nconst OverlayVisibilityContext = createContext(true);\n\n/** Persistent surfaces can dismiss overlays without unmounting their content. */\nexport function DethinkOverlayScope({\n  visible,\n  children,\n}: {\n  visible: boolean;\n  children?: ReactNode;\n}) {\n  const parentVisible = useContext(OverlayVisibilityContext);\n  return (\n    <OverlayVisibilityContext.Provider value={visible && parentVisible}>\n      {children}\n    </OverlayVisibilityContext.Provider>\n  );\n}\n\nexport function useScopedOverlayState({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n}: {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n}): readonly [boolean, (open: boolean) => void] {\n  const visible = useContext(OverlayVisibilityContext);\n  const [localOpen, setLocalOpen] = useState(defaultOpen);\n  const requestedOpen = open ?? localOpen;\n  const closeRequested = useRef(false);\n  const setOpen = useCallback(\n    (next: boolean) => {\n      if (next && !visible) return;\n      if (open === undefined) setLocalOpen(next);\n      onOpenChange?.(next);\n    },\n    [open, onOpenChange, visible],\n  );\n  useEffect(() => {\n    if (visible) closeRequested.current = false;\n    else if (requestedOpen && !closeRequested.current) {\n      closeRequested.current = true;\n      // Synchronize overlay state with the containing surface's lifecycle.\n      setOpen(false);\n    }\n  }, [visible, requestedOpen, setOpen]);\n  return [visible && requestedOpen, setOpen];\n}\n\nfunction assignForwardedRef<T>(\n  ref: ForwardedRef<T> | undefined,\n  value: T | null,\n) {\n  if (typeof ref === \"function\") {\n    ref(value);\n  } else if (ref) {\n    ref.current = value;\n  }\n}\n\nfunction fallbackProviderAttribute(name: ProviderPortalMirroredAttribute) {\n  if (typeof document === \"undefined\") {\n    return null;\n  }\n\n  if (name === \"data-density\") {\n    return \"default\";\n  }\n\n  if (name === \"data-theme\") {\n    return document.documentElement.getAttribute(name) ?? \"system\";\n  }\n\n  return document.documentElement.getAttribute(\"dir\") ?? document.dir ?? \"ltr\";\n}\n\nexport function syncDethinkPortalContainer({\n  container,\n  portalSlot,\n  provider,\n}: {\n  container: HTMLElement;\n  portalSlot: string;\n  provider: HTMLElement | null;\n}) {\n  const source =\n    provider ??\n    (typeof document === \"undefined\" ? null : document.documentElement);\n\n  container.setAttribute(\"data-slot\", portalSlot);\n  container.setAttribute(\"data-dethink-provider\", \"\");\n  container.className = provider?.className || providerPortalDefaultClasses;\n  container.style.cssText = provider?.getAttribute(\"style\") ?? \"\";\n  container.style.display = \"contents\";\n\n  for (const attribute of providerPortalMirroredAttributes) {\n    const value =\n      source?.getAttribute(attribute) ?? fallbackProviderAttribute(attribute);\n\n    if (value) {\n      container.setAttribute(attribute, value);\n    } else {\n      container.removeAttribute(attribute);\n    }\n  }\n}\n\nexport function useProviderPortalRoot<T extends HTMLElement>({\n  forwardedRef,\n  portalSlot,\n}: ProviderPortalRootOptions<T>): ProviderPortalRootState<T> {\n  const [portalContainer] = useState<HTMLElement | null>(() => {\n    if (typeof document === \"undefined\") {\n      return null;\n    }\n\n    return document.createElement(\"div\");\n  });\n  const [rootElement, setRootElement] = useState<T | null>(null);\n\n  const rootRef = useCallback(\n    (node: T | null) => {\n      setRootElement(node);\n      assignForwardedRef(forwardedRef, node);\n\n      if (node && portalContainer && typeof document !== \"undefined\") {\n        setDethinkPortalOwner(portalContainer, node);\n        const provider =\n          node.closest<HTMLElement>(\"[data-dethink-provider]\") ?? null;\n\n        syncDethinkPortalContainer({\n          container: portalContainer,\n          portalSlot,\n          provider,\n        });\n\n        if (!portalContainer.isConnected) {\n          document.body.appendChild(portalContainer);\n        }\n      }\n    },\n    [forwardedRef, portalContainer, portalSlot],\n  );\n\n  useEffect(() => {\n    if (!portalContainer || typeof document === \"undefined\") {\n      return undefined;\n    }\n\n    const provider =\n      rootElement?.closest<HTMLElement>(\"[data-dethink-provider]\") ?? null;\n    const syncContainer = () => {\n      syncDethinkPortalContainer({\n        container: portalContainer,\n        portalSlot,\n        provider,\n      });\n    };\n\n    syncContainer();\n    document.body.appendChild(portalContainer);\n\n    const observerTarget = provider ?? document.documentElement;\n    const observer = new MutationObserver(syncContainer);\n    observer.observe(observerTarget, {\n      attributeFilter: [\"class\", \"data-density\", \"data-theme\", \"dir\", \"style\"],\n      attributes: true,\n    });\n\n    return () => {\n      observer.disconnect();\n      portalContainer.remove();\n    };\n  }, [portalContainer, portalSlot, rootElement]);\n\n  return {\n    portalContainer,\n    rootRef,\n  };\n}\n\nexport function DethinkPortalProvider({\n  children,\n  container,\n}: DethinkPortalProviderProps) {\n  const hasDethinkPortalParent = useContext(DethinkPortalContext);\n  const content = hasDethinkPortalParent ? (\n    children\n  ) : (\n    <UNSAFE_PortalProvider getContainer={() => container}>\n      {children}\n    </UNSAFE_PortalProvider>\n  );\n\n  return (\n    <DethinkPortalContext.Provider value>\n      {content}\n    </DethinkPortalContext.Provider>\n  );\n}\n"
    },
    {
      "path": "packages/components/src/utils/portal-owner.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/portal-owner.ts",
      "content": "/**\n * Links a Dethink portal container on <body> to the component root that owns\n * it, so a modal surface (the mobile sidebar) can keep overlays opened from\n * inside it usable while it makes the rest of the page inert. A global symbol\n * key, so components copied from the registry agree without sharing imports.\n */\nconst PORTAL_OWNER: unique symbol = Symbol.for(\"dethink.portal-owner\");\n\ntype OwnedElement = Element & { [PORTAL_OWNER]?: Element };\n\nexport function setDethinkPortalOwner(container: Element, owner: Element) {\n  (container as OwnedElement)[PORTAL_OWNER] = owner;\n}\n\n/** The element owning a Dethink portal container, or null for any other element. */\nexport function getDethinkPortalOwner(element: Element): Element | null {\n  return (element as OwnedElement)[PORTAL_OWNER] ?? null;\n}\n"
    },
    {
      "path": "packages/components/src/utils/use-hydrated.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/use-hydrated.ts",
      "content": "import { useSyncExternalStore } from \"react\";\n\nconst subscribe = () => () => {};\nconst clientSnapshot = () => true;\nconst serverSnapshot = () => false;\n\n/** Keep browser preferences out of the first hydration render. */\nexport function useHydrated() {\n  return useSyncExternalStore(subscribe, clientSnapshot, serverSnapshot);\n}\n"
    },
    {
      "path": "packages/components/src/utils/cn.ts",
      "type": "registry:file",
      "target": "~/components/dethink/utils/cn.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"
    }
  ]
}
