{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"message-scroller","title":"Message Scroller","description":"Scroll a chat thread that opens at the latest message, follows a streaming reply and leads back to the latest message. Search terms: conversation, scroll to bottom, auto scroll, stick to bottom, AI chat.","dependencies":["lucide-react@1.18.0"],"registryDependencies":["@siteplane/base","@siteplane/action-icons","@siteplane/button","@siteplane/motion","@siteplane/scroll-area"],"files":[{"path":"packages/ui/src/components/message-scroller/message-scroller.tsx","content":"\"use client\";\n\nimport { ArrowDownIcon, ArrowUpIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport { useSiteplaneUI } from \"../../system/provider/siteplane-ui-provider\";\nimport { cn } from \"../../system/utils/cn\";\nimport { slotClass } from \"../../system/utils/slot-class\";\nimport { announceCopyStatus } from \"../action-icons\";\nimport { Button, type ButtonProps } from \"../button\";\nimport { MotionItem, MotionList } from \"../motion\";\nimport { ScrollArea, type ScrollAreaProps } from \"../scroll-area\";\nimport {\n  MessageScrollableContext,\n  MessageScrollerContext,\n  defaultMessageScrollerLabels,\n  useMessageScrollerContext,\n  type MessageScrollerLabels,\n} from \"./message-scroller-context\";\nimport {\n  MessageScrollerController,\n  type Scrollable,\n  type ScrollToOptions,\n} from \"./message-scroller-controller\";\nimport type { ScrollPosition } from \"./message-scroller-math\";\n\nimport \"./message-scroller.css\";\n\nexport type { MessageScrollerLabels };\n\nconst noScrollable: Scrollable = { end: false, start: false };\n\n/** How long the shared live region keeps a status, in milliseconds (rulebook T7). */\nconst statusHold = 2000;\n\n/** Replaces a ref's value, whether it is a callback or an object. */\nfunction setRef<T>(ref: React.Ref<T> | undefined, value: T | null): void {\n  if (typeof ref === \"function\") ref(value);\n  else if (ref) (ref as React.RefObject<T | null>).current = value;\n}\n\nexport type MessageScrollerProps = Omit<ScrollAreaProps, \"viewportRef\"> & {\n  /**\n   * Whether a growing thread carries the viewport along while the reader is\n   * at the live edge. Off by default: nothing moves unless the reader asked\n   * it to. Wheel, touch, keys, the scrollbar or a jump release the follow,\n   * and scrolling back to the end, or the button, takes it up again.\n   */\n  autoScroll?: boolean | undefined;\n  /**\n   * Where the thread opens: at its `end`, at its `start`, or at the last row\n   * that starts a turn (`last-anchor`), which falls back to the end when no\n   * row does or the turn already fits.\n   */\n  defaultScrollPosition?: ScrollPosition | undefined;\n  /** Replaces the English words of the scroller. */\n  labels?: Partial<MessageScrollerLabels> | undefined;\n  /** Whether the row the reader looks at stays in place when older rows are added above it. */\n  preserveScrollOnPrepend?: boolean | undefined;\n  /** How close to an edge counts as being at it, in pixels. */\n  scrollEdgeThreshold?: number | undefined;\n  /** How much of the row before a new turn stays visible above it, in pixels. */\n  scrollPreviousItemPeek?: number | undefined;\n};\n\nfunction scrollableAttribute(\n  scrollable: Scrollable,\n): \"end\" | \"start end\" | \"start\" | undefined {\n  if (scrollable.start && scrollable.end) return \"start end\";\n  if (scrollable.start) return \"start\";\n  if (scrollable.end) return \"end\";\n  return undefined;\n}\n\n/**\n * The scroll area of a conversation: the kit ScrollArea, which keeps the\n * reader's place while a reply streams. A new turn settles near the top, a\n * growing thread is followed only while the reader is at the live edge, older\n * rows added above leave the view where it is, and a button leads back to the\n * latest message.\n *\n * Compose it as `MessageScrollerContent` around `MessageScrollerItem`s, with\n * `MessageScrollerButton` after them. The root fills its container, so give\n * that container a height. `data-scrollable` names the edges that can still be\n * scrolled toward (`start`, `end` or both) and `data-autoscrolling` is present\n * while the scroller scrolls on its own.\n */\nexport function MessageScroller({\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  autoScroll = false,\n  children,\n  className,\n  defaultScrollPosition = \"end\",\n  labels,\n  preserveScrollOnPrepend = true,\n  scrollEdgeThreshold = 8,\n  scrollPreviousItemPeek = 64,\n  ...props\n}: MessageScrollerProps): React.ReactElement {\n  const text: MessageScrollerLabels = {\n    ...defaultMessageScrollerLabels,\n    ...labels,\n  };\n  const [scrollable, setScrollable] = React.useState<Scrollable>(noScrollable);\n  const behavior = {\n    autoScroll,\n    defaultScrollPosition,\n    preserveScrollOnPrepend,\n    scrollEdgeThreshold,\n    scrollPreviousItemPeek,\n  };\n  // The first behavior is the controller's own: the elements attach during\n  // the first commit, before any effect of this component has run.\n  const [controller] = React.useState(\n    () => new MessageScrollerController(behavior, setScrollable),\n  );\n  React.useLayoutEffect(() => {\n    controller.configure({\n      autoScroll,\n      defaultScrollPosition,\n      preserveScrollOnPrepend,\n      scrollEdgeThreshold,\n      scrollPreviousItemPeek,\n    });\n  }, [\n    autoScroll,\n    controller,\n    defaultScrollPosition,\n    preserveScrollOnPrepend,\n    scrollEdgeThreshold,\n    scrollPreviousItemPeek,\n  ]);\n\n  const setViewport = React.useCallback(\n    (element: HTMLDivElement | null) => controller.attachViewport(element),\n    [controller],\n  );\n  // Keyed on the words, not on the object they arrive in, so a labels prop\n  // written inline does not render every part again.\n  const context = React.useMemo(\n    () => ({ controller, labels: text }),\n    [\n      controller,\n      text.complete,\n      text.region,\n      text.responding,\n      text.scrollToEnd,\n      text.scrollToStart,\n    ],\n  );\n\n  return (\n    <MessageScrollerContext.Provider value={context}>\n      <MessageScrollableContext.Provider value={scrollable}>\n        <ScrollArea\n          aria-label={ariaLabelledBy ? undefined : (ariaLabel ?? text.region)}\n          aria-labelledby={ariaLabelledBy}\n          className={cn(\n            slotClass(\"message-scroller\", props),\n            // The dock's z-index stays inside the scroller.\n            \"isolate\",\n            className,\n          )}\n          data-scrollable={scrollableAttribute(scrollable)}\n          data-slot=\"message-scroller\"\n          viewportRef={setViewport}\n          {...props}\n        >\n          {children}\n        </ScrollArea>\n      </MessageScrollableContext.Provider>\n    </MessageScrollerContext.Provider>\n  );\n}\n\n/**\n * Says \"Assistant is responding\" when a reply starts and \"Response complete\"\n * when it is done, once each and never for the text that streams in between.\n * The words go to the kit's shared polite live region, the one that says a\n * copy, which exists before the first reply.\n */\nfunction useStatusAnnouncement(\n  busy: boolean,\n  labels: Pick<MessageScrollerLabels, \"complete\" | \"responding\">,\n): void {\n  React.useEffect(() => {\n    announceCopyStatus(\"\", 0)();\n  }, []);\n  const spoken = React.useRef(false);\n  React.useEffect(() => {\n    // A thread that is idle when it mounts has no reply to complete.\n    if (!busy && !spoken.current) return;\n    spoken.current = true;\n    return announceCopyStatus(\n      busy ? labels.responding : labels.complete,\n      statusHold,\n    );\n  }, [busy, labels.complete, labels.responding]);\n}\n\nexport type MessageScrollerContentProps = React.ComponentProps<\"div\"> & {\n  /**\n   * Whether a reply is on its way. The log is `aria-busy` meanwhile, so a\n   * screen reader reads the finished row once instead of each token, and the\n   * shared live region says that the assistant is responding and when it is\n   * done.\n   */\n  busy?: boolean | undefined;\n};\n\n/**\n * The rows of the thread, as a log: new rows are announced, text that streams\n * into a row is not. Each direct child is a `MessageScrollerItem`.\n */\nexport function MessageScrollerContent({\n  busy,\n  children,\n  className,\n  ref,\n  ...props\n}: MessageScrollerContentProps): React.ReactElement {\n  const { controller, labels } = useMessageScrollerContext(\n    \"MessageScrollerContent\",\n  );\n  useStatusAnnouncement(Boolean(busy), labels);\n  const setContent = React.useCallback(\n    (element: HTMLDivElement | null) => {\n      controller.attachContent(element);\n      setRef(ref, element);\n    },\n    [controller, ref],\n  );\n  const setSpacer = React.useCallback(\n    (element: HTMLDivElement | null) => controller.attachSpacer(element),\n    [controller],\n  );\n\n  return (\n    <div\n      aria-busy={busy || undefined}\n      aria-relevant=\"additions\"\n      className={cn(\n        slotClass(\"message-scroller-content\", props),\n        \"relative flex min-w-0 flex-col gap-8\",\n        className,\n      )}\n      data-slot=\"message-scroller-content\"\n      ref={setContent}\n      role=\"log\"\n      {...props}\n    >\n      {children}\n      {/* The room a turn that settles near the top needs below it; the scroller\n          sizes it as the reply grows. */}\n      <div\n        aria-hidden=\"true\"\n        className={cn(\n          slotClass(\"message-scroller-spacer\"),\n          \"pointer-events-none absolute inset-x-0 top-full\",\n        )}\n        data-slot=\"message-scroller-spacer\"\n        ref={setSpacer}\n      />\n    </div>\n  );\n}\n\nexport type MessageScrollerItemProps = React.ComponentProps<\"div\"> & {\n  /** Names the row, to scroll to it and to keep it in place. Keep it stable. */\n  messageId: string;\n  /** Whether the row starts a turn: when it arrives, it settles near the top. */\n  scrollAnchor?: boolean | undefined;\n};\n\n/**\n * One row of the thread: a message, a marker, a typing indicator or a \"load\n * earlier\" row. A row that arrives after the thread opened fades in with a\n * small rise, and takes its full height at once so that the scroller's\n * measurements stay true. Rows far from the viewport skip their rendering\n * work.\n */\nexport function MessageScrollerItem({\n  messageId,\n  scrollAnchor,\n  className,\n  ...props\n}: MessageScrollerItemProps): React.ReactElement {\n  const { controller } = useMessageScrollerContext(\"MessageScrollerItem\");\n  // A row that mounts with the thread is at rest; one that mounts later enters.\n  const entering = React.useRef(controller.ready).current;\n\n  return (\n    <div\n      className={cn(\n        slotClass(\"message-scroller-item\", props),\n        \"min-w-0 wrap-anywhere\",\n        className,\n      )}\n      data-entering={entering ? \"\" : undefined}\n      data-message-id={messageId}\n      data-scroll-anchor={scrollAnchor ? \"\" : undefined}\n      data-slot=\"message-scroller-item\"\n      {...props}\n    />\n  );\n}\n\ntype DistributiveOmit<T, K extends PropertyKey> = T extends unknown\n  ? Omit<T, K>\n  : never;\n\nexport type MessageScrollerButtonProps = DistributiveOmit<\n  ButtonProps,\n  \"children\"\n> & {\n  /** Scrolls to the latest row (`end`, the default) or back to the first one (`start`). */\n  direction?: \"end\" | \"start\" | undefined;\n};\n\n/**\n * The button that leads back to the latest message, or to the first one. It\n * shows only while there is something to scroll toward, and appears and leaves\n * with the kit's presence. Place it after the content, or before it for\n * `direction=\"start\"`; it floats 16 px inside the edge of the scroller. A\n * keyboard user who presses it keeps their place: focus moves to the scroll\n * region as the button leaves.\n */\nexport function MessageScrollerButton({\n  \"aria-label\": ariaLabel,\n  direction = \"end\",\n  onClick,\n  ...props\n}: MessageScrollerButtonProps): React.ReactElement {\n  const { controller, labels } = useMessageScrollerContext(\n    \"MessageScrollerButton\",\n  );\n  const scrollable = React.useContext(MessageScrollableContext);\n  const { controlSize } = useSiteplaneUI();\n  const toEnd = direction === \"end\";\n  const Icon = toEnd ? ArrowDownIcon : ArrowUpIcon;\n\n  return (\n    // The dock is as tall as the largest control, whatever the button's size,\n    // so the presence has no height to follow: the button fades and scales in\n    // place (rulebook M2) and nothing around it moves.\n    <MotionList\n      className={cn(\n        \"pointer-events-none absolute inset-x-0 z-10 flex h-(--sp-rw-control-xl) justify-center\",\n        toEnd ? \"bottom-4 items-end\" : \"top-4 items-start\",\n      )}\n      data-direction={direction}\n      data-slot=\"message-scroller-dock\"\n      layout=\"flow\"\n      render={<div />}\n    >\n      {(toEnd ? scrollable.end : scrollable.start) ? (\n        <MotionItem\n          className=\"pointer-events-auto\"\n          key=\"button\"\n          render={<div />}\n        >\n          <Button\n            shape=\"pill\"\n            size={`icon-${controlSize}`}\n            {...props}\n            aria-label={\n              ariaLabel ?? (toEnd ? labels.scrollToEnd : labels.scrollToStart)\n            }\n            data-direction={direction}\n            data-slot=\"message-scroller-button\"\n            onClick={(event) => {\n              onClick?.(event);\n              if (event.defaultPrevented) return;\n              const focused =\n                event.currentTarget ===\n                event.currentTarget.ownerDocument.activeElement;\n              if (toEnd) controller.scrollToEnd();\n              else controller.scrollToStart();\n              // The button leaves with the scroll; the keyboard stays on the thread.\n              if (focused) controller.focusViewport();\n            }}\n          >\n            <Icon aria-hidden=\"true\" />\n          </Button>\n        </MotionItem>\n      ) : null}\n    </MotionList>\n  );\n}\n\nexport type MessageScrollerApi = {\n  /** Scrolls to the latest row, and follows the thread from there. */\n  scrollToEnd: (options?: ScrollToOptions) => void;\n  /** Scrolls to the first row. */\n  scrollToStart: (options?: ScrollToOptions) => void;\n  /**\n   * Scrolls the row named `messageId` to the top, with the peek of the row\n   * before it. Returns false when no such row is mounted.\n   */\n  scrollToMessage: (messageId: string, options?: ScrollToOptions) => boolean;\n  /** The edges the viewport can still scroll toward. */\n  scrollable: { end: boolean; start: boolean };\n};\n\n/**\n * The commands of the nearest MessageScroller, and the edges it can still\n * scroll toward, for a control that lives inside it, such as a jump menu. The\n * commands are stable; the component renders again only when an edge turns\n * scrollable or stops being so.\n */\nexport function useMessageScroller(): MessageScrollerApi {\n  const { controller } = useMessageScrollerContext(\"useMessageScroller\");\n  const scrollable = React.useContext(MessageScrollableContext);\n  const commands = React.useMemo(\n    () => ({\n      scrollToEnd: (options?: ScrollToOptions) =>\n        controller.scrollToEnd(options),\n      scrollToMessage: (messageId: string, options?: ScrollToOptions) =>\n        controller.scrollToMessage(messageId, options),\n      scrollToStart: (options?: ScrollToOptions) =>\n        controller.scrollToStart(options),\n    }),\n    [controller],\n  );\n  return React.useMemo(\n    () => ({ ...commands, scrollable }),\n    [commands, scrollable],\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/message-scroller/message-scroller.tsx"},{"path":"packages/ui/src/components/message-scroller/message-scroller-context.ts","content":"import * as React from \"react\";\n\nimport type {\n  MessageScrollerController,\n  Scrollable,\n} from \"./message-scroller-controller\";\n\n/** The words a Message Scroller shows and speaks. */\nexport type MessageScrollerLabels = {\n  /** The name of the scrolling region. */\n  region: string;\n  /** The name of the button that scrolls to the latest message. */\n  scrollToEnd: string;\n  /** The name of the button that scrolls to the first message. */\n  scrollToStart: string;\n  /** Said once when a reply starts, not for each token. */\n  responding: string;\n  /** Said once when the reply is complete. */\n  complete: string;\n};\n\nexport const defaultMessageScrollerLabels: MessageScrollerLabels = {\n  complete: \"Response complete\",\n  region: \"Messages\",\n  responding: \"Assistant is responding\",\n  scrollToEnd: \"Scroll to latest\",\n  scrollToStart: \"Scroll to first\",\n};\n\n/** What the parts need from the scroller; it changes only with the words. */\nexport type MessageScrollerContextValue = {\n  controller: MessageScrollerController;\n  labels: MessageScrollerLabels;\n};\n\nexport const MessageScrollerContext: React.Context<MessageScrollerContextValue | null> =\n  React.createContext<MessageScrollerContextValue | null>(null);\n\n/**\n * The edges the viewport can still scroll toward. It sits in a context of its\n * own: rows read the scroller but not this, so they do not render when the\n * flags change.\n */\nexport const MessageScrollableContext: React.Context<Scrollable> =\n  React.createContext<Scrollable>({ end: false, start: false });\n\nexport function useMessageScrollerContext(\n  part: string,\n): MessageScrollerContextValue {\n  const context = React.useContext(MessageScrollerContext);\n  if (!context)\n    throw new Error(`${part} must be rendered inside MessageScroller.`);\n  return context;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/message-scroller/message-scroller-context.ts"},{"path":"packages/ui/src/components/message-scroller/message-scroller-controller.ts","content":"import { readReducedMotion } from \"../../system/motion/use-reduced-motion-preference\";\nimport {\n  anchorScrollTop,\n  isAtEdge,\n  keyScrollsTowardStart,\n  maxScrollTop,\n  openingScrollTop,\n  prependScrollTop,\n  scrollableEdges,\n  shouldFollow,\n  spacerHeight,\n  touchScrollsTowardStart,\n  wheelScrollsTowardStart,\n  type ScrollMetrics,\n  type ScrollPosition,\n} from \"./message-scroller-math\";\n\n/**\n * The scroll behavior of a Message Scroller. It lives outside React: a scroll\n * event, a resize or an arriving row is handled here, on the DOM, and React\n * hears of it only when the two `scrollable` flags change (the hot path\n * renders nothing). The component creates one controller, hands it the\n * viewport, the content and the spacer through ref callbacks and calls the\n * methods the hook exposes.\n *\n * What it does, in the words of the plan:\n * - opens at the position the app chose (`defaultScrollPosition`);\n * - lets a row that starts a turn (`data-scroll-anchor`) settle near the top,\n *   with room below it (the spacer) until the reply fills the viewport;\n * - follows a growing thread (`autoScroll`) only while the reader has not\n *   scrolled away from the live edge: a wheel turn, a touch drag, a key, a\n *   scrollbar press or a jump to a message releases it, and reaching the end\n *   again takes it up;\n * - keeps the row the reader looks at in place when rows are added above it.\n */\n\nexport type MessageScrollerBehavior = {\n  autoScroll: boolean;\n  defaultScrollPosition: ScrollPosition;\n  preserveScrollOnPrepend: boolean;\n  scrollEdgeThreshold: number;\n  scrollPreviousItemPeek: number;\n};\n\nexport type ScrollToOptions = { behavior?: ScrollBehavior | undefined };\n\n/** The edges the viewport can still scroll toward. */\nexport type Scrollable = { end: boolean; start: boolean };\n\nconst itemSlot = \"message-scroller-item\";\nconst scrollbarSlot = \"scroll-area-scrollbar\";\n\n/** How long the scroller waits for a smooth scroll before it stops waiting, in ms. */\nconst smoothScrollLimit = 1200;\n/** How long a thread that has just opened keeps its place while it settles, in ms. */\nconst openingWindow = 1000;\n\nfunction isItem(element: Element): element is HTMLElement {\n  return element.getAttribute(\"data-slot\") === itemSlot;\n}\n\nfunction isAnchor(element: Element): boolean {\n  return element.hasAttribute(\"data-scroll-anchor\");\n}\n\nfunction previousItem(item: Element): HTMLElement | null {\n  let sibling = item.previousElementSibling;\n  while (sibling && !isItem(sibling)) sibling = sibling.previousElementSibling;\n  return sibling as HTMLElement | null;\n}\n\n/** Text fields keep their arrow keys: they move a caret, not the thread. */\nfunction isEditable(target: EventTarget | null): boolean {\n  const element = target as HTMLElement | null;\n  if (!element || typeof element.tagName !== \"string\") return false;\n  return (\n    element.isContentEditable === true ||\n    /^(?:input|select|textarea)$/i.test(element.tagName)\n  );\n}\n\nfunction isScrollbar(target: EventTarget | null): boolean {\n  const element = target as HTMLElement | null;\n  return (\n    typeof element?.closest === \"function\" &&\n    element.closest(`[data-slot=\"${scrollbarSlot}\"]`) !== null\n  );\n}\n\nexport class MessageScrollerController {\n  /**\n   * Whether a row that mounts now arrived after the thread opened and so\n   * enters. False on the server, while the thread mounts and for the first\n   * frame after it.\n   */\n  ready = false;\n\n  /** The edges the viewport can still scroll toward, as last told. */\n  scrollable: Scrollable = { end: false, start: false };\n\n  private options: MessageScrollerBehavior;\n  private readonly onScrollable: (next: Scrollable) => void;\n\n  private viewport: HTMLElement | null = null;\n  private root: HTMLElement | null = null;\n  private content: HTMLElement | null = null;\n  private spacer: HTMLElement | null = null;\n  private spacerPx = 0;\n\n  private resizeObserver: ResizeObserver | null = null;\n  private mutationObserver: MutationObserver | null = null;\n\n  /** Rows the scroller has seen, to tell the rows that arrive from the rest. */\n  private known = new WeakSet<Element>();\n  /** The row that started the turn the viewport last settled on. */\n  private hold: HTMLElement | null = null;\n  /** The viewport as it was when it last changed, before a commit moves it. */\n  private snapshot = { scrollHeight: 0, scrollTop: 0 };\n\n  /** Whether the reader is at the live edge and has not scrolled away since. */\n  private following = true;\n  /** Whether the thread has just opened and keeps its position while it settles. */\n  private opening = false;\n  private openingTimer: ReturnType<typeof setTimeout> | undefined;\n  private frame = 0;\n  private opened = false;\n\n  /** A smooth scroll of our own that is on its way. */\n  private smooth: {\n    target: number;\n    timer: ReturnType<typeof setTimeout>;\n  } | null = null;\n  private touchStartY = 0;\n\n  constructor(\n    options: MessageScrollerBehavior,\n    onScrollable: (next: Scrollable) => void,\n  ) {\n    this.options = options;\n    this.onScrollable = onScrollable;\n  }\n\n  configure(options: MessageScrollerBehavior): void {\n    this.options = options;\n    if (this.opened) this.syncScrollable();\n  }\n\n  // ---------------------------------------------------------------------------\n  // The elements\n\n  attachViewport(element: HTMLElement | null): void {\n    if (element === this.viewport) return;\n    this.detachViewport();\n    this.viewport = element;\n    if (!element) return;\n    this.root = element.parentElement;\n    element.addEventListener(\"scroll\", this.onScroll, { passive: true });\n    element.addEventListener(\"scrollend\", this.onScrollEnd, { passive: true });\n    element.addEventListener(\"wheel\", this.onWheel, { passive: true });\n    element.addEventListener(\"touchstart\", this.onTouchStart, {\n      passive: true,\n    });\n    element.addEventListener(\"touchmove\", this.onTouchMove, { passive: true });\n    element.addEventListener(\"keydown\", this.onKeyDown);\n    // The scrollbars are siblings of the viewport, children of the root.\n    this.root?.addEventListener(\"pointerdown\", this.onPointerDown, {\n      passive: true,\n    });\n    this.observe();\n    this.openWhenReady();\n  }\n\n  attachContent(element: HTMLElement | null): void {\n    if (element === this.content) return;\n    this.detachContent();\n    this.content = element;\n    if (!element) return;\n    this.observe();\n    this.openWhenReady();\n  }\n\n  attachSpacer(element: HTMLElement | null): void {\n    this.spacer = element;\n    if (element) element.style.height = `${this.spacerPx}px`;\n  }\n\n  dispose(): void {\n    this.attachViewport(null);\n    this.attachContent(null);\n    this.attachSpacer(null);\n  }\n\n  private detachViewport(): void {\n    const viewport = this.viewport;\n    if (viewport) {\n      viewport.removeEventListener(\"scroll\", this.onScroll);\n      viewport.removeEventListener(\"scrollend\", this.onScrollEnd);\n      viewport.removeEventListener(\"wheel\", this.onWheel);\n      viewport.removeEventListener(\"touchstart\", this.onTouchStart);\n      viewport.removeEventListener(\"touchmove\", this.onTouchMove);\n      viewport.removeEventListener(\"keydown\", this.onKeyDown);\n    }\n    this.root?.removeEventListener(\"pointerdown\", this.onPointerDown);\n    this.finishSmoothScroll(false);\n    this.resizeObserver?.disconnect();\n    this.resizeObserver = null;\n    if (this.frame && typeof cancelAnimationFrame !== \"undefined\")\n      cancelAnimationFrame(this.frame);\n    this.frame = 0;\n    clearTimeout(this.openingTimer);\n    this.openingTimer = undefined;\n    this.opening = false;\n    this.viewport = null;\n    this.root = null;\n    this.opened = false;\n    this.ready = false;\n  }\n\n  private detachContent(): void {\n    this.mutationObserver?.disconnect();\n    this.mutationObserver = null;\n    this.resizeObserver?.disconnect();\n    this.resizeObserver = null;\n    this.content = null;\n    this.hold = null;\n    this.opened = false;\n  }\n\n  /** Watches the viewport and the content for size changes, and the content for rows. */\n  private observe(): void {\n    const { content, viewport } = this;\n    if (!viewport || !content) return;\n    if (typeof ResizeObserver !== \"undefined\" && !this.resizeObserver) {\n      this.resizeObserver = new ResizeObserver(() => this.layoutChanged());\n      this.resizeObserver.observe(viewport);\n      this.resizeObserver.observe(content);\n    }\n    if (typeof MutationObserver !== \"undefined\" && !this.mutationObserver) {\n      // Only the rows: text that streams into a row changes nothing here.\n      this.mutationObserver = new MutationObserver(() => this.itemsChanged());\n      this.mutationObserver.observe(content, { childList: true });\n    }\n  }\n\n  // ---------------------------------------------------------------------------\n  // Opening\n\n  private openWhenReady(): void {\n    if (!this.opened && this.viewport && this.content) this.open();\n  }\n\n  private open(): void {\n    const { viewport } = this;\n    if (!viewport || !this.content) return;\n    this.opened = true;\n    this.hold = null;\n    this.setSpacer(0);\n    this.known = new WeakSet(this.items());\n    this.applyOpening();\n    // A thread that opens at the start or in the middle is not at the live edge.\n    this.following = isAtEdge(\n      this.metrics(),\n      \"end\",\n      this.options.scrollEdgeThreshold,\n    );\n    this.opening = true;\n    if (this.openingTimer) clearTimeout(this.openingTimer);\n    this.openingTimer = setTimeout(() => {\n      this.opening = false;\n    }, openingWindow);\n    this.syncScrollable();\n    this.remember();\n    this.markReady();\n  }\n\n  /** Puts the viewport where the thread opens. */\n  private applyOpening(): void {\n    const { viewport } = this;\n    if (!viewport) return;\n    const last = this.items().filter(isAnchor).at(-1);\n    const top = openingScrollTop({\n      anchor: last\n        ? { target: this.anchorTarget(last), top: this.topOf(last) }\n        : undefined,\n      metrics: this.metrics(),\n      position: this.options.defaultScrollPosition,\n    });\n    if (Math.abs(top - viewport.scrollTop) >= 1) this.jump(top);\n  }\n\n  private markReady(): void {\n    if (typeof requestAnimationFrame === \"undefined\") {\n      this.ready = true;\n      return;\n    }\n    this.frame = requestAnimationFrame(() => {\n      this.frame = 0;\n      this.ready = true;\n    });\n  }\n\n  // ---------------------------------------------------------------------------\n  // Reading the layout\n\n  private metrics(): ScrollMetrics {\n    const viewport = this.viewport!;\n    return {\n      clientHeight: viewport.clientHeight,\n      scrollHeight: viewport.scrollHeight,\n      scrollTop: viewport.scrollTop,\n    };\n  }\n\n  private items(): HTMLElement[] {\n    return this.content ? Array.from(this.content.children).filter(isItem) : [];\n  }\n\n  /**\n   * Where a row starts in the viewport's scroll space. A row's offset is the\n   * layout position, which an entering row's transform does not move, from the\n   * content, which is the row's offset parent.\n   */\n  private topOf(item: HTMLElement): number {\n    const viewport = this.viewport!;\n    const content = this.content!;\n    const contentTop =\n      content.getBoundingClientRect().top -\n      viewport.getBoundingClientRect().top -\n      viewport.clientTop +\n      viewport.scrollTop;\n    return contentTop + content.clientTop + item.offsetTop;\n  }\n\n  /** Where a turn that starts at `item` settles: near the top, with the peek above it. */\n  private anchorTarget(item: HTMLElement): number {\n    const previous = previousItem(item);\n    return previous\n      ? Math.max(\n          0,\n          anchorScrollTop(\n            this.topOf(item),\n            previous.offsetHeight,\n            this.options.scrollPreviousItemPeek,\n          ),\n        )\n      : 0;\n  }\n\n  private remember(metrics: ScrollMetrics = this.metrics()): void {\n    this.snapshot = {\n      scrollHeight: metrics.scrollHeight,\n      scrollTop: metrics.scrollTop,\n    };\n  }\n\n  // ---------------------------------------------------------------------------\n  // Moving the viewport\n\n  /** Moves at once, also where the page asks for smooth scrolling. */\n  private jump(top: number): void {\n    this.viewport?.scrollTo({ behavior: \"instant\", top });\n    this.remember();\n  }\n\n  /**\n   * Scrolls to `top`: smoothly unless the caller or reduced motion (M5) asks\n   * for an instant jump. A smooth scroll marks the root `data-autoscrolling`\n   * until it arrives.\n   */\n  private go(top: number, behavior: ScrollBehavior | undefined): void {\n    const { viewport } = this;\n    if (!viewport) return;\n    const target = Math.min(Math.max(0, top), maxScrollTop(this.metrics()));\n    this.finishSmoothScroll(false);\n    const smooth =\n      (behavior ?? \"smooth\") === \"smooth\" && !readReducedMotion(viewport);\n    if (!smooth || Math.abs(target - viewport.scrollTop) < 1) {\n      this.jump(target);\n      return;\n    }\n    this.smooth = {\n      target,\n      timer: setTimeout(() => this.finishSmoothScroll(true), smoothScrollLimit),\n    };\n    this.mark(true);\n    viewport.scrollTo({ behavior: \"smooth\", top: target });\n  }\n\n  private finishSmoothScroll(resume: boolean): void {\n    if (!this.smooth) return;\n    clearTimeout(this.smooth.timer);\n    this.smooth = null;\n    this.mark(false);\n    if (resume) this.followIfNeeded();\n  }\n\n  /** `data-autoscrolling` on the root and the viewport while a smooth scroll runs. */\n  private mark(on: boolean): void {\n    this.viewport?.toggleAttribute(\"data-autoscrolling\", on);\n    this.root?.toggleAttribute(\"data-autoscrolling\", on);\n  }\n\n  // ---------------------------------------------------------------------------\n  // Following\n\n  /** The reader scrolled away, or jumped: stop carrying the viewport along. */\n  private release(): void {\n    this.following = false;\n    this.opening = false;\n    this.finishSmoothScroll(false);\n  }\n\n  private followIfNeeded(): void {\n    if (!this.viewport || this.smooth) return;\n    if (this.opening) {\n      // A thread that has just opened keeps its place while images and fonts\n      // settle, whatever `autoScroll` says: nobody has read it yet.\n      this.applyOpening();\n      return;\n    }\n    if (\n      !shouldFollow({\n        autoScroll: this.options.autoScroll,\n        following: this.following,\n      })\n    )\n      return;\n    const top = maxScrollTop(this.metrics());\n    if (Math.abs(top - this.viewport.scrollTop) >= 1) this.jump(top);\n  }\n\n  /** The size of the viewport or of the thread changed: before the next paint. */\n  layoutChanged(): void {\n    if (!this.viewport) return;\n    this.updateSpacer();\n    this.followIfNeeded();\n    this.syncScrollable();\n    this.remember();\n  }\n\n  // ---------------------------------------------------------------------------\n  // The room below a turn\n\n  private setSpacer(height: number): void {\n    this.spacerPx = height;\n    if (this.spacer) this.spacer.style.height = `${height}px`;\n  }\n\n  /**\n   * A turn settles near the top of the viewport, which needs room below its\n   * last row. The spacer gives it: it fills what the viewport could not\n   * otherwise scroll past, shrinks as the reply grows and is gone when the\n   * reply fills the viewport.\n   */\n  private updateSpacer(): void {\n    const { viewport } = this;\n    if (!viewport) return;\n    if (this.hold && !this.hold.isConnected) this.hold = null;\n    // Nothing settled, nothing to size: no layout is read.\n    if (!this.hold && this.spacerPx === 0) return;\n    const height = this.hold\n      ? spacerHeight(\n          this.anchorTarget(this.hold),\n          viewport.clientHeight,\n          // The height of the rows alone, without the room that is there now.\n          viewport.scrollHeight - this.spacerPx,\n        )\n      : 0;\n    if (height !== this.spacerPx) this.setSpacer(height);\n  }\n\n  /** A row that starts a turn arrived at the end: settle it near the top. */\n  private settle(anchor: HTMLElement): void {\n    this.hold = anchor;\n    this.opening = false;\n    this.updateSpacer();\n    // The turn settles at the live edge, so the reply that grows into the room\n    // below it is followed once it fills the viewport.\n    this.following = true;\n    this.go(this.anchorTarget(anchor), \"smooth\");\n  }\n\n  // ---------------------------------------------------------------------------\n  // Rows that arrive\n\n  /** Rows were added or removed. */\n  itemsChanged(): void {\n    if (!this.opened || !this.viewport || !this.content) return;\n    const items = this.items();\n    const survivors = items.filter((item) => this.known.has(item));\n    const fresh = items.filter((item) => !this.known.has(item));\n    for (const item of fresh) this.known.add(item);\n    if (fresh.length === 0) return;\n\n    const order = new Map(items.map((item, index) => [item, index]));\n    const firstSurvivor = survivors[0] ? order.get(survivors[0]) : undefined;\n    const lastSurvivor = survivors.at(-1)\n      ? order.get(survivors.at(-1)!)\n      : undefined;\n    const appended = fresh.filter(\n      (item) => lastSurvivor !== undefined && order.get(item)! > lastSurvivor,\n    );\n    const prepended = fresh.filter(\n      (item) => firstSurvivor !== undefined && order.get(item)! < firstSurvivor,\n    );\n\n    // Only a row appended below the reader enters. Rows that fill the thread,\n    // join above it or are inserted between others take their place without\n    // the entrance, before the first style is resolved and the animation starts.\n    for (const item of fresh)\n      if (!appended.includes(item)) item.removeAttribute(\"data-entering\");\n\n    if (survivors.length === 0) {\n      // The whole thread was replaced, or the first rows arrived: open it again.\n      this.open();\n      return;\n    }\n    if (prepended.length > 0 && this.options.preserveScrollOnPrepend)\n      this.keepPlace(survivors[0]!, items[0]!);\n    const anchor = appended.filter(isAnchor).at(-1);\n    if (anchor) this.settle(anchor);\n  }\n\n  /**\n   * Rows were added above the first row the reader had: move the viewport\n   * down by the height they took, so the row in view stays where it was.\n   */\n  private keepPlace(previousFirst: HTMLElement, first: HTMLElement): void {\n    const gained = this.topOf(previousFirst) - this.topOf(first);\n    if (gained <= 0) return;\n    const { snapshot } = this;\n    this.jump(prependScrollTop(snapshot, snapshot.scrollHeight + gained));\n  }\n\n  // ---------------------------------------------------------------------------\n  // What the reader does\n\n  private readonly onScroll = (): void => {\n    const metrics = this.metrics();\n    if (isAtEdge(metrics, \"end\", this.options.scrollEdgeThreshold))\n      this.following = true;\n    if (this.smooth && Math.abs(metrics.scrollTop - this.smooth.target) <= 1)\n      this.finishSmoothScroll(true);\n    this.syncScrollable(metrics);\n    this.remember(metrics);\n  };\n\n  private readonly onScrollEnd = (): void => {\n    this.finishSmoothScroll(true);\n  };\n\n  private readonly onWheel = (event: WheelEvent): void => {\n    this.opening = false;\n    if (wheelScrollsTowardStart(event.deltaY)) this.release();\n  };\n\n  private readonly onTouchStart = (event: TouchEvent): void => {\n    this.opening = false;\n    this.touchStartY = event.touches[0]?.clientY ?? 0;\n  };\n\n  private readonly onTouchMove = (event: TouchEvent): void => {\n    const y = event.touches[0]?.clientY;\n    if (y !== undefined && touchScrollsTowardStart(this.touchStartY, y))\n      this.release();\n  };\n\n  private readonly onKeyDown = (event: KeyboardEvent): void => {\n    if (event.defaultPrevented || isEditable(event.target)) return;\n    this.opening = false;\n    if (keyScrollsTowardStart(event.key)) this.release();\n  };\n\n  private readonly onPointerDown = (event: PointerEvent): void => {\n    if (isScrollbar(event.target)) this.release();\n  };\n\n  /** Tells React when an edge becomes scrollable or stops being so. */\n  private syncScrollable(metrics?: ScrollMetrics): void {\n    if (!this.viewport) return;\n    const next = scrollableEdges(\n      metrics ?? this.metrics(),\n      this.options.scrollEdgeThreshold,\n    );\n    if (\n      next.start === this.scrollable.start &&\n      next.end === this.scrollable.end\n    )\n      return;\n    this.scrollable = next;\n    this.onScrollable(next);\n  }\n\n  // ---------------------------------------------------------------------------\n  // The commands the hook gives out\n\n  /** Scrolls to the latest row and follows the thread from there. */\n  scrollToEnd(options: ScrollToOptions = {}): void {\n    this.opening = false;\n    this.following = true;\n    this.go(maxScrollTop(this.metrics()), options.behavior);\n  }\n\n  scrollToStart(options: ScrollToOptions = {}): void {\n    this.release();\n    this.go(0, options.behavior);\n  }\n\n  /** Scrolls a row to the top of the viewport; false when no such row is mounted. */\n  scrollToMessage(id: string, options: ScrollToOptions = {}): boolean {\n    if (!this.viewport) return false;\n    const item = this.items().find(\n      (row) => row.getAttribute(\"data-message-id\") === id,\n    );\n    if (!item) return false;\n    this.release();\n    this.go(this.anchorTarget(item), options.behavior);\n    return true;\n  }\n\n  /** Moves focus to the viewport, for a control that leaves when it is pressed. */\n  focusViewport(): void {\n    this.viewport?.focus({ preventScroll: true });\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/message-scroller/message-scroller-controller.ts"},{"path":"packages/ui/src/components/message-scroller/message-scroller-math.ts","content":"/**\n * The arithmetic of the Message Scroller, kept apart from the DOM so it can be\n * checked without a browser. Every function takes plain numbers and returns\n * one: the scroller reads the viewport, asks here and writes the answer back.\n * Nothing here is exported from the component's `index.ts`.\n */\n\n/** What the viewport reports about its scroll position. */\nexport type ScrollMetrics = {\n  clientHeight: number;\n  scrollHeight: number;\n  scrollTop: number;\n};\n\nexport type ScrollEdge = \"start\" | \"end\";\n\n/** Where a thread opens, as the `defaultScrollPosition` prop names it. */\nexport type ScrollPosition = \"end\" | \"start\" | \"last-anchor\";\n\n/** The furthest the viewport can scroll. */\nexport function maxScrollTop(\n  metrics: Pick<ScrollMetrics, \"clientHeight\" | \"scrollHeight\">,\n): number {\n  return Math.max(0, metrics.scrollHeight - metrics.clientHeight);\n}\n\n/**\n * Whether the viewport rests within `threshold` pixels of an edge. A\n * rubber-band overscroll past an edge counts as being at it.\n */\nexport function isAtEdge(\n  metrics: ScrollMetrics,\n  edge: ScrollEdge,\n  threshold: number,\n): boolean {\n  return edge === \"start\"\n    ? metrics.scrollTop <= threshold\n    : metrics.scrollHeight - metrics.clientHeight - metrics.scrollTop <=\n        threshold;\n}\n\n/**\n * The edges the viewport can still scroll toward: `start` when there is\n * content above the fold, `end` when there is content below it. An edge the\n * viewport rests at is not scrollable toward, so a thread that fits in its\n * box is scrollable toward neither.\n */\nexport function scrollableEdges(\n  metrics: ScrollMetrics,\n  threshold: number,\n): { end: boolean; start: boolean } {\n  return {\n    end: !isAtEdge(metrics, \"end\", threshold),\n    start: !isAtEdge(metrics, \"start\", threshold),\n  };\n}\n\n/**\n * Where a turn settles when it starts: its first row near the top of the\n * viewport, with up to `peek` pixels of the row before it above. A previous\n * row shorter than the peek shows whole and no more, so the view never\n * reaches past it.\n */\nexport function anchorScrollTop(\n  itemTop: number,\n  previousItemHeight: number,\n  peek: number,\n): number {\n  return itemTop - Math.min(peek, previousItemHeight);\n}\n\n/**\n * The scroll position that keeps the row the reader looks at in place when\n * rows are added above it: the old position, moved down by the height the\n * thread has gained.\n */\nexport function prependScrollTop(\n  before: Pick<ScrollMetrics, \"scrollHeight\" | \"scrollTop\">,\n  nextScrollHeight: number,\n): number {\n  return before.scrollTop + (nextScrollHeight - before.scrollHeight);\n}\n\n/**\n * Whether a growing thread carries the viewport with it. Both must hold: the\n * app asked for it (`autoScroll`) and the reader has not scrolled away since\n * they were last at the live edge (`following`). A reader who scrolled up is\n * never pulled back down.\n */\nexport function shouldFollow(state: {\n  autoScroll: boolean;\n  following: boolean;\n}): boolean {\n  return state.autoScroll && state.following;\n}\n\n/**\n * Where a thread opens. `last-anchor` shows the last turn from its first row,\n * where that row would settle (`anchor.target`: near the top, with the peek of\n * the row before it above); it falls back to the end when no row anchors a\n * turn or the last turn, from its first row to the end, already fits in the\n * viewport.\n */\nexport function openingScrollTop(input: {\n  /** The last anchor row: where it settles, and where it starts in scroll space. */\n  anchor?: { target: number; top: number } | undefined;\n  metrics: Pick<ScrollMetrics, \"clientHeight\" | \"scrollHeight\">;\n  position: ScrollPosition;\n}): number {\n  const { anchor, metrics, position } = input;\n  const end = maxScrollTop(metrics);\n  if (position === \"start\") return 0;\n  if (position === \"end\" || !anchor) return end;\n  if (metrics.scrollHeight - anchor.top <= metrics.clientHeight) return end;\n  return Math.min(end, Math.max(0, anchor.target));\n}\n\n/**\n * The room a thread needs below its last row so that a turn can settle at\n * `target`: the viewport has to reach `target` and still be filled, which is\n * `target + clientHeight` of scroll height. `naturalScrollHeight` is the\n * height of the rows alone. Whole pixels, rounded up, so the target is\n * reachable.\n */\nexport function spacerHeight(\n  target: number,\n  clientHeight: number,\n  naturalScrollHeight: number,\n): number {\n  return Math.max(0, Math.ceil(target + clientHeight - naturalScrollHeight));\n}\n\n/** Keys that scroll toward the start and so end the follow. */\nconst keysTowardStart: ReadonlySet<string> = new Set([\n  \"ArrowUp\",\n  \"Home\",\n  \"PageUp\",\n]);\n\n/** Whether a key press scrolls the viewport toward its start. */\nexport function keyScrollsTowardStart(key: string): boolean {\n  return keysTowardStart.has(key);\n}\n\n/** Whether a wheel turn scrolls toward the start. */\nexport function wheelScrollsTowardStart(deltaY: number): boolean {\n  return deltaY < 0;\n}\n\n/**\n * Whether a touch that began at `startY` and is now at `currentY` drags the\n * content down, which scrolls toward the start. The slop absorbs the jitter\n * of a finger that scrolls the other way.\n */\nexport function touchScrollsTowardStart(\n  startY: number,\n  currentY: number,\n  slop = 4,\n): boolean {\n  return currentY - startY > slop;\n}\n","type":"registry:ui","target":"~/src/siteplane/components/message-scroller/message-scroller-math.ts"},{"path":"packages/ui/src/components/message-scroller/message-scroller.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* A row of the thread. Rows far from the viewport skip their rendering\n   * work; a row that has been rendered once keeps the size it had (`auto`),\n   * and one that has not is taken to be a line or two high. Paint\n   * containment clips what a row draws outside its box, so a row keeps room\n   * for the focus ring of its controls. */\n  .sp-message-scroller-item {\n    content-visibility: auto;\n    contain-intrinsic-block-size: auto var(--sp-rw-space-12);\n  }\n\n  /* A row that arrives after the thread opened fades in with a small rise. It\n   * takes its full height at once: height, margin and padding never animate,\n   * because the scroller measures the row to settle a new turn and to follow\n   * the end, and an expanding row (rulebook M6) would move both while it\n   * runs. The animation ends where the row rests and fills nothing forward, so\n   * no stacking context or transform is left on the row afterwards. */\n  .sp-message-scroller-item[data-entering] {\n    animation: sp-message-scroller-item-enter var(--motion-duration-base)\n      var(--motion-ease-standard);\n  }\n\n  @keyframes sp-message-scroller-item-enter {\n    from {\n      opacity: 0;\n      transform: translateY(4px);\n    }\n  }\n\n  /* Reduced motion is instant (M5): the row is at rest at once. */\n  @media (prefers-reduced-motion: reduce) {\n    .sp-message-scroller-item[data-entering] {\n      animation: none;\n    }\n  }\n\n  html[data-motion-reduce=\"true\"] .sp-message-scroller-item[data-entering],\n  [data-sp-motion=\"reduced\"] .sp-message-scroller-item[data-entering] {\n    animation: none;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/message-scroller/message-scroller.css"},{"path":"packages/ui/src/components/message-scroller/index.ts","content":"export * from \"./message-scroller\";\n","type":"registry:ui","target":"~/src/siteplane/components/message-scroller/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/message-scroller.md","categories":["Chat & AI"],"meta":{"avoidWhen":["Avoid for content that does not grow at its end, such as a feed or a list; use Scroll Area.","Avoid for a long history loaded at once; the rows are not virtualized, so load older messages in pages."],"description":"Scroll a chat thread that opens at the latest message, follows a streaming reply and leads back to the latest message.","knownLimitations":["Rows are not virtualized. Every row stays mounted, and rows outside the viewport only skip their rendering work.","Only the MessageScrollerItem children of MessageScrollerContent count as rows; a row inside another element is not measured.","The reader's place is kept for rows added above the first row. Rows inserted between two rows take their place without that correction.","Screen readers treat aria-busy differently, so check how a streaming reply is announced with the screen readers your users rely on."],"providerNotes":["The responding and complete messages go to the kit's shared polite live region, which the registry installs with the scroller.","SiteplaneMotionProvider preset=\"reduced\" and the system setting make the row entrance and every scroll instant."],"releaseStatus":"preview","searchTerms":["message-scroller","chat","conversation","thread","scroll to bottom","auto scroll","stick to bottom","streaming","AI chat"],"useWhen":["Use for the thread of a chat or an AI assistant, where turns arrive at the end and replies stream in.","Use when a reader who scrolls up to read must stay in place while a reply streams, with one press back to the latest message."]}}