Most tutorials on building a chat interface stop at the message bubble. Two divs, a border-radius, mirror one of them to the right — done. But a bubble is a small fraction of an assistant UI. The rest is rendered markdown, tool-call cards, reasoning disclosures, code diffs, citations and a streaming caret, and almost nobody writes down how to style those or why.
Farvist ships eleven AI-interface components — the exact set the slim build leaves out: chat, prompt composer, status, prose, tool-call, reasoning, command palette, diff, suggestions, attachment and named code blocks. This is the reasoning behind them, including the bugs we shipped in v1.6.0 and had to fix six days later in v1.6.1.
Rendered markdown breaks the bubble
A message bubble should preserve the model's newlines, so it gets white-space: pre-wrap:
.message-bubble {
padding: .75rem 1rem;
border-radius: .875rem;
overflow-wrap: anywhere;
white-space: pre-wrap; /* preserve newlines from model output */
}
That is correct for plain text, which is what you have while tokens are arriving. It stops being correct the moment you pipe the finished message through a markdown renderer. Rendered HTML brings its own structure — <p>, <ul>, <h2> already carry spacing — and pre-wrap now also renders the whitespace between those tags, on top of margins the blocks already have.
The second half of the problem is headings. Models write # Heading constantly. The renderer emits a real <h1>, and your framework's h1 is display size — in Farvist, clamp(1.75rem, 6vw, 2.25rem). A hero headline inside a chat bubble, for what was a section label in a three-paragraph answer.
Both problems have the same fix: an opt-in scope you apply at the render boundary.
.prose {
white-space: normal; /* undo the bubble's pre-wrap */
overflow-wrap: anywhere;
h1 { font-size: 1.25rem; } /* was clamp(1.75rem, 6vw, 2.25rem) */
h2 { font-size: 1.125rem; }
h3, h4, h5, h6 { font-size: 1rem; }
}
Opt-in matters. The bubble cannot tell whether its children are a plain-text stream or rendered HTML — only the code that did the rendering knows. Making the reset automatic would break every plain-text message, so it costs one class:
<div class="message-bubble">
<div class="prose prose-sm"> …rendered markdown HTML… </div>
</div>
.prose-sm is the compact variant for bubbles — smaller type, tighter block margins. The same scope also handles the other thing markdown emits that framework CSS usually ignores: a bare <table> with no wrapper and no classes. We give it collapsed borders and readable cell padding, but deliberately keep display: table — changing the display type strips row and column semantics from the accessibility tree. Wide tables instead get wrapped at runtime by farvist.js, which measures each .prose table and only adds a .table-responsive scroll shell when it actually overflows.
Tool calls: the state is text, the colour is decoration
An agent's tool invocation renders as a compact card with three states: running, done, error. The obvious design is to flood the card green when it succeeds and red when it fails. Two things go wrong.
First, colour alone is not a state indicator — it disappears for a colour-blind user, in greyscale, and in high-contrast modes. Second, at the size this text actually renders (12px, the xs step) the raw semantic colours do not clear 4.5:1 on the light glass surface. So the tint rides a 3px left rail and the meaning stays in the status text:
.tool-call-status {
margin-left: auto;
font-size: .75rem;
color: var(--fv-muted); /* AA in both themes */
}
.tool-call-running { border-left-color: var(--fv-info); }
.tool-call-done { border-left-color: var(--fv-success); }
.tool-call-error { border-left-color: var(--fv-danger); }
The markup keeps a visible <span class="tool-call-status">Failed</span> in every state. Remove that span and the component is broken, which is why the source file says so in a comment above the rules.
One implementation detail worth stealing: the rail declaration has to come after the glass mixin, because the mixin sets the border shorthand and would silently reset a border-left declared above it. Nothing warns you: the Sass compiles, the source reads correctly, and the rail just renders in the wrong colour.
The argument and result panes are monospace with overflow-x: auto, which creates a scroll container a keyboard user cannot reach. farvist.js handles that: it gives .tool-call-args and .tool-call-result a tabindex="0" plus role="region" and a label — but only when they genuinely overflow, and it re-checks after webfonts settle and on resize, because overflow depends on font metrics that are not final at first paint.
Reasoning: use the native disclosure
A "thought for 12 seconds" panel is a disclosure widget. Building it from a <div> and a click handler means reimplementing button semantics, aria-expanded, keyboard activation and open state. <details>/<summary> gives all of it with no JavaScript, so that is what the component is:
<details class="reasoning">
<summary class="reasoning-summary">Thought for 12 seconds</summary>
<div class="reasoning-body">…model reasoning…</div>
</details>
The styling is mostly about hiding the default marker and drawing our own. The chevron is a pseudo-element with two borders rotated 45 degrees — no icon font, no sprite, and it inherits currentColor so it follows the summary's hover state for free:
.reasoning-summary {
list-style: none;
}
.reasoning-summary::-webkit-details-marker { display: none; }
.reasoning-summary::before {
content: '';
width: .55em; height: .55em;
border-right: 2px solid currentColor;
border-bottom: 2px solid currentColor;
transform: rotate(-45deg);
transition: transform .2s ease;
}
.reasoning[open] > .reasoning-summary::before { transform: rotate(45deg); }
Honest limits: you still need both marker-removal declarations, and we do not animate the panel opening. Height transitions on <details> are not reliable enough to ship as a default, and a disclosure that expands instantly is not a worse disclosure. The same pattern powers the collapsible tool-call card — summary.tool-call-header gets a focus ring and the marker treatment, so a tool call can be a <details> without any new classes.
Diffs: keep the plus and minus in the content
The diff view is where AI coding assistants live, and it is the clearest case of the colour-alone rule. Our added and removed rows carry background tints, but the + and − are characters inside the line, not decoration generated by CSS:
<pre class="diff-body"><code><span class="diff-line diff-hunk">@@ -1,3 +1,3 @@</span>
<span class="diff-line"> :root {</span>
<span class="diff-line diff-del">- --fv-primary: #6d4af5;</span>
<span class="diff-line diff-add">+ --fv-primary: #10b981;</span></code></pre>
That buys two things. The diff still reads in greyscale, and selecting the block copies a real patch instead of lines stripped of their markers — a ::before would have looked identical and produced unusable clipboard text.
We got the colours around that content wrong. v1.6.0 painted the +N/−N stats and the @@ hunk line with the raw semantic tokens. On the light theme's glass surface, at 12–14px, those measured 1.6–2.9:1. v1.6.1 darkened them toward the light body text, which lands them at 5.7–8.1:1 — the same convention alerts, badges and chips already used:
.diff-stat-add { color: var(--fv-success); }
.diff-stat-del { color: var(--fv-danger); }
/* $body-text-light is #0f172a */
[data-theme='light'] .diff-stat-add {
color: color-mix(in srgb, #0f172a 55%, var(--fv-success));
}
[data-theme='light'] .diff-stat-del {
color: color-mix(in srgb, #0f172a 55%, var(--fv-danger));
}
The hunk line got the same treatment and also lost an opacity that had been degrading re-branded palettes. The row backgrounds stayed faint on purpose — 14% and 12% color-mix tints — because they are the redundant channel, not the primary one. The header picked up a fix in the same release: a long unbreakable file path used to push the stats out of the clipped box, so it now wraps.
Streaming: a caret, a race, and two paths to the same DOM
The caret is one class you add to a bubble while tokens arrive. It is a painted box rather than a ▍ glyph, specifically so screen readers have nothing to announce:
.streaming::after {
content: '';
display: inline-block;
width: .5em; height: 1em;
background-color: var(--fv-primary-text);
animation: fv-caret 1s linear infinite;
}
/* Hard on/off — steps() on opacity only half-blinks. */
@keyframes fv-caret {
0%, 49.9% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.streaming::after { animation: none; }
}
Under reduced motion the caret stays and stops blinking — the "output is live" signal survives, the flicker does not.
The JavaScript side, Farvist.stream(el, text), is where we shipped a real bug. v1.6.0 typed text word by word on a timer with no notion of ownership, so a second call on the same element interleaved with the first and produced garbled text. Any UI where a message can be re-streamed before the first run finishes — regenerate, edit-and-resend, a retried request — hits it. The fix is a WeakMap run token:
var streamRuns = new WeakMap();
function stream(el, text, opts) {
var token = {};
streamRuns.set(el, token);
// …
(function tick() {
// Superseded by a newer stream() on this element — stop silently.
if (streamRuns.get(el) !== token) { resolve(el); return; }
el.textContent += (i ? ' ' : '') + words[i];
i++;
if (i < words.length) setTimeout(tick, delay);
else finish();
})();
}
Each call mints a fresh object as its identity and claims the element. Every tick checks whether it is still the owner and stops if not. A WeakMap keyed by the element means a removed message node takes its entry with it. The loser resolves rather than rejecting, because being superseded is not an error and callers awaiting the promise should not have to catch.
The other half of the same patch is subtler and matters more. The reduced-motion path and the animated path have to end in the same DOM:
if (reduced || opts.instant) {
el.textContent = String(text); // render instantly
finish();
return;
}
el.textContent = ''; // …then type into an emptied element
el.classList.add('streaming');
Originally the animated path appended to whatever was already in the element while the instant path replaced it, so the two branches disagreed. Bugs shaped like that only appear for users who have reduced motion enabled — exactly the population absent from your screenshots and your manual testing. The reduced-motion path is not a degraded path; it is the same result without the animation.
Two honest limits. stream() writes textContent, so it cannot stream rendered markdown; for markdown you stream plain text and swap in the .prose HTML when the message completes. And it types a string you already hold — it is a presentation helper, not a transport. Real tokens come from your network layer; the caret class and the DOM discipline are the parts that carry over.
The parts that only need a paragraph
Citations. .cite is a superscript pill link, .sources is the ordered footnote list it points at — the RAG answer pattern. The chip stays a real link with a visible focus ring, and the number stays as text so the reference survives copy-paste.
Suggestion chips. .suggestions wraps a row of .suggestion elements that work equally on <button> or <a> — the "try asking…" row every assistant opens with.
Attachment pills. .attachment holds an icon, .attachment-name, .attachment-meta and a labelled .attachment-remove. Its truncation was inert for a release: max-width: 100% resolves circularly inside content-sized flex parents like .prompt-actions, so the ellipsis never triggered. A concrete cap — min(100%, 16rem) — fixed it.
Status. .status is a text label with a coloured dot drawn as an empty pseudo-element. Same principle as the tool call: the dot is redundant, the word is the state. Online and busy pulse a fading ring; reduced motion turns it off.
Prompt composer. .prompt-field uses field-sizing: content to auto-grow between 2.75rem and 12rem. That is progressive — where it is unsupported you get a fixed-height textarea that scrolls, which is an acceptable floor. The field's own outline is removed and the ring moves to the wrapper via :focus-within, so the whole composer lights up as one control.
Named code blocks. .snippet-header is the filename/language bar assistants put above generated files. It carries padding-right: 6.5rem for a boring reason: the copy button gets wider in its "Copied ✓" state and used to overlap the filename.
These are conventions, not standards
Nothing above is specified anywhere. There is no ARIA pattern for "a tool call", no element for "model reasoning", no agreed markup for a citation chip. We picked names that read like the thing they describe and behaviours that fail safely, and in two years some of it will look dated — the interaction patterns in this space are moving much faster than the vocabulary. Treat the class names as a starting point, not a spec.
What should outlive the specifics is smaller and duller: put state in text, keep meaning-bearing characters in the content, reach for the native element before writing a widget, and make your reduced-motion path produce the same DOM as your animated one. None of those are chat-UI rules. They are ordinary accessibility practice, applied to a surface new enough that people keep forgetting to apply it.
All eleven components are in the docs; six of them — chat, prompt, prose, tool calls, reasoning and status — are assembled into a working screen in the AI console template. The kit is also available on its own as farvist-ai.min.css (6.4 KB gzip) if you want to add an assistant UI to a site that already has its own CSS.