Conversation blocks

Docs Writing

Conversation blocks

The conversation fence grammar, speaker declarations, bubble boundaries, inline markup, avatars, and layout behavior.

Playground

A chat thread written as plain text. In a blog post, fence it as conversation:

Markdown
```conversation
you: how wide should a bubble be?
ada: 30em.
```

Outside a post, render it directly:

Astro
---
import Conversation from '@/features/content/ui/Conversation.astro';
---

<Conversation source={source} />

The playground’s Source is a complete Markdown fence. Copy it as-is into a Markdown editor or a Ghost Markdown card. In a Ghost code card, set the language to conversation and paste only the lines inside the outer backticks.

Thread options

The optional @conversation line controls the whole thread. It must be the first non-empty line inside the fence. conversation is reserved for this header and cannot be used as a speaker key:

Markdown
```conversation
@conversation avatars=on names=on tints=off
@gemini [Gemini] accent=#6E7FD8 tints=on
@ada [Ada] accent=#6F8F9D

gemini: My tint overrides the thread default.
ada: I inherit the neutral receiving bubble.
```
Option Default Effect when off
avatars on Hides avatars while preserving their alignment gutters.
names on Hides visible names; accessible labels remain.
tints on Leaves receiving bubbles neutral.

Only on and off are valid. Unknown, repeated, malformed, or misplaced options remain visible as prose instead of being partially applied.

Thread options are defaults inherited by every speaker. Put the same option on a cast line to override it for that speaker only; omitted speaker options keep the thread value. This precedence applies uniformly to avatars, names, and tints.

Messages

A line of the form name: text is a message. Speakers are auto-registered on first use, so the simplest possible thread needs nothing else:

Conversation
ann: is that all?
bob: that's all.

A key is one token: no whitespace, no colon, at most 24 characters. A cast line and a message head take the same one, which is the only reason @ada and ada: cannot drift apart. A name that will not fit in a token is not a key — it is a [name].

The key is lowercased for matching but shown exactly as you first wrote it, so Ann: renders Ann and ann: renders ann. The parser never restyles your name.

Both : and work as the separator, so a Chinese keyboard never has to switch.

Not every x: y line becomes a message. The head has to be a key, so So here is the thing: it works is prose, not a speaker. On top of that a head that is a URL scheme (https, mailto, tel, ftp), that contains Markdown punctuation, or that starts with @ is prose too. The same rule applies even when a cast line was written for that spelling — a bare link on its own line does not turn into a phantom speaker.

The own side

Four keys are reserved: me, you, , . Whoever speaks under one of them is drawn on the trailing edge, filled, with no name and no avatar — a reader does not need reminding what they look like.

Conversation
@Ada avatar=🐈

me: how wide should a bubble be?
ada: 30em.

me: and you: are the two framings a thread gets written in — you speaking, or the reader cast as the one asking — and 我 / 你 are the same two without leaving a Chinese keyboard, exactly as is. Pick whichever reads right; the output is identical.

Naming the side where you use it means there is no attribute for it. If no reserved key speaks, the first voice takes that side — which is why the ann / bob thread above already lays out as a conversation.

The name on the own side is still emitted, screen-reader-only. Alignment and fill are the only visible attribution and neither reaches assistive technology, so dropping the label outright would leave those messages unattributed. Give it one worth hearing with a cast line: @me [Lucian].

Runs

Consecutive messages from one speaker collapse into a run: each message keeps its own bubble, but the name is drawn once, on its own line above the first one, and only the last bubble squares off the corner nearest its speaker. The name sits beside the bubbles rather than inside them — the bubble is the message, the name is who sent it.

Conversation
grace: One thing first.
grace: A run of messages from one person is labelled once.
grace: Like this. Three bubbles, one name.

Wrapping

An indented line is a soft wrap, exactly as in Markdown — it continues the sentence rather than starting a new paragraph:

Conversation
ada: A CJK glyph is 1em and a Latin glyph about half that,
  so one number lands on ~30 Chinese characters and ~60 Latin ones.

Latin text gets a space at the seam — that space is what separates two words. When either side of the seam is CJK the join is tight: a mixed seam like 拉丁字母大约 + 0.5em wants spacing, not a character, and the thread sets text-autospace: normal so the browser draws it. The renderer never adds a character the source lacks.

A blank line ends the current bubble, so the next message from the same speaker starts a fresh one instead of continuing it. Only an indented line can continue a bubble; unindented prose and malformed syntax remain separate notes.

There is deliberately no multi-paragraph bubble. Two paragraphs is two messages, which is what people actually send.

Dividers

A line starting with --- is a divider. With text after it, it renders as a labelled beat; bare, it collapses to a single rule. Either way it breaks the current run, so the next message is labelled again.

Conversation
ann: morning
--- three hours later
ann: afternoon

Cast lines

A line starting with @ declares a speaker before they talk. Every attribute is optional — cast lines exist to override defaults, not to satisfy the parser.

