One turn in a conversation, presented according to who produced it. The three roles are not three skins of one bubble: an assistant answer is a document — prose, headings, lists, code, tables — and a bubble caps the structure it can carry, so assistant turns have no fill and no boundary. A user message keeps the bubble because a short, discrete utterance is worth separating from the answer around it. A system note is neither, and says something about the conversation rather than in it.
Three roles, one exchange. Each turn can carry a quiet __meta line — a timestamp here, a model name on the answer — which follows its turn's alignment rather than the thread's, so a stamp under an end-aligned bubble stays with the bubble. Whether it appears at all is the thread's call, since grouping is what knows when a stamp should not repeat. The answer sits on the page with no boundary, so its two paragraphs read as prose rather than as a long bubble; the questions are bubbled and end-aligned, and the longer one shows the cap — a bubble stops at min(80%, container.sm) so it always leaves a gutter and never reads as a document with rounded corners. The system note is a marker, not a turn. Every action is a ghost icon Button — the row is composition, not new chrome. Width comes from the surface, never from a Message.
A turn owns its internal spacing so the thread's gap can always be the larger break. Left alone, the host's paragraph margins were 24px against a 16px thread gap — a paragraph read as a bigger event than a change of speaker, and the bubbles looked glued to the answer below them.
| Break | Owner | Value | Because |
|---|---|---|---|
| Between block children of one turn | Message | space.3 | Pinned, so a host stylesheet cannot invert the hierarchy |
| Between turns | Message | space.6 | A speaker change is the bigger break. The thread sets no gap of its own, or the two add |
| Between consecutive turns from one speaker | Message | space.2 | Adjacency knows the previous author, so a run closes up; grouping that needs more than adjacency — repeated avatars, day separators — stays ChatThread’s |
The role decides the whole presentation, which is why it is an axis and not a colour variant. Nothing here is a fill choice: it is a decision about what kind of thing the turn is.
| Role | Modifier | Presentation | Because |
|---|---|---|---|
| Assistant | --assistant | No fill, no boundary, text.primary at font.size.copy-sm | The default. An answer is a document, and structure needs room a bubble does not give |
| User | --user | control.bg bubble at radius.container, end-aligned, capped at min(80%, container.sm) | Short and discrete — worth separating from the answer around it, which the cap is what preserves |
| System | --system | Centred text.secondary at font.size.caption | A statement about the conversation, not a turn in it |
Streaming is aria-busy on the root. A highlight sweeps across the answer, and the whole footer is withheld rather than shown disabled — there is nothing complete to copy, rate, or stamp yet, and a disabled Copy invites waiting for it to light up.
The whole footer is present in the markup and hidden by the state — the stamp goes with the buttons, because a timestamp under a half-finished answer claims the turn is done, so nothing has to be added when the turn finishes. A band of full opacity travels horizontally across the answer, so letters light up as it passes and fall back once it has gone — the text reads as arriving rather than arrived. It is masked rather than clipped: a gradient clipped to the glyphs would replace the text’s colour outright, breaking selection highlighting and anything nested that paints with currentColor. The band and the trailing LoadingDots are one gesture: the dots sit in the sweep’s path at the end of the text, so they light in turn as it passes over them and settle behind it. The sweep stops under prefers-reduced-motion, where the text rests one step quieter than a finished turn.
Metadata and controls share one line. Two lines of secondary information push the next turn further away than a turn's own paragraphs are from each other, which inverts the hierarchy above. The footer owns the alignment, so both follow their turn rather than the thread.
| Turn | Order | Overflow | Usage |
|---|---|---|---|
| User | Stamp, then actions | — | Edit and copy earn a slot; the row is short enough not to need a menu |
| Assistant | Actions, then i-ellipsis | A Menu | Copy and regenerate stay out; sources, branching, and read aloud go behind the trigger, with the stamp as the menu’s group label |
The bubble question is the one that matters. Everything else follows from it.
| Verdict | Rule |
|---|---|
| Do | Let the thread own the measure. A Message that sets its own width fights the surface it sits in, and two turns end up disagreeing about the reading line. |
| Do | Render a failed generation as an Alert inside the content, not as a styled Message — Alert already owns persistent in-flow error presentation. |
| Do | Keep the decision about identity in the thread — grouping is what knows when a stamp should stop repeating — and use __meta for where it lands. A Message that draws its own avatar makes grouping impossible; one with nowhere to put a stamp forces every thread to invent a place. |
| Do | Give every icon-only action an accessible name and a Tooltip — the name is what a screen reader announces, the tooltip is what a sighted user needs, and an icon row without both is a guessing game either way. |
| Don’t | Put an assistant answer in a bubble. It is a document — headings, lists, code, tables, and citations all stop working at the width a bubble implies. |
| Don’t | Grow the footer past two or three actions. Anything rarer belongs behind an i-ellipsis Menu, which is also where a crowded timestamp can go. |
| Don’t | Use the system role for assistant content that happens to be short. It is for statements about the conversation — a model change, a cleared history. |