{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"message","type":"registry:component","title":"Message","description":"Composable conversation primitives for message rows, grouped bubbles, avatars, metadata, live markers, and a mount-only trailing-edge pop-up for newly sent rows.","author":"AgentUI","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/agents/message.tsx","type":"registry:component","target":"@components/agents/message.tsx","content":"\"use client\";\n// www.agentui.pro/components/agents/message\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentPropsWithRef,\n  createContext,\n  type ReactNode,\n  useContext,\n} from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { MessageSideContext } from \"@/components/agents/message-context\";\n\nexport {\n  MessageBubble,\n  MessageBubbleCollapsible,\n  MessageBubbleContent,\n  MessageBubbleGroup,\n} from \"@/components/agents/message-bubble\";\nexport { MessageScroller } from \"@/components/agents/message-scroller\";\nexport type { MessageScrollerProps } from \"@/components/agents/message-scroller\";\n\nexport type MessageFrom = \"user\" | \"assistant\";\n\ninterface MessageContextValue {\n  from: MessageFrom;\n}\n\nconst MessageContext = createContext<MessageContextValue>({\n  from: \"assistant\",\n});\n\nexport interface MessageProps\n  extends Omit<ComponentPropsWithRef<typeof motion.article>, \"children\"> {\n  from: MessageFrom;\n  /** Plays a trailing-edge pop-up once when this message row mounts. */\n  animateIn?: boolean;\n  children: ReactNode;\n}\n\nexport interface MessageGroupProps extends ComponentPropsWithRef<\"div\"> {\n  spacing?: \"compact\" | \"default\";\n}\n\nexport interface MessageAvatarProps extends ComponentPropsWithRef<\"div\"> {\n  /** Keep an empty avatar slot so grouped messages remain aligned. */\n  placeholder?: boolean;\n}\n\nexport type MessageContentProps = ComponentPropsWithRef<\"div\">;\nexport type MessageHeaderProps = ComponentPropsWithRef<\"div\">;\nexport type MessageFooterProps = ComponentPropsWithRef<\"div\">;\n\nexport type MessageMarkerProps = ComponentPropsWithRef<\"div\">;\n\nexport interface MessageTypingProps extends ComponentPropsWithRef<\"span\"> {\n  label?: string;\n}\n\n// A sent row should rise from the live edge without changing measured layout.\nconst MESSAGE_POP_UP = {\n  type: \"spring\",\n  stiffness: 480,\n  damping: 32,\n  mass: 0.62,\n} as const;\n\nexport function Message({\n  from,\n  animateIn = false,\n  children,\n  className,\n  initial,\n  animate,\n  transition,\n  exit,\n  style,\n  ...props\n}: MessageProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <MessageSideContext.Provider value={from === \"user\" ? \"end\" : \"start\"}>\n      <MessageContext.Provider value={{ from }}>\n        <motion.article\n          data-slot=\"message\"\n          data-from={from}\n          aria-label={props[\"aria-label\"] ?? `${from} message`}\n          initial={\n            initial ??\n            (animateIn && !reduce\n              ? {\n                  opacity: 0,\n                  transform: \"translateY(8px) scale(0.95)\",\n                }\n              : false)\n          }\n          animate={\n            animate ??\n            (animateIn && !reduce\n              ? {\n                  opacity: 1,\n                  transform: \"translateY(0px) scale(1)\",\n                }\n              : { opacity: 1 })\n          }\n          exit={\n            exit ??\n            (reduce\n              ? { opacity: 0 }\n              : {\n                  opacity: 0,\n                  transform: \"translateY(-3px) scale(0.99)\",\n                })\n          }\n          transition={\n            transition ?? (reduce ? { duration: 0.12 } : MESSAGE_POP_UP)\n          }\n          style={{\n            transformOrigin: from === \"user\" ? \"100% 100%\" : \"0% 100%\",\n            ...style,\n          }}\n          className={cn(\n            \"group/message flex w-full items-start gap-2\",\n            from === \"user\" ? \"flex-row-reverse\" : \"flex-row\",\n            className,\n          )}\n          {...props}\n        >\n          {children}\n        </motion.article>\n      </MessageContext.Provider>\n    </MessageSideContext.Provider>\n  );\n}\n\nexport function MessageGroup({\n  spacing = \"compact\",\n  className,\n  ...props\n}: MessageGroupProps) {\n  return (\n    <div\n      data-slot=\"message-group\"\n      className={cn(\n        \"flex w-full flex-col\",\n        spacing === \"compact\" ? \"gap-1.5\" : \"gap-4\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function MessageAvatar({\n  placeholder = false,\n  children,\n  className,\n  ...props\n}: MessageAvatarProps) {\n  return (\n    <div\n      data-slot=\"message-avatar\"\n      aria-hidden={placeholder || undefined}\n      className={cn(\n        \"grid size-7 shrink-0 place-items-center overflow-hidden rounded-full bg-muted text-xs font-medium text-muted-foreground [&_img]:size-full [&_img]:object-cover [&_svg]:size-3.5\",\n        placeholder && \"invisible\",\n        className,\n      )}\n      {...props}\n    >\n      {children}\n    </div>\n  );\n}\n\nexport function MessageContent({ className, ...props }: MessageContentProps) {\n  const { from } = useContext(MessageContext);\n\n  return (\n    <div\n      data-slot=\"message-content\"\n      className={cn(\n        \"flex min-w-0 flex-1 flex-col gap-1.5\",\n        from === \"user\" ? \"items-end\" : \"items-start\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function MessageHeader({ className, ...props }: MessageHeaderProps) {\n  const { from } = useContext(MessageContext);\n\n  return (\n    <div\n      data-slot=\"message-header\"\n      className={cn(\n        \"flex items-center gap-1.5 px-1 text-[11px] leading-none text-muted-foreground\",\n        from === \"user\" ? \"justify-end\" : \"justify-start\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function MessageFooter({ className, ...props }: MessageFooterProps) {\n  const { from } = useContext(MessageContext);\n\n  return (\n    <div\n      data-slot=\"message-footer\"\n      className={cn(\n        \"flex min-h-5 items-center gap-1 px-1 text-[11px] text-muted-foreground\",\n        from === \"user\" ? \"justify-end\" : \"justify-start\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function MessageMarker({ className, ...props }: MessageMarkerProps) {\n  return (\n    <div\n      data-slot=\"message-marker\"\n      className={cn(\n        \"mx-auto flex w-fit max-w-[88%] items-center gap-1.5 rounded-full bg-muted/70 px-2.5 py-1 text-center text-xs text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function MessageTyping({\n  label = \"Responding\",\n  className,\n  ...props\n}: MessageTypingProps) {\n  const reduce = useReducedMotion() ?? false;\n\n  return (\n    <span\n      data-slot=\"message-typing\"\n      className={cn(\"inline-flex h-5 items-center gap-1\", className)}\n      {...props}\n    >\n      <span className=\"sr-only\">{label}</span>\n      {[0, 1, 2].map((index) => (\n        <motion.span\n          key={index}\n          aria-hidden=\"true\"\n          className=\"size-1 rounded-full bg-current\"\n          animate={\n            reduce\n              ? { opacity: 0.45 }\n              : { opacity: [0.28, 0.85, 0.28], y: [0, -2, 0] }\n          }\n          transition={{\n            duration: 1.05,\n            ease: EASE_OUT,\n            repeat: Number.POSITIVE_INFINITY,\n            delay: index * 0.14,\n          }}\n        />\n      ))}\n    </span>\n  );\n}\n"},{"path":"components/agents/message-bubble.tsx","type":"registry:component","target":"@components/agents/message-bubble.tsx","content":"\"use client\";\n\nimport { ChevronDown } from \"lucide-react\";\nimport {\n  type HTMLMotionProps,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  cloneElement,\n  type ComponentPropsWithRef,\n  createContext,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n  useCallback,\n  useContext,\n  useId,\n  useState,\n} from \"react\";\nimport {\n  EASE_OUT,\n  SPRING_LAYOUT,\n  SPRING_SWAP,\n} from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\nimport { MessageSideContext } from \"@/components/agents/message-context\";\n\nexport type MessageBubbleVariant =\n  | \"solid\"\n  | \"soft\"\n  | \"tint\"\n  | \"outline\"\n  | \"ghost\"\n  | \"danger\";\nexport type MessageBubbleAlign = \"start\" | \"end\";\n\ninterface MessageBubbleContextValue {\n  align?: MessageBubbleAlign;\n  animateIn: boolean;\n  variant: MessageBubbleVariant;\n}\n\nconst MessageBubbleContext = createContext<MessageBubbleContextValue>({\n  animateIn: true,\n  variant: \"soft\",\n});\nconst MessageBubbleLayoutContext = createContext<() => void>(() => {});\n\nexport interface MessageBubbleProps\n  extends Omit<HTMLMotionProps<\"div\">, \"children\"> {\n  variant?: MessageBubbleVariant;\n  /** Defaults to the surrounding Message alignment when omitted. */\n  align?: MessageBubbleAlign;\n  /** Plays the bubble entrance once when this component mounts. */\n  animateIn?: boolean;\n  children?: ReactNode;\n}\n\nexport interface MessageBubbleContentProps\n  extends ComponentPropsWithRef<\"div\"> {\n  /** Replaces the content element while preserving bubble styling. */\n  render?: ReactElement;\n}\n\nexport interface MessageBubbleGroupProps extends ComponentPropsWithRef<\"div\"> {\n  spacing?: \"compact\" | \"default\";\n}\n\nexport interface MessageBubbleCollapsibleProps\n  extends ComponentPropsWithRef<\"div\"> {\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  collapsedLines?: 2 | 3 | 4 | 5 | 6;\n  moreLabel?: ReactNode;\n  lessLabel?: ReactNode;\n  contentClassName?: string;\n  triggerClassName?: string;\n  children?: ReactNode;\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) ref.current = node;\n    }\n  };\n}\n\nconst BUBBLE_CONTENT_REVEAL = {\n  duration: 0.12,\n  ease: EASE_OUT,\n  delay: 0.04,\n} as const;\n\n// Sent bubbles should pop into place quickly with one restrained overshoot.\nconst BUBBLE_POP = {\n  type: \"spring\",\n  stiffness: 520,\n  damping: 27,\n  mass: 0.52,\n} as const;\n\nexport function MessageBubble({\n  variant = \"soft\",\n  align,\n  animateIn = false,\n  className,\n  children,\n  initial,\n  animate,\n  exit,\n  transition,\n  layout,\n  ...props\n}: MessageBubbleProps) {\n  const reduce = useReducedMotion() ?? false;\n  const messageSide = useContext(MessageSideContext);\n  const resolvedAlign = align ?? messageSide ?? \"start\";\n\n  return (\n    <MessageBubbleContext.Provider\n      value={{ align: resolvedAlign, animateIn, variant }}\n    >\n      <motion.div\n        data-slot=\"message-bubble\"\n        data-align={resolvedAlign}\n        data-variant={variant}\n        layout={layout}\n        initial={initial ?? false}\n        animate={animate}\n        exit={\n          exit ??\n          (reduce ? { opacity: 0 } : { opacity: 0, y: -3, scale: 0.99 })\n        }\n        transition={transition ?? (reduce ? { duration: 0.12 } : SPRING_LAYOUT)}\n        className={cn(\n          \"group/bubble flex w-full flex-col\",\n          resolvedAlign === \"end\" ? \"items-end\" : \"items-start\",\n          className,\n        )}\n        {...props}\n      >\n        {children}\n      </motion.div>\n    </MessageBubbleContext.Provider>\n  );\n}\n\nfunction bubbleContentClass(\n  variant: MessageBubbleVariant,\n  interactive: boolean,\n) {\n  return cn(\n    \"relative z-0 min-w-9 max-w-[82%] rounded-2xl px-3.5 py-2.5 text-sm leading-6 text-foreground\",\n    \"[&_a]:font-medium [&_a]:underline [&_a]:underline-offset-4 [&_code]:rounded [&_code]:bg-background/60 [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-[0.9em] [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-background/60 [&_pre]:p-3 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5\",\n    variant === \"solid\" && \"text-background\",\n    variant === \"ghost\" && \"w-full max-w-none rounded-none px-0 py-0\",\n    variant === \"danger\" && \"text-destructive\",\n    interactive &&\n      \"cursor-pointer text-left outline-none transition-[background-color,color,transform] duration-150 hover:brightness-[0.98] focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.99]\",\n  );\n}\n\nfunction bubbleSurfaceClass(\n  variant: MessageBubbleVariant,\n  align: MessageBubbleAlign,\n) {\n  return cn(\n    \"pointer-events-none absolute inset-0 -z-10 rounded-[inherit]\",\n    align === \"end\" ? \"origin-bottom-right\" : \"origin-bottom-left\",\n    variant === \"solid\" && \"bg-foreground\",\n    variant === \"soft\" && \"bg-muted\",\n    variant === \"tint\" && \"bg-primary/10\",\n    variant === \"outline\" && \"border border-border/70 bg-background\",\n    variant === \"danger\" && \"bg-destructive/10\",\n  );\n}\n\nexport function MessageBubbleContent({\n  render,\n  className,\n  children,\n  ref,\n  ...props\n}: MessageBubbleContentProps) {\n  const reduce = useReducedMotion() ?? false;\n  const { align = \"start\", animateIn, variant } =\n    useContext(MessageBubbleContext);\n  const [layoutVersion, setLayoutVersion] = useState(0);\n  const notifyLayout = useCallback(\n    () => setLayoutVersion((version) => version + 1),\n    [],\n  );\n  const interactive =\n    render?.type === \"button\" || render?.type === \"a\";\n  const classes = cn(bubbleContentClass(variant, interactive), className);\n  const composedChildren = (\n    <>\n      {variant !== \"ghost\" ? (\n        <motion.span\n          aria-hidden=\"true\"\n          layout={reduce ? false : \"size\"}\n          layoutDependency={layoutVersion}\n          initial={\n            animateIn && !reduce\n              ? {\n                  opacity: 0,\n                  scale: 0.92,\n                }\n              : false\n          }\n          animate={{ opacity: 1, scale: 1 }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : {\n                  opacity: { duration: 0.12, ease: EASE_OUT },\n                  scale: BUBBLE_POP,\n                  layout: SPRING_LAYOUT,\n                }\n          }\n          className={bubbleSurfaceClass(variant, align)}\n        />\n      ) : null}\n      <MessageBubbleLayoutContext.Provider value={notifyLayout}>\n        <motion.div\n          initial={\n            animateIn\n              ? reduce\n                ? { opacity: 0 }\n                : { opacity: 0 }\n              : false\n          }\n          animate={{ opacity: 1 }}\n          transition={\n            reduce ? { duration: 0.12, ease: EASE_OUT } : BUBBLE_CONTENT_REVEAL\n          }\n          className=\"relative\"\n        >\n          {children}\n        </motion.div>\n      </MessageBubbleLayoutContext.Provider>\n    </>\n  );\n\n  if (render) {\n    const child = render as ReactElement<\n      Record<string, unknown> & { className?: string; ref?: Ref<HTMLElement> }\n    >;\n\n    return cloneElement(child, {\n      ...props,\n      ref: mergeRefs(child.props.ref, ref as Ref<HTMLElement> | undefined),\n      className: cn(classes, child.props.className),\n      children: composedChildren,\n      \"data-slot\": \"message-bubble-content\",\n    });\n  }\n\n  return (\n    <div\n      ref={ref}\n      data-slot=\"message-bubble-content\"\n      className={classes}\n      {...props}\n    >\n      {composedChildren}\n    </div>\n  );\n}\n\nexport function MessageBubbleGroup({\n  spacing = \"compact\",\n  className,\n  ...props\n}: MessageBubbleGroupProps) {\n  return (\n    <div\n      data-slot=\"message-bubble-group\"\n      className={cn(\n        \"flex w-full flex-col\",\n        spacing === \"compact\" ? \"gap-1.5\" : \"gap-3\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nconst LINE_CLAMP_CLASS = {\n  2: \"line-clamp-2\",\n  3: \"line-clamp-3\",\n  4: \"line-clamp-4\",\n  5: \"line-clamp-5\",\n  6: \"line-clamp-6\",\n} as const;\n\nexport function MessageBubbleCollapsible({\n  open,\n  defaultOpen = false,\n  onOpenChange,\n  collapsedLines = 4,\n  moreLabel = \"Show more\",\n  lessLabel = \"Show less\",\n  contentClassName,\n  triggerClassName,\n  className,\n  children,\n  ...props\n}: MessageBubbleCollapsibleProps) {\n  const reduce = useReducedMotion() ?? false;\n  const contentId = useId();\n  const notifyLayout = useContext(MessageBubbleLayoutContext);\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const currentOpen = open ?? internalOpen;\n\n  const setOpen = useCallback(\n    (next: boolean) => {\n      notifyLayout();\n      if (open === undefined) setInternalOpen(next);\n      onOpenChange?.(next);\n    },\n    [notifyLayout, onOpenChange, open],\n  );\n\n  return (\n    <div\n      data-slot=\"message-bubble-collapsible\"\n      data-state={currentOpen ? \"open\" : \"closed\"}\n      className={cn(\"w-full\", className)}\n      {...props}\n    >\n      <div\n        id={contentId}\n        className={cn(\n          \"transition-[mask-image] duration-200\",\n          !currentOpen && LINE_CLAMP_CLASS[collapsedLines],\n          !currentOpen &&\n            \"[mask-image:linear-gradient(to_bottom,#000_68%,transparent_100%)]\",\n          contentClassName,\n        )}\n      >\n        {children}\n      </div>\n      <button\n        type=\"button\"\n        aria-expanded={currentOpen}\n        aria-controls={contentId}\n        onClick={() => setOpen(!currentOpen)}\n        className={cn(\n          \"mt-2 inline-flex h-7 items-center gap-1 rounded-full px-2 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring\",\n          triggerClassName,\n        )}\n      >\n        <span>{currentOpen ? lessLabel : moreLabel}</span>\n        <motion.span\n          aria-hidden=\"true\"\n          animate={{ rotate: currentOpen ? 180 : 0 }}\n          transition={reduce ? { duration: 0 } : SPRING_SWAP}\n        >\n          <ChevronDown className=\"size-3.5\" />\n        </motion.span>\n      </button>\n    </div>\n  );\n}\n"},{"path":"components/agents/message-context.tsx","type":"registry:component","target":"@components/agents/message-context.tsx","content":"\"use client\";\n\nimport { createContext } from \"react\";\n\nexport type MessageSide = \"start\" | \"end\";\n\nexport const MessageSideContext = createContext<MessageSide | undefined>(\n  undefined,\n);\n"},{"path":"components/agents/message-scroller.tsx","type":"registry:component","target":"@components/agents/message-scroller.tsx","content":"\"use client\";\n\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentPropsWithRef,\n  type Ref,\n  useCallback,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport {\n  PreviewRail,\n  type PreviewRailItem,\n} from \"@/components/motion/preview-rail\";\nimport { cn } from \"@/lib/utils\";\n\nconst PREVIEW_TITLE_LENGTH = 56;\nconst PREVIEW_DESCRIPTION_LENGTH = 88;\n\nfunction truncateMessageText(text: string, limit: number) {\n  if (text.length <= limit) return text;\n  const excerpt = text.slice(0, limit);\n  const boundary = excerpt.lastIndexOf(\" \");\n  return `${excerpt.slice(0, boundary > limit * 0.65 ? boundary : limit).trim()}…`;\n}\n\nfunction getMessageText(message: HTMLElement) {\n  const surface =\n    message.querySelector<HTMLElement>('[data-slot=\"message-bubble-content\"]') ??\n    message.querySelector<HTMLElement>('[data-slot=\"message-content\"]') ??\n    message;\n  return (surface.textContent ?? \"\").replace(/\\s+/g, \" \").trim();\n}\n\nfunction getMessagePreview(\n  message: HTMLElement,\n  assistantResponse?: HTMLElement,\n) {\n  const text = getMessageText(message);\n  if (!text) {\n    return { label: \"Message\", description: undefined };\n  }\n\n  if (text.length <= PREVIEW_TITLE_LENGTH) {\n    const responseText = assistantResponse\n      ? getMessageText(assistantResponse)\n      : \"\";\n    return {\n      label: text,\n      description: responseText\n        ? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH)\n        : undefined,\n    };\n  }\n\n  const titleExcerpt = text.slice(0, PREVIEW_TITLE_LENGTH);\n  const titleBoundary = titleExcerpt.lastIndexOf(\" \");\n  const titleEnd =\n    titleBoundary > PREVIEW_TITLE_LENGTH * 0.65\n      ? titleBoundary\n      : PREVIEW_TITLE_LENGTH;\n  const label = `${text.slice(0, titleEnd).trim()}…`;\n  const responseText = assistantResponse\n    ? getMessageText(assistantResponse)\n    : text.slice(titleEnd).trim();\n  return {\n    label,\n    description: responseText\n      ? truncateMessageText(responseText, PREVIEW_DESCRIPTION_LENGTH)\n      : undefined,\n  };\n}\n\nexport interface MessageScrollerProps extends ComponentPropsWithRef<\"div\"> {\n  /** Keep streamed output pinned while the reader remains near the end. */\n  followOutput?: boolean;\n  /** Distance from the end that still counts as following the output. */\n  followThreshold?: number;\n  /** Smoothly follow growing content. */\n  smooth?: boolean;\n  /** Reports when the reader leaves or returns to the live edge. */\n  onFollowChange?: (following: boolean) => void;\n  /** Accessible label for the scrollable transcript. */\n  label?: string;\n  /** Marks the transcript as waiting for more streamed content. */\n  busy?: boolean;\n  /** Adds a compact rail for navigating between rendered Message rows. */\n  navigation?: \"rail\";\n  /** Accessible label for the optional message navigation rail. */\n  navigationLabel?: string;\n  viewportClassName?: string;\n  contentClassName?: string;\n  railClassName?: string;\n  viewportRef?: Ref<HTMLElement>;\n  viewportProps?: Omit<\n    ComponentPropsWithRef<\"section\">,\n    \"children\" | \"className\" | \"ref\"\n  >;\n  contentProps?: Omit<\n    ComponentPropsWithRef<\"div\">,\n    \"children\" | \"className\" | \"ref\"\n  >;\n}\n\nexport function MessageScroller({\n  followOutput = true,\n  followThreshold = 56,\n  smooth = true,\n  onFollowChange,\n  label = \"Conversation\",\n  busy,\n  navigation,\n  navigationLabel = \"Message navigation\",\n  viewportClassName,\n  contentClassName,\n  railClassName,\n  viewportRef: externalViewportRef,\n  viewportProps,\n  contentProps,\n  className,\n  children,\n  ...props\n}: MessageScrollerProps) {\n  const reduce = useReducedMotion() ?? false;\n  const viewportRef = useRef<HTMLElement>(null);\n  const contentRef = useRef<HTMLDivElement>(null);\n  const followingRef = useRef(followOutput);\n  const programmaticScrollRef = useRef(false);\n  const scrollTimerRef = useRef<number | undefined>(undefined);\n  const frameRef = useRef<number | undefined>(undefined);\n  const railFrameRef = useRef<number | undefined>(undefined);\n  const railIdRef = useRef(new WeakMap<HTMLElement, string>());\n  const railIdCounterRef = useRef(0);\n  const railTargetsRef = useRef(new Map<string, HTMLElement>());\n  const [railItems, setRailItems] = useState<PreviewRailItem[]>([]);\n  const [activeRailId, setActiveRailId] = useState(\"\");\n  const [railOverflowing, setRailOverflowing] = useState(false);\n  const {\n    onScroll: onViewportScroll,\n    onWheel: onViewportWheel,\n    onTouchStart: onViewportTouchStart,\n    onKeyDown: onViewportKeyDown,\n    ...restViewportProps\n  } = viewportProps ?? {};\n\n  const setViewportRef = useCallback(\n    (node: HTMLElement | null) => {\n      viewportRef.current = node;\n      if (typeof externalViewportRef === \"function\") {\n        externalViewportRef(node);\n      } else if (externalViewportRef) {\n        externalViewportRef.current = node;\n      }\n    },\n    [externalViewportRef],\n  );\n\n  const setFollowing = useCallback(\n    (next: boolean) => {\n      if (followingRef.current === next) return;\n      followingRef.current = next;\n      onFollowChange?.(next);\n    },\n    [onFollowChange],\n  );\n\n  const updateActiveRailItem = useCallback(() => {\n    if (navigation !== \"rail\") return;\n    const viewport = viewportRef.current;\n    const targets = [...railTargetsRef.current.entries()];\n    if (!viewport || targets.length === 0) return;\n\n    const viewportRect = viewport.getBoundingClientRect();\n    if (viewport.scrollTop <= followThreshold) {\n      const firstId = targets[0]?.[0] ?? \"\";\n      setActiveRailId((current) => (current === firstId ? current : firstId));\n      return;\n    }\n\n    const distanceFromEnd =\n      viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;\n    if (distanceFromEnd <= followThreshold) {\n      const lastId = targets.at(-1)?.[0] ?? \"\";\n      setActiveRailId((current) => (current === lastId ? current : lastId));\n      return;\n    }\n\n    const viewportCenter = viewportRect.top + viewportRect.height / 2;\n    let nearestId = targets[0]?.[0] ?? \"\";\n    let nearestDistance = Number.POSITIVE_INFINITY;\n\n    for (const [id, element] of targets) {\n      const rect = element.getBoundingClientRect();\n      const messageCenter = rect.top + rect.height / 2;\n      const distance = Math.abs(messageCenter - viewportCenter);\n      if (distance < nearestDistance) {\n        nearestDistance = distance;\n        nearestId = id;\n      }\n    }\n\n    setActiveRailId((current) =>\n      current === nearestId ? current : nearestId,\n    );\n  }, [followThreshold, navigation]);\n\n  const syncRailItems = useCallback(() => {\n    if (navigation !== \"rail\") return;\n    const content = contentRef.current;\n    const viewport = viewportRef.current;\n    if (!content || !viewport) return;\n\n    const messages = Array.from(\n      content.querySelectorAll<HTMLElement>('[data-slot=\"message\"]'),\n    );\n    const targets = new Map<string, HTMLElement>();\n    const nextItems = messages.map((message, index) => {\n      let id = railIdRef.current.get(message);\n      if (!id) {\n        railIdCounterRef.current += 1;\n        id = `message-rail-${railIdCounterRef.current}`;\n        railIdRef.current.set(message, id);\n      }\n      targets.set(id, message);\n      const sender = message.dataset.from ?? \"conversation\";\n      const assistantResponse =\n        sender === \"user\"\n          ? messages\n              .slice(index + 1)\n              .find((candidate) => candidate.dataset.from === \"assistant\")\n          : undefined;\n      const preview = getMessagePreview(message, assistantResponse);\n\n      return {\n        id,\n        label: preview.label,\n        description: preview.description,\n        ariaLabel: `Go to ${sender} message ${index + 1} of ${messages.length}`,\n      };\n    });\n\n    railTargetsRef.current = targets;\n    setRailItems((current) => {\n      const unchanged =\n        current.length === nextItems.length &&\n        current.every(\n          (item, index) =>\n            item.id === nextItems[index]?.id &&\n            item.label === nextItems[index]?.label &&\n            item.description === nextItems[index]?.description &&\n            item.ariaLabel === nextItems[index]?.ariaLabel,\n        );\n      return unchanged ? current : nextItems;\n    });\n    setRailOverflowing(\n      viewport.scrollHeight > viewport.clientHeight + 1 && messages.length > 1,\n    );\n  }, [navigation]);\n\n  const scheduleRailSync = useCallback(() => {\n    if (navigation !== \"rail\") return;\n    if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current);\n    railFrameRef.current = requestAnimationFrame(() => {\n      syncRailItems();\n      updateActiveRailItem();\n    });\n  }, [navigation, syncRailItems, updateActiveRailItem]);\n\n  const scrollToEnd = useCallback((behavior: ScrollBehavior) => {\n    const viewport = viewportRef.current;\n    if (!viewport) return;\n\n    programmaticScrollRef.current = true;\n    if (typeof viewport.scrollTo === \"function\") {\n      viewport.scrollTo({ top: viewport.scrollHeight, behavior });\n    } else {\n      viewport.scrollTop = viewport.scrollHeight;\n    }\n    if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);\n    scrollTimerRef.current = window.setTimeout(() => {\n      programmaticScrollRef.current = false;\n    }, behavior === \"smooth\" ? 320 : 0);\n  }, []);\n\n  const handleScroll = useCallback(() => {\n    const viewport = viewportRef.current;\n    if (!viewport || programmaticScrollRef.current) return;\n\n    const distance =\n      viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;\n    setFollowing(distance <= followThreshold);\n    updateActiveRailItem();\n  }, [followThreshold, setFollowing, updateActiveRailItem]);\n\n  const leaveLiveEdge = useCallback(() => {\n    programmaticScrollRef.current = false;\n  }, []);\n\n  useLayoutEffect(() => {\n    followingRef.current = followOutput;\n    if (!followOutput) return;\n\n    frameRef.current = requestAnimationFrame(() => scrollToEnd(\"auto\"));\n    return () => {\n      if (frameRef.current) cancelAnimationFrame(frameRef.current);\n    };\n  }, [followOutput, scrollToEnd]);\n\n  useEffect(() => {\n    const content = contentRef.current;\n    if (!content || typeof ResizeObserver === \"undefined\") return;\n\n    const observer = new ResizeObserver(() => {\n      scheduleRailSync();\n      if (!followOutput || !followingRef.current) return;\n      scrollToEnd(reduce || !smooth ? \"auto\" : \"smooth\");\n    });\n    observer.observe(content);\n\n    return () => observer.disconnect();\n  }, [followOutput, reduce, scheduleRailSync, scrollToEnd, smooth]);\n\n  useEffect(() => {\n    if (navigation !== \"rail\") {\n      railTargetsRef.current.clear();\n      setRailItems([]);\n      setRailOverflowing(false);\n      return;\n    }\n\n    const content = contentRef.current;\n    const viewport = viewportRef.current;\n    if (!content || !viewport) return;\n\n    scheduleRailSync();\n    const mutationObserver =\n      typeof MutationObserver === \"undefined\"\n        ? null\n        : new MutationObserver(scheduleRailSync);\n    mutationObserver?.observe(content, {\n      childList: true,\n      characterData: true,\n      subtree: true,\n    });\n\n    const resizeObserver =\n      typeof ResizeObserver === \"undefined\"\n        ? null\n        : new ResizeObserver(scheduleRailSync);\n    resizeObserver?.observe(content);\n    resizeObserver?.observe(viewport);\n\n    return () => {\n      mutationObserver?.disconnect();\n      resizeObserver?.disconnect();\n    };\n  }, [navigation, scheduleRailSync]);\n\n  useEffect(\n    () => () => {\n      if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);\n      if (frameRef.current) cancelAnimationFrame(frameRef.current);\n      if (railFrameRef.current) cancelAnimationFrame(railFrameRef.current);\n    },\n    [],\n  );\n\n  const scrollToRailItem = useCallback(\n    (item: PreviewRailItem) => {\n      const viewport = viewportRef.current;\n      const target = railTargetsRef.current.get(item.id);\n      if (!viewport || !target) return;\n\n      const lastItem = railItems.at(-1)?.id === item.id;\n      setActiveRailId(item.id);\n      if (lastItem) {\n        setFollowing(true);\n        scrollToEnd(reduce || !smooth ? \"auto\" : \"smooth\");\n        return;\n      }\n\n      setFollowing(false);\n      programmaticScrollRef.current = true;\n      const viewportRect = viewport.getBoundingClientRect();\n      const targetRect = target.getBoundingClientRect();\n      const top =\n        viewport.scrollTop +\n        targetRect.top -\n        viewportRect.top -\n        (viewport.clientHeight - targetRect.height) / 2;\n      const behavior = reduce || !smooth ? \"auto\" : \"smooth\";\n\n      if (typeof viewport.scrollTo === \"function\") {\n        viewport.scrollTo({ top, behavior });\n      } else {\n        viewport.scrollTop = top;\n      }\n      if (scrollTimerRef.current) window.clearTimeout(scrollTimerRef.current);\n      scrollTimerRef.current = window.setTimeout(() => {\n        programmaticScrollRef.current = false;\n      }, behavior === \"smooth\" ? 320 : 0);\n    },\n    [railItems, reduce, scrollToEnd, setFollowing, smooth],\n  );\n\n  const viewport = (\n    <section\n      ref={setViewportRef}\n      aria-label={label}\n      {...restViewportProps}\n      onScroll={(event) => {\n        handleScroll();\n        onViewportScroll?.(event);\n      }}\n      onWheel={(event) => {\n        leaveLiveEdge();\n        onViewportWheel?.(event);\n      }}\n      onTouchStart={(event) => {\n        leaveLiveEdge();\n        onViewportTouchStart?.(event);\n      }}\n      onKeyDown={(event) => {\n        if ([\"ArrowUp\", \"PageUp\", \"Home\"].includes(event.key)) {\n          leaveLiveEdge();\n        }\n        onViewportKeyDown?.(event);\n      }}\n      className={cn(\n        \"h-full overflow-y-auto overscroll-contain outline-none [overflow-anchor:none] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring\",\n        navigation === \"rail\"\n          ? \"[-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\"\n          : \"[scrollbar-gutter:stable]\",\n        viewportClassName,\n        navigation === \"rail\" && railOverflowing && \"pr-10\",\n      )}\n    >\n      <div\n        ref={contentRef}\n        role=\"log\"\n        aria-live=\"polite\"\n        aria-relevant=\"additions text\"\n        aria-busy={busy}\n        className={contentClassName}\n        {...contentProps}\n      >\n        {children}\n      </div>\n    </section>\n  );\n\n  return (\n    <div\n      data-slot=\"message-scroller\"\n      className={cn(\"min-h-0\", className)}\n      {...props}\n    >\n      {navigation === \"rail\" ? (\n        <PreviewRail\n          items={railOverflowing ? railItems : []}\n          label={navigationLabel}\n          activeId={activeRailId}\n          onItemSelect={scrollToRailItem}\n          previewSide=\"before\"\n          highlightActive\n          itemSize={14}\n          className=\"h-full min-h-0 overflow-hidden\"\n          previewContainerClassName=\"right-8 left-3\"\n          previewClassName=\"mr-1 w-64 max-w-full [&_[data-slot=preview-rail-card]]:h-20 [&_[data-slot=preview-rail-card]]:overflow-hidden [&_[data-slot=preview-rail-card]]:p-3 [&_[data-slot=preview-rail-title]]:line-clamp-1 [&_[data-slot=preview-rail-title]]:text-xs [&_[data-slot=preview-rail-title]]:leading-4 [&_[data-slot=preview-rail-description]]:line-clamp-2 [&_[data-slot=preview-rail-description]]:text-xs [&_[data-slot=preview-rail-description]]:leading-4\"\n          railClassName={cn(\n            \"absolute inset-y-3 right-1 w-7 content-center py-1 [&_[data-slot=preview-rail-item]]:w-7 [&_[data-slot=preview-rail-item]]:justify-end [&_[data-slot=preview-rail-tick]]:h-px [&_[data-slot=preview-rail-tick]]:w-4 [&_[data-slot=preview-rail-tick]]:origin-right\",\n            railOverflowing\n              ? \"pointer-events-auto opacity-100\"\n              : \"pointer-events-none opacity-0\",\n            railClassName,\n          )}\n        >\n          {viewport}\n        </PreviewRail>\n      ) : (\n        viewport\n      )}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders) — critically damped `useSpring` config,\n * so the value follows the pointer butterily and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\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":"components/motion/preview-rail.tsx","type":"registry:component","target":"@components/motion/preview-rail.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type MouseEvent,\n  type PointerEvent,\n  type ReactNode,\n  useCallback,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\nimport { EASE_OUT, SPRING_LAYOUT } from \"@/lib/ease\";\nimport { useDismiss } from \"@/lib/hooks/use-dismiss\";\nimport { useHoverGesture } from \"@/lib/hooks/use-hover-gesture\";\nimport { useTapGesture } from \"@/lib/hooks/use-tap-gesture\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface PreviewRailItem {\n  id: string;\n  label: string;\n  ariaLabel?: string;\n  description?: ReactNode;\n  href?: string;\n  target?: \"_blank\" | \"_self\" | \"_parent\" | \"_top\";\n  rel?: string;\n}\n\nexport interface PreviewRailProps {\n  items: PreviewRailItem[];\n  label?: string;\n  orientation?: \"vertical\" | \"horizontal\";\n  activeId?: string;\n  defaultActiveId?: string;\n  onActiveChange?: (id: string) => void;\n  onItemSelect?: (item: PreviewRailItem) => void;\n  renderPreview?: (item: PreviewRailItem) => ReactNode;\n  showPreview?: boolean;\n  previewSide?: \"before\" | \"after\";\n  highlightActive?: boolean;\n  itemSize?: number;\n  children?: ReactNode;\n  className?: string;\n  railClassName?: string;\n  previewContainerClassName?: string;\n  previewClassName?: string;\n}\n\nfunction DefaultPreview({ item }: { item: PreviewRailItem }) {\n  return (\n    <div\n      data-slot=\"preview-rail-card\"\n      className=\"rounded-2xl border border-border bg-card p-4 shadow-sm\"\n    >\n      <p\n        data-slot=\"preview-rail-title\"\n        className=\"font-medium text-card-foreground\"\n      >\n        {item.label}\n      </p>\n      {item.description ? (\n        <div\n          data-slot=\"preview-rail-description\"\n          className=\"mt-1 text-sm leading-6 text-muted-foreground\"\n        >\n          {item.description}\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nexport function PreviewRail({\n  items,\n  label = \"Section navigation\",\n  orientation = \"vertical\",\n  activeId,\n  defaultActiveId,\n  onActiveChange,\n  onItemSelect,\n  renderPreview,\n  showPreview = true,\n  previewSide = \"after\",\n  highlightActive = false,\n  itemSize = 24,\n  children,\n  className,\n  railClassName,\n  previewContainerClassName,\n  previewClassName,\n}: PreviewRailProps) {\n  const uid = useId();\n  const reduce = useReducedMotion();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [internalActiveId, setInternalActiveId] = useState(\n    defaultActiveId ?? items[0]?.id ?? \"\",\n  );\n  const [hoveredId, setHoveredId] = useState<string | null>(null);\n  // A finger cannot hover, so a tap lights the tick instead. Kept apart from\n  // the hovered one: they end in different ways, and a stray mouse move must\n  // not clear a tick the keyboard or a tap chose.\n  const [pinnedId, setPinnedId] = useState<string | null>(null);\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n  // A click carries no pointerType, so the pointerdown before it is what says\n  // whether the activation was a tap. Keyboard activation has none at all.\n  const tap = useTapGesture<boolean>();\n  const hover = useHoverGesture();\n\n  const clearPinned = useCallback(() => setPinnedId(null), []);\n\n  // The next tap outside the rail stands in for the pointer leaving it. The\n  // card is a preview, so that tap passes through to whatever it landed on.\n  useDismiss(pinnedId !== null, clearPinned, rootRef);\n\n  const requestedActiveId = activeId ?? internalActiveId;\n  const selectedId = items.some((item) => item.id === requestedActiveId)\n    ? requestedActiveId\n    : (items[0]?.id ?? \"\");\n  const displayedId = hoveredId ?? pinnedId ?? focusedId ?? \"\";\n  const highlightedId = displayedId || (highlightActive ? selectedId : \"\");\n  const displayedIndex = items.findIndex((item) => item.id === highlightedId);\n  const rowTemplate = items.length\n    ? `repeat(${items.length}, ${itemSize}px)`\n    : undefined;\n  const isHorizontal = orientation === \"horizontal\";\n\n  const selectItem = (id: string) => {\n    if (activeId === undefined) setInternalActiveId(id);\n    onActiveChange?.(id);\n  };\n\n  return (\n    <motion.div\n      layoutRoot\n      ref={rootRef}\n      onBlur={(event) => {\n        // Both tick sources leave with the focus: a tap does not always land\n        // focus, but when it does, tabbing away must not strand the card.\n        if (!event.currentTarget.contains(event.relatedTarget)) {\n          setFocusedId(null);\n          setPinnedId(null);\n        }\n      }}\n      className={cn(\n        \"isolate relative flex w-full overflow-visible\",\n        isHorizontal\n          ? \"min-h-64 flex-col items-center justify-center\"\n          : \"min-h-80\",\n        className,\n      )}\n    >\n      <nav\n        aria-label={label}\n        onPointerLeave={(event) => {\n          // A touch pointer leaves on lift, which would clear the tick the tap\n          // just chose — that one is cleared by the outside tap instead.\n          if (hover.leave(event)) setHoveredId(null);\n        }}\n        style={\n          isHorizontal\n            ? { gridTemplateColumns: rowTemplate }\n            : { gridTemplateRows: rowTemplate }\n        }\n        className={cn(\n          \"relative z-10 grid shrink-0\",\n          isHorizontal\n            ? \"h-12 w-fit max-w-full self-center justify-center\"\n            : \"w-12 content-center\",\n          railClassName,\n        )}\n      >\n        {items.map((item, index) => {\n          const selected = item.id === selectedId;\n          const highlighted = item.id === highlightedId;\n          const distance =\n            displayedIndex < 0 ? Number.POSITIVE_INFINITY : Math.abs(index - displayedIndex);\n          const scale = highlighted\n            ? 1\n            : distance === 1\n              ? 0.68\n              : distance === 2\n                ? 0.44\n                : 0.25;\n\n          const itemContent = (\n            <>\n              <motion.span\n                data-slot=\"preview-rail-tick\"\n                aria-hidden=\"true\"\n                animate={isHorizontal ? { scaleY: scale } : { scaleX: scale }}\n                transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                className={cn(\n                  \"block bg-current\",\n                  isHorizontal\n                    ? \"h-12 w-0.5 origin-bottom\"\n                    : \"h-0.5 w-12 origin-left\",\n                  highlighted ? \"text-foreground\" : undefined,\n                )}\n              />\n            </>\n          );\n\n          const sharedClassName = cn(\n            \"relative flex text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            isHorizontal\n              ? \"h-12 w-6 items-end justify-center\"\n              : \"h-6 w-12 items-center\",\n          );\n          const sharedStyle = isHorizontal\n            ? { width: itemSize }\n            : { height: itemSize };\n          const handlePointerEnter = (event: PointerEvent<HTMLElement>) => {\n            if (hover.enter(event)) setHoveredId(item.id);\n          };\n          const handlePointerDown = (event: PointerEvent<HTMLElement>) => {\n            tap.start(event, pinnedId === item.id);\n            setFocusedId(null);\n          };\n          // A gesture the platform takes away sends no click, and a key press\n          // starts an activation that never had a pointer behind it: either\n          // one leaves a record the next click would read as a tap of its own.\n          const dropGesture = () => tap.drop();\n          const handleFocus = (currentTarget: HTMLElement) => {\n            if (currentTarget.matches(\":focus-visible\")) {\n              setFocusedId(item.id);\n            }\n          };\n          const handleSelect = (event: MouseEvent<HTMLElement>) => {\n            const gesture = tap.take();\n            const tapped =\n              gesture !== null && gesture.pointerType !== \"mouse\";\n\n            if (tapped) {\n              // A link would otherwise show its preview and leave the page in\n              // the same tap, so the card is never read: the first tap lights\n              // the tick, the second follows the link.\n              if (item.href && !gesture.state) {\n                event.preventDefault();\n                setPinnedId(item.id);\n                return;\n              }\n              setPinnedId(item.id);\n            }\n\n            selectItem(item.id);\n            onItemSelect?.(item);\n          };\n\n          return item.href ? (\n            <a\n              key={item.id}\n              data-slot=\"preview-rail-item\"\n              href={item.href}\n              target={item.target}\n              rel={\n                item.rel ??\n                (item.target === \"_blank\" ? \"noreferrer noopener\" : undefined)\n              }\n              aria-label={item.ariaLabel ?? item.label}\n              aria-current={selected ? \"page\" : undefined}\n              onPointerEnter={handlePointerEnter}\n              onPointerDown={handlePointerDown}\n              onPointerCancel={dropGesture}\n              onKeyDown={dropGesture}\n              onFocus={(event) => handleFocus(event.currentTarget)}\n              onClick={handleSelect}\n              style={sharedStyle}\n              className={sharedClassName}\n            >\n              {itemContent}\n            </a>\n          ) : (\n            <button\n              key={item.id}\n              data-slot=\"preview-rail-item\"\n              type=\"button\"\n              aria-label={item.ariaLabel ?? item.label}\n              aria-current={selected ? \"location\" : undefined}\n              onPointerEnter={handlePointerEnter}\n              onPointerDown={handlePointerDown}\n              onPointerCancel={dropGesture}\n              onKeyDown={dropGesture}\n              onFocus={(event) => handleFocus(event.currentTarget)}\n              onClick={handleSelect}\n              style={sharedStyle}\n              className={sharedClassName}\n            >\n              {itemContent}\n            </button>\n          );\n        })}\n      </nav>\n\n      {showPreview ? (\n        <div\n          aria-hidden=\"true\"\n          style={\n            isHorizontal\n              ? { gridTemplateColumns: rowTemplate }\n              : { gridTemplateRows: rowTemplate }\n          }\n          className={cn(\n            \"pointer-events-none absolute z-50 grid\",\n            isHorizontal\n              ? \"top-1/2 left-1/2 h-5 w-fit max-w-full -translate-x-1/2 -translate-y-1/2 justify-center\"\n              : previewSide === \"before\"\n                ? \"inset-y-0 right-16 left-4 content-center\"\n                : \"inset-y-0 right-4 left-16 content-center\",\n            previewContainerClassName,\n          )}\n        >\n          {items.map((item) => (\n            <div\n              key={item.id}\n              style={\n                isHorizontal ? { width: itemSize } : { height: itemSize }\n              }\n              className={cn(\n                \"relative flex items-center\",\n                isHorizontal ? \"justify-center\" : undefined,\n              )}\n            >\n              {item.id === displayedId ? (\n                <div\n                  className={cn(\n                    isHorizontal\n                      ? \"absolute bottom-12 left-1/2 w-72 -translate-x-1/2\"\n                      : cn(\n                          \"w-full max-w-sm\",\n                          previewSide === \"before\" && \"ml-auto\",\n                        ),\n                    previewClassName,\n                  )}\n                >\n                  <motion.div\n                    layoutId={`preview-rail-card-${uid}`}\n                    transition={reduce ? { duration: 0 } : SPRING_LAYOUT}\n                  >\n                    <AnimatePresence mode=\"wait\" initial={false}>\n                      <motion.div\n                        key={item.id}\n                        initial={\n                          reduce\n                            ? { opacity: 0 }\n                            : { opacity: 0, y: 4, filter: \"blur(6px)\" }\n                        }\n                        animate={\n                          reduce\n                            ? { opacity: 1 }\n                            : { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                        }\n                        exit={\n                          reduce\n                            ? { opacity: 0 }\n                            : {\n                                opacity: 0,\n                                y: -2,\n                                filter: \"blur(4px)\",\n                                transition: {\n                                  duration: 0.12,\n                                  ease: EASE_OUT,\n                                },\n                              }\n                        }\n                        transition={{\n                          duration: reduce ? 0 : 0.18,\n                          ease: EASE_OUT,\n                        }}\n                      >\n                        {renderPreview ? (\n                          renderPreview(item)\n                        ) : (\n                          <DefaultPreview item={item} />\n                        )}\n                      </motion.div>\n                    </AnimatePresence>\n                  </motion.div>\n                </div>\n              ) : null}\n            </div>\n          ))}\n        </div>\n      ) : null}\n\n      {children ? (\n        <div className=\"min-h-0 min-w-0 flex-1\">{children}</div>\n      ) : null}\n    </motion.div>\n  );\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/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 * 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"}]}