Conversation
@gemini [Gemini] accent=#6E7FD8 tints=on
@ada [Ada] accent=#6F8F9D
@tutu [图图] accent=#B4603A avatar=🐈
Written as Effect
[…] Display name. Defaults to the key, exactly as first written.
accent=#RRGGBB Custom hue for the own-side fill, receiving-side tint, and name. Must be a hex colour.
avatar=… See below.
avatars=on or avatars=off Overrides the thread avatar default for this speaker.
names=on or names=off Overrides the thread name default for this speaker.
tints=on or tints=off Overrides the thread tint default for this speaker.

A value is one token: name=value, never quoted. The display name is the one thing that is a phrase, so it is the one thing that is a content block — borrowed from Typst, where […] means this is prose, not a token. Tokens do not need quoting and prose does not need escaping, so nothing on a cast line ever needs either.

The key, message head, and cast declaration use the same validator: one token, no whitespace or colon, at most 24 characters, and no Markdown punctuation. Each listed cast attribute may appear at most once.

That is the whole grammar. A cast line is a key, then at most one [name] and any number of name=value pairs, and nothing else — a stray word or an attribute that is not on the list means the line is not a cast line, so it falls through and renders as written:

Conversation
@Ada Lovelace accent=#4E7A5E

That is a key with a space in it, so it is not a key. The line appears in the thread verbatim rather than declaring ada and dropping the rest in silence.

There is no attribute for which side a speaker sits on. That is the key’s job — see the own side.

[name]

The label defaults to the key as first written, and nothing rewrites it. Capitalisation is therefore not something to declare — it is something to type:

Conversation
@Ada accent=#4E7A5E

ada: Case only matters the first time. Match it however you like after that.

Brackets are for the names a key cannot spell — a space, a script the key is not in, a name that is not the handle:

Conversation
@ada [Ada Lovelace]
@tutu [图图] avatar=🐈
@octo [Octocat] avatar=https://avatars.githubusercontent.com/u/583231?v=4

accent

The default is monochrome, derived from the site’s --foreground, and is AA in both themes by construction.

Supplying accent=#RRGGBB opts into a hue, and where it lands depends on which side the speaker is on:

Side What the accent paints
own side (me:, you:, 我:, 你:) the whole bubble, filled
everyone else a tint on the bubble, plus the name

The tint is what makes a colour worth declaring on a thread running avatars=off names=off: with no name and no avatar to carry it, the bubble is the only surface left. It stays a tint on purpose — one side filled outright is what tells a reader which way the conversation runs, and two filled sides lose that. tints=off drops it entirely.

Put tints=off on a cast line to keep only that speaker’s receiving bubbles neutral. The speaker value overrides the thread default in either direction, so tints=on can opt one speaker back in when @conversation tints=off.

The tint is not your hex mixed into the bubble. It keeps the hue, pins lightness beside the bubble’s own, and caps chroma, all in OKLCH. That is what keeps a thread even: a vivid violet and a muted sage arrive at the same weight, so no speaker shouts louder than another for a reason you did not choose. It is also the only way light mode stays clean — an accent picked as a fill is mid-dark, and mixing one into a light bubble lands on a dirty pastel every time.

A hex chosen to look good as a fill routinely lands near 4:1 when reused as name text, so the accent is walked toward the far end of the page background in 4% steps until it clears 4.5:1 — once per theme, when the conversation is rendered. You keep as much of the chosen hue as the contrast ratio allows, and no configuration can produce unreadable text.

Anything that is not a hex colour makes the cast line invalid and leaves it visible as prose.

avatar

Four forms, in the order you are likely to reach for them:

Written as Renders
avatar=https://…, /local.png, data:image/svg+xml,… <img>, lazy, referrerpolicy="no-referrer"
avatar=#sprite-id <svg><use href="#sprite-id">, inheriting the speaker’s colour
avatar=🐈 the glyph itself
omitted initials; a CJK label takes its last character

Raw inline <svg> is deliberately not a form: it would wreck the readability of a cast line, and a data: URI covers the same ground.

An external avatar still costs your readers a request to someone else’s host. referrerpolicy="no-referrer" keeps that host from learning which page they are on, but routing through the site’s image proxy is better where it is available.

Inline markup

A deliberately small subset — `code`, **bold**, *italic*, and [links](https://example.com). A conversation is dialogue; headings and lists inside a chat bubble are a sign the content belongs in prose instead. Links are limited to HTTP(S), root-relative, and fragment targets; other schemes remain literal text.

Layout notes

The bubble cap is 30em, not a pixel width. A CJK glyph is 1em and a Latin glyph roughly 0.5em, so one number lands on about 30 Chinese characters and about 60 Latin ones — both inside a comfortable measure. That is the whole trick for mixed text.

Everything reflows on container queries, not viewport media queries, so a thread in a narrow column adapts to that column rather than to the window. At container widths under 520px the avatar gutter and the far-side channel shrink; under 360px avatars are dropped and the body steps down to 15px.

The renderer translates @conversation into namespaced attributes on the thread. The playground edits that source line directly, so its switches never create a visual state that cannot be copied back into an editor.

Custom properties are namespaced --conv-*. --accent and --radius are global site tokens, and an un-prefixed name on the thread would shadow them for every descendant.