Dated design · #441, shipped by #456 · 2026-09-07 · hand-written and frozen at this date; src/main.css is what runs
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.
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 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.
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.css | Value | Why | |
|---|---|---|---|
| --chat-sans | system-ui, … | travels | Already what the bubble uses. Now it is the same decision rather than the same accident. |
| body font-size | 13.5px | travels | A line the Character says gets the measure it would have had in the log. Down from the 14px #119 shipped. |
| .row.them .said | line-height 1.55 | travels | 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 | #14171e | travels | The bubble's fill. The chat is dark, so the bubble is dark — not the ramp inverted. |
| --chat-ink | #e8ebf2 | travels | Text, and the 2px ring. Opaque, so it means the same on any wallpaper. |
| --chat-accent | #5cc9b5 | travels | The thinking ellipsis, which is .caret's job in the chat: the accent on the panel, marking a turn in flight. |
| --chat-radius | 12px | travels | Identical today by coincidence. Stated, so a future design moves both or neither. |
| .composer input padding | 10px 13px | travels | The chat's inset for a bordered box holding one piece of text, which is structurally what the bubble is. |
| --chat-radius-tail | 3px | refused | .row.you .said's corner. It is the gesture for you said this, and the bubble speaks for the Character. |
| .row.you .said padding | 9px 13px | refused | 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, -soft | 2–6% white | refused | An alpha with no panel under it. Over the busy ground it is nothing at all. |
| --chat-rule / --chat-line | 7% / 13% white | refused | The ring stays 2px opaque ink. A 13%-white hairline over a photograph is not an edge. |
| --chat-accent-soft | 13% accent | refused | Same failure, and it would put a tint where the bubble needs its highest contrast. |
| .chat-ui-minimal | the root class | refused | See below. The overlay is a different document, and it never loads this stylesheet. |
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.
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.
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.