{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-scroller",
  "title": "MessageScroller",
  "description": "Focusable transcript scroller with turn anchoring, reader-controlled live-edge following, and a measured interaction-plus-composer overlay stack.",
  "type": "registry:ui",
  "meta": {
    "library": "@ds-mo/ui",
    "distribution": "npm",
    "storybook": "https://zainadeel.github.io/compomo/?path=/story/message-scroller",
    "source": "src/wc/components/MessageScroller/MessageScroller.tsx",
    "intentStatus": "complete",
    "intent": {
      "audience": "general",
      "status": "experimental",
      "summary": "Focusable transcript scroller with turn anchoring, reader-controlled live-edge following, and a measured interaction-plus-composer overlay stack.",
      "useWhen": [
        "Messages can append or stream and transcript position must remain respectful of reader intent."
      ],
      "avoidWhen": [
        "Content is static prose without message navigation or live updates."
      ],
      "commonlyComposedWith": [
        "component:ds-message",
        "component:ds-message-composer",
        "component:ds-agent-questionnaire"
      ],
      "patterns": [
        "pattern:conversation",
        "pattern:agent-chat"
      ],
      "accessibility": [
        "Give each transcript a concise localized label.",
        "New content is exposed through log semantics without stealing focus.",
        "The scroll-to-latest control remains keyboard accessible when follow behavior is released and exposes its action name in a tooltip."
      ],
      "states": [
        "Wheel, touch, keyboard navigation, selection, and transcript interaction release automatic following; Scroll to latest explicitly restores it.",
        "A newly appended outgoing Message marked scroll-anchor starts a turn near the top with the configurable previous-turn peek; mutations inside existing messages and prepended history do not re-anchor.",
        "Opening saved history, message jumps, and prepended history preserve deliberate position.",
        "The scroll-to-latest action is absent at the live edge, appears only after the reader moves away, and disappears again after returning to the newest message.",
        "Scroll to latest uses the shared md control-elevation wrapper with a pointer-transparent top highlight, preserving its transparent backdrop blur and borderless unfilled action while the press-scale modifier moves the complete elevated surface.",
        "Place a pending human-input surface in interaction and a composer container with 8px padding on every side in overlay; the shared scroll-overlay controller measures them as one stack, tracks every growth step, reserves matching transcript clearance, begins the fade before the stack, and keeps scroll-to-latest above it."
      ],
      "responsiveBehavior": [
        "Transcript width is readable while the scroll region fills available height.",
        "For a narrower fullscreen conversation lane, set --ds-message-scroller-content-max-width on the host instead of constraining the host; the full-width viewport then keeps its scrollbar at the outer edge while the transcript remains centered.",
        "The custom property sizes the padded transcript box. If the target is the usable conversation width, add the transcript's two 16px inline paddings and size the centered overlay container to the same target plus its two 8px paddings so messages and the visible composer align."
      ],
      "references": [
        {
          "label": "Storybook examples",
          "path": "src/wc/components/MessageScroller/MessageScroller.stories.ts"
        }
      ]
    },
    "api": {
      "props": {
        "autoFollow": {
          "type": "boolean",
          "resolvedType": "boolean",
          "attribute": "auto-follow",
          "default": "true",
          "required": false,
          "mutable": false
        },
        "busy": {
          "type": "boolean",
          "resolvedType": "boolean",
          "attribute": "busy",
          "default": "false",
          "required": false,
          "mutable": false
        },
        "defaultPosition": {
          "type": "MessageScrollerPosition",
          "resolvedType": "\"end\" | \"last-anchor\" | \"start\"",
          "attribute": "default-position",
          "default": "'last-anchor'",
          "required": false,
          "mutable": false
        },
        "messagesLabel": {
          "type": "string",
          "resolvedType": "string",
          "attribute": "messages-label",
          "default": "'Messages'",
          "required": false,
          "mutable": false
        }
      },
      "events": [
        {
          "name": "dsReachStart",
          "detail": "void",
          "bubbles": true,
          "cancelable": true,
          "composed": true
        }
      ],
      "methods": [
        {
          "name": "scrollToEnd",
          "signature": "scrollToEnd() => Promise<void>"
        },
        {
          "name": "scrollToMessage",
          "signature": "scrollToMessage(id: string) => Promise<boolean>"
        },
        {
          "name": "scrollToStart",
          "signature": "scrollToStart() => Promise<void>"
        }
      ],
      "slots": []
    },
    "props": {
      "autoFollow": {
        "type": "boolean",
        "resolvedType": "boolean",
        "attribute": "auto-follow",
        "default": "true",
        "required": false,
        "mutable": false
      },
      "busy": {
        "type": "boolean",
        "resolvedType": "boolean",
        "attribute": "busy",
        "default": "false",
        "required": false,
        "mutable": false
      },
      "defaultPosition": {
        "type": "MessageScrollerPosition",
        "resolvedType": "\"end\" | \"last-anchor\" | \"start\"",
        "attribute": "default-position",
        "default": "'last-anchor'",
        "required": false,
        "mutable": false
      },
      "messagesLabel": {
        "type": "string",
        "resolvedType": "string",
        "attribute": "messages-label",
        "default": "'Messages'",
        "required": false,
        "mutable": false
      }
    },
    "events": [
      {
        "name": "dsReachStart",
        "detail": "void",
        "bubbles": true,
        "cancelable": true,
        "composed": true
      }
    ],
    "methods": [
      {
        "name": "scrollToEnd",
        "signature": "scrollToEnd() => Promise<void>"
      },
      {
        "name": "scrollToMessage",
        "signature": "scrollToMessage(id: string) => Promise<boolean>"
      },
      {
        "name": "scrollToStart",
        "signature": "scrollToStart() => Promise<void>"
      }
    ],
    "slots": [],
    "exports": {
      "customElement": "ds-message-scroller",
      "react": "DsMessageScroller",
      "vue": "DsMessageScroller",
      "angular": "DsMessageScroller"
    },
    "consumption": {
      "install": "npm install @ds-mo/ui @ds-mo/tokens @ds-mo/icons",
      "cssSetup": "import '@ds-mo/tokens';\nimport '@ds-mo/tokens/reset';\nimport '@ds-mo/tokens/globals';",
      "customElements": {
        "import": "import '@ds-mo/ui/dist/components/ds-message-scroller.js';",
        "example": "<ds-message-scroller></ds-message-scroller>"
      },
      "react": {
        "import": "import { DsMessageScroller } from '@ds-mo/ui/react';",
        "example": "<DsMessageScroller />"
      },
      "vue": {
        "import": "import { DsMessageScroller } from '@ds-mo/ui/vue';",
        "example": "<DsMessageScroller />"
      },
      "angular": {
        "import": "import { DsMessageScroller } from '@ds-mo/ui/angular/ds-message-scroller';",
        "example": "<ds-message-scroller></ds-message-scroller>"
      },
      "peerDependencies": {
        "required": [
          "@ds-mo/tokens ^6.5.0",
          "@ds-mo/icons ^7.0.0"
        ],
        "frameworks": "Custom Elements; React 18/19 wrappers; Vue 3 wrappers; Angular 19-22 standalone adapters."
      }
    }
  },
  "dependencies": [
    "@ds-mo/ui",
    "@ds-mo/tokens"
  ],
  "registryDependencies": [
    "button-unfilled",
    "tooltip"
  ],
  "files": [
    {
      "path": "src/wc/components/MessageScroller/AgentChatReview.stories.css",
      "content": "@import '../../utils/forced-colors.css';\n\n.agent-chat-review {\n  display: grid;\n  min-height: 100vh;\n  place-items: center;\n  padding: var(--dimension-space-200);\n  background: var(--color-background-secondary);\n  box-sizing: border-box;\n}\n\n.agent-chat-review__viewport {\n  width: min(var(--agent-chat-review-width), 100%);\n  height: min(\n    var(--agent-chat-review-height),\n    calc(100vh - var(--dimension-space-400))\n  );\n  min-height: var(--agent-chat-review-min-height);\n  overflow: hidden;\n  border: var(--dimension-stroke-width-012) solid var(--color-border-tertiary);\n  border-radius: var(--dimension-radius-125);\n  background: var(--color-background-primary);\n  box-shadow: var(--effect-elevation-elevated-md);\n}\n\n.agent-chat-review__viewport--narrow {\n  width: min(var(--agent-chat-review-width), 100%);\n  height: min(\n    var(--agent-chat-review-height),\n    calc(100vh - var(--dimension-space-400))\n  );\n}\n\n.agent-chat-review__viewport ds-message-scroller {\n  --ds-message-scroller-content-max-width: var(--dimension-panel-width-xl);\n}\n\n.agent-chat-review__interaction {\n  padding: var(--dimension-space-100) var(--dimension-space-100) 0;\n}\n\n.agent-chat-review__overlay {\n  padding: var(--dimension-space-100);\n}\n\n.agent-chat-review__prose {\n  display: grid;\n  gap: var(--dimension-space-100);\n}\n\n.agent-chat-review__result-link {\n  color: inherit;\n  text-decoration: none;\n}\n\n@media (max-width: 480px) {\n  .agent-chat-review {\n    padding: var(--dimension-space-100);\n  }\n\n  .agent-chat-review__viewport,\n  .agent-chat-review__viewport--narrow {\n    width: 100%;\n    height: calc(100vh - var(--dimension-space-200));\n    min-height: var(--agent-chat-review-min-height);\n  }\n}\n\n@media (forced-colors: active) {\n  .agent-chat-review__viewport {\n    border-color: var(--ds-forced-color-content);\n    box-shadow: none;\n  }\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/wc/components/MessageScroller/MessageScroller.css",
      "content": "@import '../../styles/control-elevation.css';\n\n:host {\n  --ds-message-scroller-anchor-offset: var(--dimension-space-800);\n\n  display: block;\n  width: 100%;\n  height: 100%;\n  min-height: 0;\n  box-sizing: border-box;\n}\n\n.message-scroller {\n  position: relative;\n  width: 100%;\n  height: 100%;\n  min-height: 0;\n}\n\n.message-scroller__viewport {\n  width: 100%;\n  height: 100%;\n  min-height: 0;\n  overflow: auto;\n  overflow-anchor: none;\n  overscroll-behavior: contain;\n  box-sizing: border-box;\n}\n\n.message-scroller__viewport:focus-visible {\n  outline: var(--dimension-stroke-width-025) solid var(--color-border-bold-brand);\n  outline-offset: calc(-1 * var(--dimension-stroke-width-025));\n}\n\n.message-scroller__content {\n  display: flex;\n  flex-direction: column;\n  gap: var(--dimension-space-300);\n  width: 100%;\n  max-width: var(\n    --ds-message-scroller-content-max-width,\n    var(--dimension-panel-width-2xl)\n  );\n  min-height: 100%;\n  margin-inline: auto;\n  padding-block-start: var(--dimension-space-200);\n  padding-inline: var(--dimension-space-200);\n  padding-block-end: max(\n    calc(var(--dimension-space-200) + var(--ds-scroll-overlay-block-size, 0px)),\n    var(--ds-message-scroller-turn-clearance, 0px)\n  );\n  box-sizing: border-box;\n}\n\n.message-scroller__overlay {\n  position: absolute;\n  right: 0;\n  bottom: 0;\n  left: 0;\n  z-index: var(--dimension-z-index-floating);\n}\n\n.message-scroller__overlay-stack {\n  display: grid;\n  width: 100%;\n}\n\n.message-scroller__interaction,\n.message-scroller__overlay-content {\n  min-width: 0;\n}\n\n.message-scroller__control {\n  position: absolute;\n  bottom: calc(\n    var(--ds-scroll-overlay-content-block-size, 0px) + var(--dimension-space-200)\n  );\n  left: 50%;\n  z-index: var(--dimension-z-index-floating);\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-radius: var(--dimension-radius-half);\n  background: var(--color-background-transparent);\n  transform: translateX(-50%);\n  -webkit-backdrop-filter: blur(var(--effect-blur-sm));\n  backdrop-filter: blur(var(--effect-blur-sm));\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "src/wc/components/MessageScroller/MessageScroller.tsx",
      "content": "import {\n  Component,\n  Element,\n  Event,\n  EventEmitter,\n  h,\n  Host,\n  Method,\n  Prop,\n  State,\n} from '@stencil/core';\nimport type { MessageScrollerPosition } from '../conversation-types';\nimport { ScrollOverlayController } from '../../utils/scroll-overlay-controller';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\n\nconst LIVE_EDGE_THRESHOLD = 24;\n\n@Component({\n  tag: 'ds-message-scroller',\n  styleUrl: 'MessageScroller.css',\n  styleUrls: ['../../utils/scroll-edge-fade.css'],\n  scoped: true,\n})\nexport class MessageScroller {\n  @Element() el!: HTMLElement;\n\n  @Prop() busy: boolean = false;\n  @Prop() autoFollow: boolean = true;\n  @Prop() defaultPosition: MessageScrollerPosition = 'last-anchor';\n  @Prop() messagesLabel: string = 'Messages';\n\n  @Event() dsReachStart!: EventEmitter<void>;\n\n  @State() private showScrollToLatest = false;\n\n  private viewport?: HTMLElement;\n  private content?: HTMLElement;\n  private overlay?: HTMLElement;\n  private transcriptSlot?: HTMLSlotElement;\n  private scrollOverlayController?: ScrollOverlayController;\n  private mutationObserver?: MutationObserver;\n  private following = true;\n  private followReleased = false;\n  private releasePendingAtLiveEdge = false;\n  private atStart = false;\n  private programmatic = false;\n  private programmaticTimer?: ReturnType<typeof setTimeout>;\n  private transcriptChildren: HTMLElement[] = [];\n  private knownTranscriptElements = new Set<HTMLElement>();\n  private knownTurnAnchors = new Set<HTMLElement>();\n  private firstChildViewportTop?: number;\n  private activeTurnAnchor?: HTMLElement;\n  private activeTurnAnchorViewportTop?: number;\n  private turnClearance = 0;\n  private prependReconciliation = 0;\n\n  componentDidLoad() {\n    this.transcriptChildren = this.getTranscriptChildren();\n    this.transcriptChildren.forEach(element => {\n      this.knownTranscriptElements.add(element);\n      if (element.matches('ds-message[scroll-anchor]')) {\n        this.knownTurnAnchors.add(element);\n      }\n    });\n    this.rememberFirstChildTop();\n    this.connectObservers();\n    requestAnimationFrame(() => this.applyDefaultPosition());\n    document.addEventListener('selectionchange', this.handleSelectionChange);\n  }\n\n  disconnectedCallback() {\n    this.prependReconciliation += 1;\n    this.scrollOverlayController?.disconnect();\n    this.mutationObserver?.disconnect();\n    if (this.programmaticTimer) clearTimeout(this.programmaticTimer);\n    document.removeEventListener('selectionchange', this.handleSelectionChange);\n  }\n\n  @Method()\n  async scrollToMessage(id: string): Promise<boolean> {\n    const message = Array.from(\n      this.el.querySelectorAll<HTMLElement & { messageId?: string }>('ds-message')\n    ).find(item => item.messageId === id || item.getAttribute('message-id') === id);\n    if (!message) return false;\n    this.releasePendingAtLiveEdge = false;\n    this.following = false;\n    this.followReleased = true;\n    this.showScrollToLatest = true;\n    message.scrollIntoView({ block: 'start', behavior: this.motionBehavior() });\n    return true;\n  }\n\n  @Method()\n  async scrollToStart() {\n    this.releasePendingAtLiveEdge = false;\n    this.scrollTo(0);\n    this.following = false;\n    this.followReleased = true;\n    this.showScrollToLatest = Boolean(\n      this.viewport && this.viewport.scrollHeight > this.viewport.clientHeight\n    );\n  }\n\n  @Method()\n  async scrollToEnd() {\n    this.releasePendingAtLiveEdge = false;\n    this.following = true;\n    this.followReleased = false;\n    this.showScrollToLatest = false;\n    this.scrollTo(this.viewport?.scrollHeight ?? 0);\n  }\n\n  private motionBehavior(): ScrollBehavior {\n    return matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';\n  }\n\n  private scrollTo(top: number) {\n    if (!this.viewport) return;\n    this.programmatic = true;\n    const behavior = this.motionBehavior();\n    this.viewport.scrollTo({ top, behavior });\n    if (this.programmaticTimer) clearTimeout(this.programmaticTimer);\n    this.programmaticTimer = setTimeout(() => {\n      this.programmatic = false;\n      this.programmaticTimer = undefined;\n      if (this.following) {\n        this.viewport!.scrollTop = top;\n        this.showScrollToLatest = false;\n      }\n    }, behavior === 'smooth' ? 600 : 0);\n  }\n\n  private connectObservers() {\n    if (!this.viewport || !this.content || !this.overlay) return;\n    this.scrollOverlayController = new ScrollOverlayController({\n      host: this.el,\n      viewport: this.viewport,\n      content: this.content,\n      overlay: this.overlay,\n      overlayContentSelector: '.message-scroller__overlay-stack',\n      onGeometryChange: () => this.handleContentGrowth(),\n    });\n    this.scrollOverlayController.connect();\n    this.mutationObserver = new MutationObserver(() => {\n      this.handleTranscriptChange();\n      this.handleContentGrowth();\n    });\n    this.mutationObserver.observe(this.el, {\n      childList: true,\n      subtree: true,\n      characterData: true,\n    });\n  }\n\n  private handleContentGrowth() {\n    if (!this.viewport) return;\n    this.syncTurnClearance();\n    const nextHeight = this.viewport.scrollHeight;\n    if (this.autoFollow && this.following && !this.followReleased) {\n      this.viewport.scrollTop = nextHeight;\n    }\n    if (this.releasePendingAtLiveEdge && !this.isAtLiveEdge()) {\n      this.releasePendingAtLiveEdge = false;\n    }\n    this.showScrollToLatest = !this.following && !this.isAtLiveEdge();\n    this.scrollOverlayController?.sync();\n  }\n\n  private rememberFirstChildTop() {\n    if (!this.viewport) return;\n    const first = this.getTranscriptChildren()[0];\n    this.firstChildViewportTop = first\n      ? first.getBoundingClientRect().top - this.viewport.getBoundingClientRect().top\n      : undefined;\n  }\n\n  private getTranscriptChildren(): HTMLElement[] {\n    const assigned = this.transcriptSlot?.assignedElements() ?? [];\n    const assignedTranscript = assigned.filter(\n      (element): element is HTMLElement =>\n        element instanceof HTMLElement &&\n        (element.matches('ds-message') || element.matches('ds-agent-response')),\n    );\n    const queriedTranscript = Array.from(\n      this.el.querySelectorAll<HTMLElement>('ds-message, ds-agent-response'),\n    ).filter(element => {\n      if (\n        element.closest(\n          '.message-scroller__interaction, .message-scroller__overlay-content',\n        )\n      ) {\n        return false;\n      }\n      if (element.matches('ds-agent-response')) return true;\n      return !element.closest('ds-agent-response');\n    });\n    return queriedTranscript.length > assignedTranscript.length\n      ? queriedTranscript\n      : assignedTranscript;\n  }\n\n  private handleTranscriptChange = () => {\n    const current = this.getTranscriptChildren();\n    const previous = this.transcriptChildren;\n    const hadKnownTranscript = this.knownTranscriptElements.size > 0;\n    const previouslyKnown = new Set(this.knownTranscriptElements);\n    const appendedAnchor = [...current].reverse().find((element, reverseIndex) => {\n      if (\n        !element.matches('ds-message[scroll-anchor]') ||\n        this.knownTurnAnchors.has(element)\n      ) {\n        return false;\n      }\n      const index = current.length - reverseIndex - 1;\n      return current\n        .slice(index + 1)\n        .every(next => !this.knownTranscriptElements.has(next));\n    });\n    current.forEach(element => {\n      this.knownTranscriptElements.add(element);\n      if (element.matches('ds-message[scroll-anchor]')) {\n        this.knownTurnAnchors.add(element);\n      }\n    });\n    if (!previous.length) {\n      this.transcriptChildren = current;\n      if (appendedAnchor && hadKnownTranscript) {\n        requestAnimationFrame(() => this.positionNewTurn(appendedAnchor));\n      }\n      return;\n    }\n\n    const firstKnownIndex = current.findIndex(element => previouslyKnown.has(element));\n    const newTranscript = current.filter(element => !previouslyKnown.has(element));\n    const prepended =\n      (current.length > previous.length && previous.every(\n        (element, index) => current[current.length - previous.length + index] === element,\n      )) ||\n      (!appendedAnchor &&\n        newTranscript.length > 0 &&\n        firstKnownIndex > 0 &&\n        newTranscript.every(element => current.indexOf(element) < firstKnownIndex));\n\n    this.transcriptChildren = current;\n    if (prepended) {\n      if (this.viewport) {\n        const preservedElement = this.activeTurnAnchor?.isConnected\n          ? this.activeTurnAnchor\n          : previous[0];\n        const preservedTop = this.activeTurnAnchor?.isConnected\n          ? this.activeTurnAnchorViewportTop\n          : this.firstChildViewportTop;\n        if (preservedElement && preservedTop !== undefined) {\n          this.reconcilePrependPosition(preservedElement, preservedTop);\n          this.schedulePrependReconciliation(\n            preservedElement,\n            preservedTop,\n            newTranscript,\n          );\n        }\n      }\n      this.rememberFirstChildTop();\n      this.rememberActiveTurnAnchorTop();\n      this.scrollOverlayController?.sync();\n      return;\n    }\n    if (appendedAnchor) {\n      requestAnimationFrame(() => this.positionNewTurn(appendedAnchor));\n    }\n  };\n\n  private reconcilePrependPosition(element: HTMLElement, viewportTop: number) {\n    if (!this.viewport || !element.isConnected) return;\n    const nextTop =\n      element.getBoundingClientRect().top - this.viewport.getBoundingClientRect().top;\n    this.viewport.scrollTop += nextTop - viewportTop;\n  }\n\n  private schedulePrependReconciliation(\n    element: HTMLElement,\n    viewportTop: number,\n    prepended: HTMLElement[],\n  ) {\n    const generation = ++this.prependReconciliation;\n    // Prepended custom elements can gain height after their child-list mutation.\n    // Reconcile against the original coordinate after their layouts settle.\n    const pending = prepended\n      .flatMap(item => [item, ...Array.from(item.querySelectorAll<HTMLElement>('*'))])\n      .map(item => {\n        const componentOnReady = (\n          item as HTMLElement & { componentOnReady?: () => Promise<unknown> }\n        ).componentOnReady;\n        return componentOnReady ? componentOnReady.call(item).catch(() => undefined) : null;\n      })\n      .filter((promise): promise is Promise<unknown> => Boolean(promise));\n\n    void Promise.all(pending).then(() => {\n      requestAnimationFrame(() => {\n        if (generation !== this.prependReconciliation) return;\n        this.reconcilePrependPosition(element, viewportTop);\n        requestAnimationFrame(() => {\n          if (generation !== this.prependReconciliation) return;\n          this.reconcilePrependPosition(element, viewportTop);\n          this.rememberFirstChildTop();\n          this.rememberActiveTurnAnchorTop();\n          this.scrollOverlayController?.sync();\n        });\n      });\n    });\n  }\n\n  private handleTranscriptSlotChange = () => this.handleTranscriptChange();\n\n  private positionNewTurn(anchor: HTMLElement) {\n    if (!this.viewport) return;\n    this.activeTurnAnchor = anchor;\n    this.syncTurnClearance();\n    requestAnimationFrame(() => this.finishPositionNewTurn(anchor));\n  }\n\n  private finishPositionNewTurn(anchor: HTMLElement) {\n    if (!this.viewport || !anchor.isConnected) return;\n    const viewportRect = this.viewport.getBoundingClientRect();\n    const anchorTop =\n      anchor.getBoundingClientRect().top - viewportRect.top + this.viewport.scrollTop;\n    const customOffset = getComputedStyle(this.el)\n      .getPropertyValue('--ds-message-scroller-anchor-offset')\n      .trim();\n    const offset = resolveCssLengthPx(customOffset, 'var(--dimension-space-800)');\n    const max = Math.max(this.viewport.scrollHeight - this.viewport.clientHeight, 0);\n    const target = Math.min(Math.max(anchorTop - offset, 0), max);\n    this.following = max - target <= LIVE_EDGE_THRESHOLD;\n    this.followReleased = !this.following;\n    this.showScrollToLatest = !this.following;\n    this.programmatic = true;\n    this.viewport.scrollTop = target;\n    requestAnimationFrame(() => {\n      this.programmatic = false;\n      this.following = this.isAtLiveEdge();\n      this.followReleased = !this.following;\n      this.showScrollToLatest = !this.following;\n      this.rememberFirstChildTop();\n      this.rememberActiveTurnAnchorTop();\n    });\n    this.scrollOverlayController?.sync();\n  }\n\n  private rememberActiveTurnAnchorTop() {\n    if (!this.viewport || !this.activeTurnAnchor?.isConnected) return;\n    this.activeTurnAnchorViewportTop =\n      this.activeTurnAnchor.getBoundingClientRect().top -\n      this.viewport.getBoundingClientRect().top;\n  }\n\n  private syncTurnClearance() {\n    if (!this.viewport || !this.activeTurnAnchor?.isConnected) return;\n    const children = this.getTranscriptChildren();\n    const last = children[children.length - 1];\n    if (!last) return;\n    const anchorRect = this.activeTurnAnchor.getBoundingClientRect();\n    const lastRect = last.getBoundingClientRect();\n    const customOffset = getComputedStyle(this.el)\n      .getPropertyValue('--ds-message-scroller-anchor-offset')\n      .trim();\n    const offset = resolveCssLengthPx(customOffset, 'var(--dimension-space-800)');\n    const contentAfterAnchor = Math.max(lastRect.bottom - anchorRect.top, 0);\n    const clearance = Math.max(\n      Math.ceil(this.viewport.clientHeight - offset - contentAfterAnchor),\n      0,\n    );\n    if (clearance === this.turnClearance) return;\n    this.turnClearance = clearance;\n    this.el.style.setProperty('--ds-message-scroller-turn-clearance', `${clearance}px`);\n  }\n\n  private applyDefaultPosition() {\n    if (!this.viewport) return;\n    this.releasePendingAtLiveEdge = false;\n    if (this.defaultPosition === 'start') {\n      this.viewport.scrollTop = 0;\n      this.following = false;\n      this.followReleased = true;\n      this.showScrollToLatest = this.viewport.scrollHeight > this.viewport.clientHeight;\n      this.scrollOverlayController?.sync();\n      return;\n    }\n    if (this.defaultPosition === 'last-anchor') {\n      const anchors = this.el.querySelectorAll<HTMLElement>('ds-message[scroll-anchor]');\n      const last = anchors.item(anchors.length - 1);\n      if (last) {\n        last.scrollIntoView({ block: 'start' });\n        this.following = this.isAtLiveEdge();\n        this.followReleased = !this.following;\n        this.showScrollToLatest = !this.following;\n        this.scrollOverlayController?.sync();\n        return;\n      }\n    }\n    this.viewport.scrollTop = this.viewport.scrollHeight;\n    this.following = true;\n    this.followReleased = false;\n    this.showScrollToLatest = false;\n    this.scrollOverlayController?.sync();\n  }\n\n  private isAtLiveEdge(): boolean {\n    if (!this.viewport) return true;\n    return (\n      this.viewport.scrollHeight - this.viewport.clientHeight - this.viewport.scrollTop <=\n      LIVE_EDGE_THRESHOLD\n    );\n  }\n\n  private handleScroll = () => {\n    if (!this.viewport) return;\n    this.scrollOverlayController?.sync();\n    this.rememberFirstChildTop();\n    this.rememberActiveTurnAnchorTop();\n    if (this.programmatic) return;\n    const atLiveEdge = this.isAtLiveEdge();\n    if (this.releasePendingAtLiveEdge && atLiveEdge) {\n      // A scroll event queued by the previous auto-follow can arrive after\n      // reader input. Keep that stale event from restoring follow mode.\n      this.following = false;\n      this.followReleased = true;\n      this.showScrollToLatest = false;\n      return;\n    }\n    this.releasePendingAtLiveEdge = false;\n    this.following = atLiveEdge;\n    this.followReleased = !this.following;\n    this.showScrollToLatest = !this.following;\n    const nextAtStart = this.viewport.scrollTop <= LIVE_EDGE_THRESHOLD;\n    if (nextAtStart && !this.atStart) this.dsReachStart.emit();\n    this.atStart = nextAtStart;\n  };\n\n  private releaseFollow = () => {\n    this.prependReconciliation += 1;\n    this.releasePendingAtLiveEdge = this.isAtLiveEdge();\n    this.following = false;\n    this.followReleased = true;\n    this.showScrollToLatest = !this.releasePendingAtLiveEdge;\n  };\n\n  private handleSelectionChange = () => {\n    const selection = document.getSelection();\n    if (\n      selection &&\n      !selection.isCollapsed &&\n      selection.anchorNode &&\n      this.el.contains(selection.anchorNode)\n    ) {\n      this.releaseFollow();\n    }\n  };\n\n  render() {\n    return (\n      <Host>\n        <div class=\"message-scroller\">\n          <div\n            class=\"message-scroller__viewport\"\n            ref={element => {\n              this.viewport = element;\n            }}\n            role=\"region\"\n            aria-label={this.messagesLabel}\n            tabIndex={0}\n            onScroll={this.handleScroll}\n            onPointerDown={(event: PointerEvent) => {\n              if (event.pointerType === 'touch') this.releaseFollow();\n            }}\n            onWheel={this.releaseFollow}\n            onKeyDown={(event: KeyboardEvent) => {\n              if (\n                ['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '].includes(\n                  event.key,\n                )\n              ) {\n                this.releaseFollow();\n              }\n            }}\n          >\n            <div\n              class=\"message-scroller__content scroll-edge-fade--block-window\"\n              ref={element => {\n                this.content = element;\n              }}\n              role=\"log\"\n              aria-live=\"polite\"\n              aria-relevant=\"additions\"\n              aria-busy={this.busy ? 'true' : undefined}\n            >\n              <slot\n                ref={element => {\n                  this.transcriptSlot = element;\n                }}\n                onSlotchange={this.handleTranscriptSlotChange}\n              />\n            </div>\n          </div>\n          <div\n            class=\"message-scroller__overlay\"\n            ref={element => {\n              this.overlay = element;\n            }}\n          >\n            <div class=\"message-scroller__overlay-stack\">\n              <div class=\"message-scroller__interaction\">\n                <slot name=\"interaction\" />\n              </div>\n              <div class=\"message-scroller__overlay-content\">\n                <slot name=\"overlay\" />\n              </div>\n            </div>\n          </div>\n          {this.showScrollToLatest ? (\n            <div class=\"message-scroller__control ds-control-elevation ds-control-elevation--md ds-control-elevation--press-scale\">\n              <ds-tooltip label=\"Scroll to latest message\" side=\"top\" size=\"sm\">\n                <ds-button-unfilled\n                  variant=\"icon\"\n                  icon=\"ChevronDown\"\n                  size=\"md\"\n                  rounded\n                  hasBorder={false}\n                  aria-label=\"Scroll to latest message\"\n                  onDsClick={() => void this.scrollToEnd()}\n                />\n              </ds-tooltip>\n            </div>\n          ) : null}\n        </div>\n      </Host>\n    );\n  }\n}\n",
      "type": "registry:ui"
    }
  ]
}
