Dated design · #441, shipped by #456 · 2026-09-07 · hand-written and frozen at this date; src/main.css is what runs

Buddy Bubble

The overlay's bubble drawn as a piece of the chat surface's panel, carried out onto a desktop the chat surface never has to survive. One bubble for both states, always above the head. Right is what src/main.css draws; left is the design #456 retired on 2026-09-07, kept because the comparison is the argument. Same content, same moment, three grounds.

State
Placement
Line

One bubble, dark, always above the head

Three things separate this from the first draft of this page, and each one has a consequence worth stating rather than absorbing. All three are in src/main.css as of #456, and the argument for them is ADR-0013's overlay amendment.

The bubble is dark, because the chat surface is. .chat-ui-minimal is unconditionally dark — color-scheme: dark, --chat-panel: #14171e, --chat-ink: #e8ebf2. The earlier draft inverted that ramp so the bubble would be light, which made it legible and made it a different surface. The legibility problem it was solving is real and does not go away, so it is solved at the edge instead: a 2px --chat-ink ring around a --chat-panel fill. The two fail on opposite grounds — over a blown-out patch the ring disappears and the dark fill carries; over a near-black patch the fill disappears and the ring carries — so there is no ground on which both are gone. Drive the busy row and watch the bubble cross the white disc and the black one. A drop shadow at 55% closes the mid-tone case where neither is at full strength.

It echoes the Character's turn, not the user's. In src/chat-ui.css only .row.you .said has a fill, a radius and a padding; .row.them .said — the Character's own line — is bare ink at line-height: 1.55 on the panel. The overlay bubble speaks for the Character, so it takes the Character's ink and leading, and the box around it is the panel's, not the user bubble's: --chat-panel filled, --chat-radius cornered, inset at .composer input's 10px 13px — the step the chat already uses for a bordered box holding one piece of text. --chat-radius-tail no longer travels. That tightened corner is .row.you's gesture for you said this, and putting it on the Character's speech would say the wrong thing.

Thinking is the same bubble with an ellipsis in it. No round silhouette, no trailing discs. The dots take --chat-accent, which is what .caret in the chat surface already is: the accent on the panel, marking a turn in flight. Switch between the two states above — the box does not move or change, only what is inside it does.

The ceiling case

The bubble never flips below the sprite. That removes the answer the flip was giving, so the case it answered has to be answered again: near the top of a display there is no room above the head.

Clamp and overlap, with the bubble in front. placeBubble clamps y into the display bounds, and the bubble is drawn in front of its own sprite — main.js puts it on the higher of each Instance's two levels — so this is what the code does with the flip branch gone, not a new mechanism. The bubble sits against the display edge and the art passes behind it. The tail still points down at the head, so it is never unclear who is speaking, and the bubble is transient where the sprite is constant: a line you cannot read is a worse failure than art briefly covered. The At the ceiling control puts the head 96px below the display edge — inside the 128px clearance the flip used to trigger on — and the red dashes are that edge. Left, for comparison, is the retired flip.

The bubble's position there is computed on the page from its measured height, not hardcoded, so the overlap you see is the overlap the arithmetic produces. That makes the cost visible and it is not small: switch Line between the three. A short line near the ceiling still clears the head and never clamps at all. The 6-line cap — the tallest bubble the Shell will ever draw — clamps to the edge and covers the sprite almost entirely. That is the real worst case of dropping the flip, it is on the page rather than in a footnote, and it was the part most worth rejecting if the answer had been that the art must stay visible. It shipped anyway, with that cost on the page.

CEILING_CLEARANCE in src/bubble.js existed only to trigger that flip, so with no flip it was dead, not repurposed: #456 deleted the constant, the ceilingClearance parameter and the spriteHeadY < ceilingClearance branch from placeBubble, and the clamp two lines below does the rest unchanged. Repurposing it as a minimum-gap knob was considered and dropped — nothing asks for one, and a constant kept alive for a use nobody has is how a threshold outlives its reason.

What travels, and what does not

