Docs Writing
Conversation blocks
The conversation fence grammar, speaker declarations, bubble boundaries, inline markup, avatars, and layout behavior.
PlaygroundA chat thread written as plain text. In a blog post, fence it as conversation:
```conversation
you: how wide should a bubble be?
ada: 30em.
```
Outside a post, render it directly:
---
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:
```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:
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.
@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.
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:
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.
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.
@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:
@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:
@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:
@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.