Accessibility
MessageList, MessageListItemKind, MessageListSenderGap from @prepared911/ui/message-list.
The transcript is one role="log" region named by label, with aria-live="polite" and aria-relevant="additions", all from ChatScroll. No Message or SystemMessage carries a role or a live region of its own, so a new message is announced once.
While the loading row shows, and for a frame after, the log is aria-live="off", so a page of earlier messages is not read as new traffic. loadingLabel is announced once through the live announcer when loading starts, and the log is aria-busy while it lasts. The placeholder lines are decorative.
The empty state sits inside the log. The log exists before the first message, so the first message is announced when it arrives.
Items are in the DOM in chronological order, which is also the focus order, even though a short thread sits at the bottom of the frame. System lines and blocks are in the same order and are never hidden. The space between items is margin only, so it adds nothing to the reading order or the accessibility tree.
The jump control is ChatScroll's: an icon-only button named by jumpToLatestLabel, or by jumpUnreadLabel(count) when messages arrived while the reader was away. Focus moves to the log before the control leaves, so it never falls to the page.
Everything visible is Message, SystemMessage, EmptyState, Skeleton or ChatScroll, each checked at WCAG 2.2 AA in light and dark; a list-level axe check covers a plain transcript, a bubble transcript and the empty state. Motion is theirs too: the jump control's rise collapses under reduced motion, and the list adds none.