ADR-0013 put everything the overlay draws outside the chat UI, and the reason it gave is sound: the bubble is the Character's expression over arbitrary wallpaper, not chrome inside a window. #441 narrowed that bullet, because the reason does not reach every token in src/chat-ui.css. It reaches the ones whose value is an alpha over --chat-panel, because those are not colours — they are instructions for sitting on a panel the overlay does not have. A radius, a padding step, a type size and an opaque hex mean the same thing on a photograph as they do in a window.

From chat-ui.cssValueWhy
--chat-sanssystem-ui, …travels Already what the bubble uses. Now it is the same decision rather than the same accident.
body font-size13.5pxtravels A line the Character says gets the measure it would have had in the log. Down from the 14px #119 shipped.
.row.them .saidline-height 1.55travels The Character's own turn. #119's 1.4 was tighter than anything in the chat, and the six-line cap is where that showed.
--chat-panel#14171etravels The bubble's fill. The chat is dark, so the bubble is dark — not the ramp inverted.
--chat-ink#e8ebf2travels Text, and the 2px ring. Opaque, so it means the same on any wallpaper.
--chat-accent#5cc9b5travels The thinking ellipsis, which is .caret's job in the chat: the accent on the panel, marking a turn in flight.
--chat-radius12pxtravels Identical today by coincidence. Stated, so a future design moves both or neither.
.composer input padding10px 13pxtravels The chat's inset for a bordered box holding one piece of text, which is structurally what the bubble is.
--chat-radius-tail3pxrefused .row.you .said's corner. It is the gesture for you said this, and the bubble speaks for the Character.
.row.you .said padding9px 13pxrefused Same reason. Close to the composer's step either way, so nothing is lost by taking the one that means the right thing.
--chat-fill, -dim, -soft2–6% whiterefused An alpha with no panel under it. Over the busy ground it is nothing at all.
--chat-rule / --chat-line7% / 13% whiterefused The ring stays 2px opaque ink. A 13%-white hairline over a photograph is not an edge.
--chat-accent-soft13% accentrefused Same failure, and it would put a tint where the bubble needs its highest contrast.
.chat-ui-minimalthe root classrefused See below. The overlay is a different document, and it never loads this stylesheet.

History — what #119 decided, and what #456 reversed

The speech bubble and the thinking indicator were drawn apart on purpose. src/main.css:75-117, up to #456, gave that state a round silhouette and a trail of shrinking discs, and its comment said why: the idiom is what tells a reader at a glance which of the two they are looking at, before the dots inside have moved at all. #456 reversed that on 2026-09-07, and those rules are gone from the stylesheet. One bubble, one shape, one fill, one border; the ellipsis alone carries the difference. The rationale stays on the page because it is why the current bubble looks the way it does.

What is given up is the glance. What is bought is that the Character has one bubble rather than two objects that happen to appear in the same place, and the state a user sees for two seconds before an answer is visibly the box that answer arrives in. The ellipsis is not a weak signal on its own — three animating dots in a bubble is the most legible waiting mark there is, and the chat surface already spends .caret on the same job. Judge it by switching State above and asking whether the two read as different enough.

Still open: quotation marks around the spoken line, as a second way to separate the two states without a second silhouette. Parked deliberately — it is a typographic change with its own wrap and measure consequences, and it should be judged on its own rather than smuggled in with this.

When the switcher lands

Nothing here moves. src/chat-ui.css is loaded by the chat window; the overlay is a separate document loading src/main.css, and importing the stylesheet would drag the refused tokens into the one document that must not use them. The values above are inherited once, by hand, as literals in main.css — so #348's switcher repaints the chat surface and only the chat surface. Selecting terminal log or pane of glass leaves the Character's bubble exactly as it is.

#348's out-of-scope bullet therefore stays true as written; only its reason changes, from the overlay is off limits to the switcher does not reach it. A bubble sitting over the user's wallpaper that changed identity because of a setting in another window would be a worse surprise than an inconsistency. The one thing that reopens this is v2 making something other than modern minimal the default, and that is a line in ADR-0013's amendment rather than a second decision.

What #456 did not change

Cues (cues.html, #277) are white on ink and already tuned for arbitrary wallpaper; they are untouched. The six-line cap, the durations and the grace and min-hold timers in src/bubble.js are unchanged — the only edit there was deleting the dead ceiling branch. Nothing here is per-Character or per-Instance; #119 closed that and it stays closed.