<!-- GENERATED by scripts/build-llms.mjs from llms/conversation.md — do not edit this file. -->

# `lr-chat-viewport`

- **Import** `import '@aceshooting/lyra-ui/components/lr-chat-viewport.js';` (stable tag alias; registers the tag)
- **Class** `LyraChatViewport`, also available unregistered from `@aceshooting/lyra-ui/components/conversation/chat-viewport/chat-viewport.class.js`
- **Family** `components/conversation/` — see `llms/index.md` for its siblings
- **Status** `stable` since `4.0.0` — see the maturity and deprecation policy in `llms/shared.md`
- **Release history** [CHANGELOG.md](../../CHANGELOG.md); family-wide breaking-change summaries: [llms-full.txt](../../llms-full.txt)
- **Deprecations** none
- **Optional peers** none
- **Themeable via** 5 parts, 0 custom properties — see this component's own `@csspart`/`@cssprop` list below
- **Library-wide behavior** (events, form association, `locale`/`strings`, tokens, TS types): `llms/shared.md`

---

## `lr-chat-viewport`

The transcript scroll container: owns stick-to-bottom behavior while an answer streams, the "jump to
latest" pill, and the unread divider. Two supported content shapes, auto-detected: ordinary element
children (typically `lr-chat-message`s — _slotted mode_), or exactly one `lr-virtual-list`
(_virtual mode_, detected via `instanceof`). In virtual mode this component defers all scrolling to
the slotted list's own `scrollToIndex()`. Follow/release state machine: while `follow` is engaged,
content growth re-scrolls to the end; release happens only on a user-intent gesture (wheel,
touchmove, scrollbar-drag, or PageUp/ArrowUp/Home while the log region has focus) that leaves the
view more than `bottomThreshold` from the end — a scroll caused by this component's own programmatic
scrolling, or by a layout shift, never releases it. Reaching the bottom again by any means re-engages
`follow`. The shadow `role="log"` always remains `aria-live="off"`, which avoids announcing every
streaming token. Consumers that append complete messages at an announcement-safe cadence can opt
into `polite` or `assertive`; each newly appended direct child's accessibility-exposed text is then
copied to the matching shared light-DOM announcement sink in the component's `ownerDocument`.
Hidden, inert, `aria-hidden`, and CSS-hidden content is omitted. Existing declarative children stay
silent on mount, and appending the same text again creates another announcement. `off` acquires no
sink and produces no announcements.

**Properties:** `follow: boolean = true` (reflected) — component-managed stick-to-bottom state,
host-writable: setting `true` scrolls to the end and re-engages following, setting `false` releases
it. `bottomThreshold: number = 24` (attribute `bottom-threshold`) — px distance from the end still
counted as "at bottom." `unreadStartIndex: number | null = null` (attribute `unread-start-index`) —
index of the first unread item (element-child index in slotted mode, `items` index in virtual mode);
`null` disables both the divider and the pill's unread count. `live: 'off' | 'polite' | 'assertive' =
'off'` (reflected) — policy for the shared light-DOM announcement sink; the internal log itself
remains non-live. Keep `off` for token-by-token streaming and opt in only when complete messages are
appended as direct children at an announcement-safe cadence.
`label: string = ''` — accessible name
for the log region, defaults to the localized `chatViewportLabel`. `accessibleLabel: string | null =
null` (attribute `aria-label`) — host `aria-label`, forwarded to the internal `role="log"` element
(an `aria-label` left on the host itself names nothing, since the log role lives inside the shadow
root); wins over `label` and the localized default.

**Methods:** `scrollToBottom(options?)` — scrolls to the end and re-engages `follow`; default
`smooth`, forced to `auto` under `prefers-reduced-motion`. `scrollToUnread(options?)` — scrolls the
unread divider to the top of the view, resolving `false` when `unreadStartIndex` is `null`/out of
range; does not re-engage `follow`.

**Events:** `lr-follow-change` — `detail: { following }`, fired whenever `follow` flips (user
scroll-up release, or reaching the bottom again). Never fired for the initial mount state.

Activating a focused jump-to-latest pill, or directly setting `follow = true`, transfers focus to
the transcript's stable scroll owner after the pill disappears: `[part="scroll"]` in slotted mode,
or the nested virtual list's real focus owner in virtual mode. Focus that moved elsewhere before the
update is preserved.

**Slots:** default — the transcript: ordinary element children, or exactly one `lr-virtual-list`.

Only a direct child `lr-virtual-list` selects virtual mode; bubbled list events from nested message
content are ignored. In slotted mode the visual unread divider remains an absolutely positioned
paint layer while a separate hidden semantic boundary is inserted immediately before the first
unread child, so DOM/accessibility order matches the visible boundary. Reordering an existing child
does not announce it again; only genuinely appended complete-message nodes do. A primary
`pointerdown` begins scrollbar-drag tracking only when the scroll container itself is the event
target, never for arbitrary descendant controls.

**CSS parts:** `base` (the positioning root), `scroll` (the scroll container, non-live `role="log"`,
`tabindex="0"`; in virtual mode it stops scrolling itself, keeps the role, and drops its tab stop), `content` (the
slotted-content wrapper the growth observers watch), `jump-pill` (the built-in jump-to-latest button,
absent while `follow` is engaged), `unread-divider` (the "New messages" separator, slotted mode
only).

Renders no messages and computes no unread state itself — the host supplies `unreadStartIndex`; no
virtualization of its own (`lr-virtual-list`); not a generic overflow surface (`lr-scroller`); no
message semantics (`lr-chat-message`).

**Sizing in virtual mode.** `[part='scroll']` steps aside and the slotted `lr-virtual-list`'s own
viewport becomes the real scroller, so it is given this component's full height — otherwise it would
scroll inside `lr-virtual-list`'s `24rem` default no matter how tall the viewport is. An explicit
`block-size` on the slotted list is what makes that resolvable: without it the list host is
auto-height, its own base percentage chains to `auto`, and the two size each other circularly.
`<lr-thread-list>` solves the same problem by turning the internal list's shipped `24rem` into a
flex-basis through `::part(base)`, which is not available here — that list lives in the _consumer's_
light DOM, and `::slotted()` cannot be followed by `::part()`. Virtual mode therefore inherits this
component's existing requirement of a height-bounded parent, exactly as slotted mode's own
`[part='scroll']` already does. A document-tree declaration on the list (a consumer's own rule or an
inline style) still wins over the built-in one.

```html
<lr-chat-viewport unread-start-index="12">
  <lr-chat-message message-role="user">…</lr-chat-message>
  <lr-chat-message message-role="assistant" status="streaming">
    <lr-streaming-text streaming></lr-streaming-text>
  </lr-chat-message>
</lr-chat-viewport>
<lr-chat-composer status="streaming"></lr-chat-composer>
<script type="module">
  const viewport = document.querySelector("lr-chat-viewport");
  viewport.querySelector("lr-streaming-text").content = partial;
  viewport.addEventListener("lr-follow-change", (e) => console.log(e.detail.following));
</script>
```
