Dialogue · Design notes · Style Guide
← Design notes

Style Guide

Every part of the live Chat UI — its name and its current design, as of live v422 (new-chat is now two dialogs — the “Call the panel” pill opens the “What’s on your mind?” describe→call flow, while the top-right new-chat button opens a separate “Hand-pick your panel” dialog with a skeleton “add a persona” seat-card and an auto-call escape hatch; the add buttons turned coral and now hide at the panel cap; the “Add to your panel” overlay floats the curator’s picks (with their “why”) to the top; the WeChat-style landing — the chats list is home, with a bottom composer that flies your text into the new-chat dialog and auto-calls the panel, and relative timestamps on every row; the smart panel selector — describe a situation → curated panels, hot vs cold start; every room speaks one target language (from your describe box, or your account default), and a host who slips to another script is shown a faithful translation under a hairline in the same bubble; colour is a per-room seat, not an identity; the Notebook, multi-human typing + asides, all seven artifact kinds incl. diagram, the skeleton + instant chat-switch; Back / the OS back-gesture peels the open drawer·sheet·dialog and (narrow) steps a chat back to the list before leaving, with a cold relaunch landing on the chats list; the landing composer collapses to a quiet “Call the panel” pill once a chat is open on wide; the Notebook delete is a two-tap red confirm). When we use these — “the egg”, “the join capsule”, “the infinity button” — we mean the same thing. Each card renders the real component. Use the theme toggle to see both palettes (light · dark — the app has no e-ink). This guide now also documents the social layer — Vibes · Moments · Seen · notifications · select & forward — added since v390, and a Foundations section marking every ♺ theme.css reusable module.

House rule worth restating here: colour is the seat — a persona has no intrinsic colour; each voice is handed a per-room seat colour (rotated by room), so the same persona may wear different colours in different rooms while a cast always reads distinct. Grey is system (the egg, telemetry, the leave capsule, your own bubble): if a thing is grey, it isn't a character.

Foundations the shared layer — tokens · scales · reusable modules

Palette, fonts, the scale tokens, and every once-defined component live in lib/theme.css, linked before each page's own styles with a ?v= buster (bumped alongside BUILD whenever the file changes). Anything marked ♺ theme.css is a reusable module — defined once, consumed everywhere; page CSS adds only page-specific tokens and z-ladders. This guide mirrors the values inline so it renders standalone.

Radius

--radius-s
8px · chips · inputs
--radius-m
10px · buttons · cards
--radius-l
12px · popovers
--radius-xl
16px · dialog card
--radius-pill
999px · pills

Space · avatar · shadow

--space-1…54·8·12·16·24px
--av-xs…xl24·30·40·64·84px
--shadow-1
--shadow-2
--shadow-3

Seat palette ♺ theme.css

Colour is a per-room seat, not a persona's identity — the room rotates this 8-slot palette (OKLCH-spaced) so a cast always reads distinct, and the same figure can wear a different colour in another room. Slot 0 is coral; each has a -soft tint for backgrounds. Grey (--egg) is system.

seat-0
seat-1
seat-2
seat-3
seat-4
seat-5
seat-6
seat-7

Reusable modules ♺ theme.css

The components below are defined once in the shared layer; a card elsewhere in this guide carries the badge when it renders one of them.

#moduleclassesused by
#22dialog close / back.dialog-x · .dialog-backevery dialog · sheet · drawer
#22bdismissal grammar v516–517.modal-head.hbar · .dialog-x.task · .dialog-back.corner · .dialog-commit · .hide-narrowphone-only, opt-in — see the four types
#23search chrome.search-box · .search-x · .ps-suggestall three persona search boxes
#25dialog scaffold.modal-scrim/.modal · .vp · .mep — the 580 card + the .40 scrimmain & sub dialogs · Me sheet · profile · survey
#26buttons.btn-ghost · .btn-primary · the coral CTA baseevery dialog action · Call pill · Post · Done
#28pill family.lib-mine (the spec other pills follow)library filters · toggle chips
#29popover engine.popmenu · .rowmenu · .cardmenu⋮ menus · model switcher · quick actions
#30toast.toast (+ the --toast-* tokens)every transient notice
palette · fonts · scales · seatsthe token blocks abovethe whole app + the embedded Studio iframe

The screen layout regions, wide view

chats pane persistent left column (wide)
full-screen root (narrow)
‹ back Title
the stream
topbar parts: back button · chat title + chevron · infinity button (becomes while the info screen is open)  ·  bottom = composer. The bar + composer are frosted glass — the stream blurs as it scrolls beneath them.

Topbar & chrome

back button
aka: back
Narrow only. Slides the chat window back to the full-screen chats list. Hidden on wide (the chats pane is always there).
‹ Back peels one layer
Back gesture
aka: hardware Back · iOS edge-swipe · Cancel
Android hardware Back and the iOS edge-swipe both close the topmost open layer before leaving the app — a dialog (= Cancel), a sheet, a drawer (wildcard / confer), a popover or menu — most-recently-opened first (a nested popover before its parent sheet, the library overlay before its picker). With nothing else open, on narrow Back steps the open chat back to the chats list; only then does it exit. A cold relaunch lands on the chats list (not the last chat), so Back from there exits cleanly. The platform's own back animation plays — the app suppresses its own slide so the chat pane doesn't double-animate.
‹ Notes · Chats · Studio · Vibes ›
swipe between tabs
aka: tab swipe
Narrow only. A horizontal swipe on the landing steps between the four tabs — swipe left for the next (Notes → Chats → Studio → Vibes), swipe right for the previous — and the new pane slides in from the swipe direction. It works over Studio too, even though that tab is an embedded page (the gesture is bound to the builder frame's own document). Vertical scrolls and screen-edge swipes (the Back gesture) are left alone, and the ends are hard stops (no wrap-around). Same outcome as tapping a tab, just by gesture.
Three flats — which appreciates?
chat title + chevron
aka: the title, the chevron
Centred in the topbar. Tapping it grows the title bar into a full-screen chat-info screen (Telegram-style) — the title flies down + grows into the hero, and this small title hides while open.
Three flats — which appreciates?
5 personas · 2 people · DeepSeek v4 Pro
Cost$0.480
Current Cache 41.8k tok
Д
+
chat-info screen
aka: the info screen, the grown title bar
Grows out of the title bar into a full-screen sheet (Telegram-style) — one flat surface, no floating card; it covers the chat's title bar and carries its own header bar — one continuous block, no top hairline. A centred hero leads: the room's own 56px tile (v534 — the same face / seat disc / group-glyph-over-a-blend the chat list and title bar wear; it replaced a generic , which now shows only when no room is open), the enlarged chat title (the bar's own small title hides while grown, so it reads as moved-down + grown), and a N personas · N people · Model line (the Model here is the current model used — an admin can hide it under Who sees what). A small ✎ pencil at the hero's right edge inline-renames the chat — tap → edit in place → Enter/blur saves, Esc discards (it fades with the title on scroll). Below: the Cost + cache bar, the model switcher + theme, the read-only Persona vividness notches + Floor producer row, the members grid, and the people list + add-a-person. Its own header bar carries ‹ back — one way to retract — and a (placeholder), hugging the sheet's left/right edges (on wide, not the full-width topbar). Tapping the title also retracts — the big hero title, or (once scrolled) the collapsed bar title, closes the sheet with the same fly-back as ‹ back. On desktop the sheet caps to the composer's max width (790px), centred. Opening animates — the bar title flies down + grows into the hero; closing reverses it (the title shrinks back up into the bar as the sheet collapses upward). When the sheet is tall, scrolling collapses it Telegram-style — the ‹/⋮ bar sticks (gaining a hairline) while the big title shrinks + rises into the bar's centre at the original size.
infinity button
aka: the , the wildcard button  (was the “meatball” ⋯)
Top-right, borderless. Opens the wildcard drawer from the right, which collects artifacts the panel emits. A red dot appears when a new artifact lands while the drawer is closed. While the chat-info screen is open it's replaced in place by a (more — placeholder for now).
wildcard
DOCRevised proposalcopy
from Li

Three flats, one buyer — here's the revised split…

wildcard drawer
aka: the ∞ drawer, the artifact pane
Right-side drawer opened by the infinity button. Collects every artifact a host hands over — document · code · table · image · note · diagram · web (the seven kinds) — as cards, newest filed in. Also home to this chat's saved lines — an All · Artifacts · ✦ Saved kind-filter is the only control (the old This-chat/Notebook scope toggle is gone; the global Notebook is the Notes tab). Mutually exclusive with the confer drawer (opening one closes the other).
chats
chat window
chats pane
aka: “your chats”, the conversations list
All your conversations. A persistent left column on wide; the full-screen root view on narrow (tap a chat → the chat window slides in from the right). It rides the base app surface (not a raised drawer), reading as home — acting on a row (delete/archive) keeps you on the list on narrow, never sliding a chat over it. On wide the column is resizable: drag its right edge (a coral hairline appears on hover) between the default 363px and +50% (545px); double-click resets. The width is remembered per user and restored before first paint.

Chats pane rows · rename · fork · archive · trash

Three Shanghai flats — which appreciates?
14:32
Li · Doronin
with Dan, Mary
Where to retire on $1.2M?
Yesterday
Xiao Pang
chat row
aka: a chat item, a conversation row
A 46px avatar leads the row (v531, resized v532 — 2½ text lines, top-aligned with the first two lines so a third "with …" line never shoves it off the title; the row's left inset halves to 12px to pay for it, and the text block is unchanged beside it). The same tile follows the room (v534): 22px beside the chat title and 56px in the chat-info hero, where it replaced the generic coral ✦. One rule sized by --avs, so a room reads identically in all three places. A single party keeps its own face: a 1:1's counterpart (resolved from the immutable pair binding, so it shows even before they've been pushed the chat), your own notes, or a solo persona's seat disc. Every group takes the same plain two-person glyph, and the disc says who is inside — a linear blend of the room's seat colours, humans contributing the muted grey (colour stays reserved for AI): flat grey = humans only · colour into colour = a panel · colour into grey = mixed. The blend uses the same seat assignment as the meta line, so the tile matches the names beneath it. Study + decision: Chat-list avatars.

Bold title + a meta line of the panel — each name in its seat colour; a solo chat shows the persona's full name, a panel their short known-as labels (no turn count). A ⋮ more button sits on the right (hover; always on touch). The open chat wears a soft grey fill + coral spine on its left edge; rows are divided by a weak hairline, not boxed as cards. An unread chat shows a red dot on the avatar's top-right corner (v532 — WeChat's badge, ringed in the pane colour; it marks the chat, not the title, and it cleared the title line's width. Cleared on open; the Vibes folder row keeps an inline dot, having no tile); one still conferring elsewhere gets a soft grey pulse. A multi-human room adds a muted-grey line naming the other people ("with Dan, Mary"). The row's right edge shows a WeChat-style relative time (last activity): a clock for today, Yesterday, a weekday within the week, then M/D — and M/D/YY once it's past this year. Opening is instant — title, spine, dot paint before the wire; cached fills at once, uncached shows the skeleton.
⋮ more menu
aka: the row menu, the kebab menu
Opens from a row's . Four actions in light-separated groups: Rename · Fork · Archive · Delete (red). Archive sets the chat aside, Delete sends it to the Trash can — both reversible; Fork clones the chat, Rename retitles it.
Fork
aka: clone, branch a chat
A ⋮ menu item. Clones the chat to “<name> (fork)”, carrying its model, language, cast, and the whole conversation — then opens the copy. The original is untouched; the two diverge from here.
Rename
aka: retitle from the list
A ⋮ menu item — retitle any chat from the list (not just the open one). Prefills the current title; saving updates the chats list and, if it's the open chat, the topbar too. (The ✎ pencil on the chat-info hero does the same for the open chat.)
Archived & Trash
aka: the storage rows
Two rows — Archived and Trash can — in the Me sheet's Account card (the ≡ menu); each opens the storage modal on its tab, stacked over the sheet. (Was a standalone chats-pane foot button; the foot is now the Call the panel composer, and the build tag sits under the chat rows.)
Old draft — physics tangent
Einstein · Mary · 1 turn · 25m ago
RestoreDelete
Archived
aka: the archive, set-aside chats
A modal listing chats you've archived. Each row: Restore (back to the list) or Delete (on to the Trash can). Reached from Archived & Trash in the account menu.
Scratch chat
Shane · 1 turn · 2d ago
RestoreDelete forever
Trash can
aka: trash, deleted chats
Deleted chats wait here — still recoverable with Restore. Delete forever (red, confirmed) is the only permanent erase; an Empty trash action clears them all at once.
Einstein vs Bohr — the dice
Einstein · Bohr
conferring dot
aka: the busy dot, the background-turn pulse
A soft grey pulse on a chat row whose panel is still conferring while you're looking at a different chat — it clears when that turn lands. System grey, like the egg: it belongs to no character.
compose button
aka: start a chat, the new-chat icon
The compose icon (a message bubble with a +) at the top-right of the chats-pane head bar. Opens the new-chat picker — describe a situation for a curated panel, or hand-pick a cast (with model · language · dials) before the room opens. (The describe-a-situation hot start also rides the bottom Call the panel composer.)
head tabs
aka: Notes · Chats · Studio · Vibes
Four tabs in the chats-pane head bar: Notes · Chats · Studio · Vibes. Chats is home (the conversation list). Notes opens the global Notebook — every line you've saved across all chats — as a full landing pane (it was a foot row opening the wildcard pane). Studio embeds the persona builder. Vibes is the typographic poster feed. On narrow, a horizontal swipe steps between them (see swipe between tabs). Studio is role-gated, so it may be hidden — the swipe then skips it.
menu button (≡)
aka: the hamburger, the user menu, the Me sheet
The ≡ hamburger at the top-left of the chats-pane head. Opens the Me sheet — the personal page as an instant overlay (main-dialog grammar: a centered 580px card over a dim scrim on wide, full-screen on narrow; a ‹ back at the bar's top-left — the chat topbar's back button, sitting exactly where the ≡ that opened it was — plus the ×, back-gesture aware). No navigation, no load: it paints from memory + the cached profile, then a fresh /api/me/profile repaints behind it. Top-down: the hero (avatar · name with a rename pencil · @username · role chip · member-since) → a 4-stat strip (chats · turns · personas · days) → Admin (admins only: the console link) → Energy (the daily/weekly spend-cap bars — hidable per role via the console's Who-sees-what) → Last fortnight (14 activity bars) → Personas you've met (a leaderboard; each row opens that persona's profile) → Preferences (Light/Dark, Serif/Sans, text size A·A·A, Enter-to-send on mobile, the themed Default language dropdown — OS-seeded for a new user; it sets both the app UI language and new chats’ panel language (one setting, two axes — i18n.js, English-as-key)) → Account (change password · Archived · Trash can · Devices with Log out others · Invite code for admins) → a quiet red Log out. Controls render in-theme — no native OS pickers; Text size scales the composers too.
live v390 · the field-label family reads 11px everywhere · the shared layer: theme.css (tokens + dialog scaffold + × + pills + popovers) and fmt.js (one time/token/money voice) · profiles open full-screen instantly, fade-only, chromeless scroll · every picker card: ⋮ → View profile · chat-info quick actions [Theme][Mute][Fork] · UI speaks the account language (i18n.js)
build badge
aka: the build tag, the version string
Under the chat rows (bottom of the pane): the running live vNN string with a one-line note on the latest ship. Bumped on every UI deploy — the quickest check that a new build actually reached your browser.

Landing composer a “Call the panel” pill → the composer → flies into the dialog

Call the panel
“Call the panel” pill
aka: the rest state, the call button
The landing's rest state: rather than an always-open box, a single coral “Call the panel” capsule wearing the → into a trio glyph. Tap it and it shrinks to a circle at its centre as the glyph turns 90° counter-clockwise (the → and its trio rotate into ↑), the real composer (next) fades in, and that circle flies straight across into the send slot (the pill sits at the send button's height, so the travel is horizontal). Once it lands the textarea takes focus (caret + keyboard). On PC/Android focus is deferred to the landing, so the keyboard never rises mid-flight; on iOS — which only opens the keyboard from inside the tap gesture — focus fires in the tap, so the keyboard rises as the circle flies (the iOS flight is shortened to ~half so it lands about when the keyboard settles). Left empty and tapped away, it folds back to the pill. (Reduced-motion skips the flight and focuses at once.)
What's on your mind?
landing composer
aka: the bottom box, the situation composer
Pinned at the bottom of the chats pane (the landing's primary entry), revealed by tapping the “Call the panel” pill (above). A Gemini-style box that grows 1→4 lines then scrolls; its border is the “coral flow” — a slowly rotating coral→gold gradient (light mode adds a thin glow). The send is a flat coral disc wearing the panel-star (same glyph as Call the panel) — drop-shadow, flat face. It doesn't post a message: it opens the new-chat dialog and flies your text into the situation box (next).
Call the panel
call pill (muted)
aka: the muted “Call the panel” pill
Wide only. Once a chat is open, the call composer steps back to the very same “Call the panel” pill as the landing — same capsule, same → -into-trio glyph — only its colour is muted (a soft-coral tint) so the chat stays the focus and it never reads as a second reply box. It warms back to the full coral pill on hover. Tap it and it runs the very same puck-flight as the landing pill — shrinking to a circle, the glyph turning → into ↑, gliding into the send slot as the composer fades in — then the prompt takes focus; it re-folds when left empty. The landing (no chat open) keeps the coral pill; on narrow the call box is hidden entirely while a chat is open. (The top-of-pane + New chat stays the hand-pick path — distinct from this describe-a-situation hot start.)
composer text situation box
fly to the dialog
aka: the compose fly, the hand-off
On send, a ghost of your text lifts off the composer and glides into the new-chat dialog's situation box as the dialog fades in (no slide). It grows to the box's width mid-flight so the text reflows smoothly instead of jumping, lands scrolled to the end with the caret placed, then auto-fires Convene with a quick button-press pulse — a composer launch arrives with Convene locked in. (Admins can flip an auto-fire switch off to rehearse the transition without spending an LLM call; the press still plays. The switch is enforced server-side, so it takes effect for every user at once.)
one line
two lines
three lines
four lines
always-visible scrollbar
aka: the custom thumb
Past the 4-line cap, both the landing composer and the dialog's situation box draw a small always-visible thumb in the right gutter. A textarea's native scrollbar is a fading overlay (no persistent track on Chrome; iOS ignores ::-webkit-scrollbar there entirely), so it's hidden and this JS-positioned thumb is drawn instead — visible on every platform, and only while the text overflows. In narrow mode, the in-content scrollbars (code blocks, tables, artifact bodies, diagram sources, rich quotes) are hidden — the content still scrolls, the bars just don't clutter a phone column.

The stream messages & turns

Li Ka-shingmagnate

Keep some back, whatever you choose.

persona bubble
aka: a character / AI bubble
A character's message — left-aligned, tinted in that character's seat colour (avatar + name + a left colour-bar).
DDan

Does the newest one have a metro line coming?

Which of the three appreciates most?

Yyou
human message
aka: the human bubble · the user monogram
De-emphasized on purpose — quiet neutral grey (keeps the eye on the panel, WeChat-style). Your own line is right-aligned with the monogram + “you” UNDER the bubble; another human’s line is left-aligned with their monogram + name ABOVE it, like a persona. The monogram is each person’s default avatar — a faint greyscale hashed Bauhaus wash (seeded from the user id, so it’s unique even when two share an initial) under their serif initial. The one per-user identity mark, kept colour-free so the seat palette stays the personas’. Also shown in the people list + invite picker. Since v468 an uploaded profile photo layers over it everywhere (→ User profiles); the monogram stays the forever fallback.
Euler is weighing in…
8.4s
the egg
aka: the conferring bubble, the thinking bubble
The placeholder shown while the panel confers — breathes + soft-pulses, grey (belongs to no character). The label is upright serif (not italic) and rotates through canned lines on a slow cross-fade (≈2.6s — fast alternation read as fake); a host's name wears its seat colour. The elapsed timer counts from the turn's real start and survives a room switch (no reset).
ringing the newsroom…
3.1s
search bubble
aka: the assistant fetching, the robot egg
Shown while the @Web Search fetches a web dispatch. Same breathing shell as the egg, but it belongs to a robot — so it reads in the mono face (JetBrains Mono, not the personas' serif) and rotates "newsroom" phrases ("ringing the newsroom…", "scanning the headlines…"). Its own elapsed timer, also switch-proof. Replaced by the dispatch card when the brief lands.
egg-hatch
aka: the hatch, the reveal
The resolution: the egg grows into a shell the size of the turn, the replies fill in one by one, then the shell dissolves.
skeleton
aka: the loading shimmer, ghost bubbles
Shown while an uncached chat's history crosses the wire (live v81). The chrome paints instantly — title, the active row's border, the cleared unread dot — because the chats pane already holds them; only the message body, which has to be fetched, gets grey shimmer bubbles mirroring the persona / you layout (so nothing jumps on swap). A cached chat skips it (the stored snapshot paints at once), and a fast fetch never flashes it (a ~160ms delay-guard) — it earns its keep on the China→Singapore hop, where the round-trip is real.
confer capsule
aka: “view confer”, the deliberation chip
A small chip under a turn. Opens the confer drawer (from the left) showing the panel's internal discussion before they answered. Only on turns that conferred — a direct answer (solo chat, an @name reply, or the opening) has no backstage confer, so it shows no capsule.
the question(s)
“Okay, here's the full picture so you can weigh in properly. I have three flats shortlisted — one in Xuhui near the river, one in Pudong that's brand new, one in Jing'an that's small but central. My budget is tight after the down payment…”
Show more
confer drawer
aka: the deliberation panel, the confer slide-out
Slides in from the left when you tap a confer capsule. Header is the question(s) — the prompt that triggered the turn — over their internal discussion. A long prompt folds to 4 lines with a Show more / Show less toggle so it never fills the header.
⚙ cache write · 8.7k tok · $0.040 · 8.8s
cost boundary
aka: the telemetry pill, the cost line
System telemetry under each turn — token count, cost, and the generation time (· 8.8s under 10s, whole seconds above). The prefix flags a cache write (this turn wrote more prefix than it reused — a cold or TTL-expired prefix, a cast change, or a model switch). The time is the panel's wall-clock for that turn — a quick way to read the fast vs (reasoning) DeepSeek gap.
resumed — the panel remembers
note line
aka: a system note
A centred grey timeline marker for system events (e.g. resuming a saved chat). Lowercase, flanked by rules. An err variant turns coral, flanked by !!! — it carries failures the room must never swallow, e.g. "the panel didn't respond — try sending again".
✦ Web Search

Shanghai eases home-purchase rules

On June 3, Shanghai lowered the down-payment floor for second homes to 30%…

Sources: Caixin · June 3 · Reuters · June 4

dispatch card
aka: the web fact-brief, the @Web Search card
The result of @Web Search pull the news about X (live v47): while fetching, a search bubble breathes in the stream (assistant-grey egg shell, rotating newsroom phrases — “flipping through the wires…”); then the neutral, dated, sourced fact-brief lands in-stream — visible to everyone — followed by a search-cost pill that breaks out the search-API fee from the total (✦ web search · 1 query · $0.027 · incl. search $0.010; a SERP engine names itself: ✦ web search · serper · 1 query · $0.003 · incl. search $0.001) — the $total is the whole dispatch (brief model + search fee) and it is counted in the room total, the per-model split, the per-user cap and the admin usage totals; only the pill and the boundary capsule keep dispatch spend on its own line to avoid double-display. The brief also files into the ∞ pane as a WEB-tagged artifact (red dot + pulse). The conferring egg and the hosts' reactions come after — the hosts see the brief in context before they reply. System grey with a ✦ head (Web Search is not a host). Correspondent: admin-picked engine (console → Models) — Haiku 4.5 + native web_search (max 1 search), or DeepSeek v4 Flash + a search API routed by query language (English Serper→Brave · Chinese Bocha→Serper→Brave).
Dan is typing…
typing line
aka: the presence shimmer, "X is typing…"
A live presence line while another human in the room types — their name with a soft left→right shimmer. Ephemeral: RAM-only, never persisted, driven by a debounced heartbeat; it fades when they send or fall idle, and coalesces several typers ("Dan, Mary are typing…"). Never the panel.

@Mary ignore the hosts a sec — did you call the bank?

You → Mary
aside
aka: an @human side-note, the panel-skip line
A line addressed only to another human (an @person mention, no host): it posts in the stream for everyone, but the panel does not answerno turn, no egg. A side conversation the hosts simply overhear (they catch up on their next real turn). @Web Search is never an aside.
Mary

I'd hold the Pudong one — newer build, better resale.

other-human bubble
aka: a co-human's bubble
Another person's message in a multi-human room — left-aligned with a neutral theme-grey accent bar + their name (colour stays reserved for AI). Distinct from your bubble (right, grey, no name) and a persona bubble (left, seat colour).
rating rail · check-in
aka: the thumbs, the feedback row
A quiet 👍 · 👎 · ⌄ row inside every persona bubble, right-aligned under the text. The glyphs are small but the tap target is invisibly enlarged (~32×38, v367). The ⌄ folds out lever tags (length · tone · content) plus an optional note, and a periodic check-in survey card asks for a fuller read — all of it feeds the floor producer’s guest-feedback brief. Hidable per role via Who-sees-what.

Rich rendering how a host's words are drawn (v52+)

Terence Tao

Two cases, then the bound:

  • the even split
  • the odd split
rendered bubble
aka: rich speech, markdown bubble
Host replies render full Markdown now (was inline-only): lists, tables, headings, blockquotes, bold/links — plus math and code below. One shared renderRich() pipeline draws bubbles, the artifact pane, and dispatch briefs; a final sanitize pass (DOMPurify) and a scrub guarantee a raw <speak> tag never reaches the screen.
Confucius

名不正,則言不順。


If names are not correct, language is not in accord with the truth of things.

translation bubble
aka: the hairline translation, the divergence net
When a host slips out of the room's target language (a cross-script miss — Chinese in a French room, say), the room keeps the line and draws a faithful translation below a hairline in the same bubble — original ─ rule ─ translation, formats intact. It's persisted with the message (survives reopen), ✦ Save works on either half, and Notes go-to-line can land on it. The panel's own memory keeps only the translation, so the room converges on the target. See Room language.
pythoncopy
import math
total = cross_section * age
code block
aka: the cb block, fenced code
A fenced ```lang block renders with a language label, a copy button, and highlight.js syntax colours mapped to the theme palette (so they adapt across light/dark/e-ink). Shared by chat bubbles and the artifact pane. Hosts are asked (in the SP) to fence code, or Markdown eats the underscores and asterisks.
e + 1 = 0
math
aka: KaTeX, the equation
KaTeX renders LaTeX — inline $…$ or \(…\), displayed $$…$$ or \[…\] (vendored same-origin, lazy-loaded). A single $ is math only by delimiter pairing (close-$ tight against non-space, not followed by a digit), so $x=\pi$ renders while $5 and $10 stay money. \$ is a literal dollar.

Wildcard & artifacts non-dialogue blocks the panel hands over

DOCRevised proposalcopy
from Li

Three flats, one buyer — here's the revised split and the exit timeline…

artifact card
aka: a wildcard card
A non-dialogue block a host hands over — captured into the wildcard drawer as a card: a colour-coded kind tag, the title, a copy button, a from <host> line in the host's colour, then the rendered body. The kind is inferred from the content (model-agnostic), so it only styles the card — a mis-read block still survives as readable markdown. Seven kinds →
DOCCODETABLEIMGNOTEDIAGRAMWEB
artifact kinds
the seven tags
DOC (prose) · CODE (a fenced block + its language) · TABLE · IMG · NOTE (a short aside) · DIAGRAM (a Mermaid figure, rendered live) · WEB (an @Web Search dispatch — system grey, not a host). The first six are the panel's own handovers (DIAGRAM draws as SVG); only WEB is server-fetched — the @Web Search, not a host.
DIAGRAMDiagramcopy
diagram
aka: the Mermaid card, a figure
A host hands over a Mermaid diagram — a ```mermaid fence, or a block the parser detects as Mermaid (a flowchart / sequence / gantt keyword). It renders as a real SVG in the wildcard pane (vendored Mermaid, lazy-loaded), contain-fit to the card with tap-to-zoom (panzoom). The bubble's link chip and the pane card show the same DIAGRAM tag. Invalid Mermaid falls back to its source — never an error bomb.
link chip
aka: the artifact link, the jump chip
A small chip inside the host bubbleDOC  Revised proposal  → — that jumps to the artifact in the wildcard drawer (and flashes it). The dialogue stays clean; the heavy block lives in the pane.

Notebook & saved lines clip a line from any chat

✦ Save line
save-line pill
aka: the ✦ Save-line pill, the selection pill
Select text inside a host bubble → a rose "✦ Save line" pill appears at the selection. Tap it to clip that exact span into your Notebook; the clip flies into the ∞ button, which pulses.
Li Ka-shing · saved
"Keep some back, whatever you choose."
Notebook
aka: saved lines, the quote book
Your clipped quotes as rose cards — attribution · the quote · jump · copy · a two-tap delete (next). The ∞ pane shows this chat's saved lines (with an All · Artifacts · ✦ Saved filter); the global Notebook — every saved line across all chats — is the Notes tab. Both views stay live — a saved or deleted line shows immediately, no tab-switch needed. Jump (go-to-line) opens the source chat and scrolls the original into view, highlighted — landing the highlight's start at the top quarter of the screen on any device (any bubble width), translated lines under the hairline included.
Li Ka-shing

Keep some back, whatever you choose.

saved star
aka: the ✦ on a name
A faint rose after a host's name marks a bubble you've clipped to the Notebook — so you can see at a glance which lines you kept.
confirm
two-tap delete
aka: the confirm pill
Un-saving a line is a two-tap confirm: the first tap grows the muted bin into a red “confirm 🗑” pill, the second deletes — it auto-reverts after 2.6s if you don't confirm. The card leaves both the Notes tab and the ∞ pane at once. (The old per-note free-text annotation was removed — a saved line is just the quote.)

Panel events arrivals & departures

EAlbert Einstein materialized
join capsule
aka: the “xxx joined” capsule
One per character, in that character's seat colour. Pulses 3 beats on the live arrival (static on reopen — no re-pulse when you switch back). No greeting — they just speak from the next turn. The arrival verb varies (one of ~50: joined, materialized, swept in, took the mic…).
Roger Federer and Mary Tehrani have left
leave capsule
aka: the “xxx left” capsule
One combined, system grey capsule for the whole removal — “X has left” / “X and Y have left”. Pulses 2 beats on the live event (static thereafter).
switched to DeepSeek v4 Pro
switch capsule
aka: the model-switch capsule
System grey capsule marking a mid-chat model switch — “switched to <model>”. Same grey family as the leave capsule (it's not a character); dropped at the point in the stream where the model changed.
Dan just joined
member-join capsule
aka: the "X joined" (human) capsule
System grey, no avatar — marks a human invited into the room ("X just joined"). Grey because a person isn't a character (contrast the seat-coloured join capsule, a persona's arrival). Pulses 2 beats on the live join (static on reopen). Its mirror, the member-leave capsule, follows the WhatsApp grammar (v528, chosen over WeChat's silent exits): the room is told, and a removal names who did it — “Y removed X” — while walking out yourself is the plainer “X left”.
Alice has left
member-leave capsule
aka: the "X has left" (human) capsule
The mirror of the member-join capsule — system grey, no avatar — when a human leaves the room or the owner removes them: "X has left". Live + persisted in replay; pulses 2 beats on the live event. (Not to be confused with the seat-coloured persona retire capsule above, which also reads "have left" but is about personas leaving the panel.)

Panel management

Li
ДDoronin
+invite
remove
members grid
aka: the member avatars, the panel tiles
Inside the chat-info panel: the seated panel as avatar tiles, plus the invite (+) and remove (−) action tiles. Both open the picker modal. Every persona card in the pickers also carries a quiet ⋮ → View profile (the profile opens above, v368). In a zero-persona room (a DM, v465) only the + tile shows — it IS the seating door; removing every persona is allowed (the remove picker notes "the chat goes quiet — humans only").
Brian Yuaninfra lead
castcard
aka: a picker card
A selectable character row in the picker modal (new-chat / invite / remove). A pick takes its seat colour on the disc · border · ✓ — no background wash, no checkbox; an unpicked card is neutral, its avatar gaining a coral ring on hover. Picked names also gather into a roster above the button.
This room is full — up to 5 AI personas. Retire one to add another.
room-full toast
aka: the invite-full toast, the cap toast
Blocks an add past the room's persona cap (max_personas). The invite / + surfaces toast "This room is full — up to N AI personas. Retire one to add another." (a headroom variant counts what's left — "only room for 2 more"). In new-chat, the add button itself hides at the cap — both + Add more and the skeleton "add a character" card disappear once the panel is full, and return when you remove a seat; a stray over-cap tap just toasts "A room holds up to N AI personas.".

New-chat · panel selector “Call the panel” → describe → curated panels (hot start) · each card: ⋮ → View profile

“What’s on your mind?” · Call · Hand-pick
aka: the describe→call dialog, the two doors
Reached via the landing “Call the panel” pill, this dialog opens to a situation box and two equal-height (42px) buttons. Call the panel (wide, coral) is the hot start: one DeepSeek v4 Pro call reads the whole library and returns three candidate panels for what you typed. Hand-pick (narrow, ghost) opens the library overlay to choose by hand. (The top-right new-chat compose button opens a separate “Hand-pick your panel” dialog — no describe box, no call/hand-pick buttons — see the skeleton add-card below.) The persona list and its search live only behind Hand-pick / Add more, never on this page. While the call runs, all three action buttons grey out. The situation box is a fixed two-deck (v381): the text shows exactly 3 lines, full width (the grey tip wraps instead of clipping to one), with the dictate mic on its own bottom-right row — shown when the server holds STT keys; dictation streams zh + en into the box (a textarea, so no muted tail), pinned to the newest words, and Call the panel / closing the dialog commits or stops it. Past 3 lines it scrolls with the always-visible scrollbar — the old 1→4 autosize is retired. It can arrive pre-filled by the landing composer (which then auto-fires the call — see Landing composer).
takes switcher
aka: the 1 · 2 · 3, the panel chooser
Three muted pills flipping between the three candidate panels the curator returned (panel 1 = its top recommendation). Deliberately quiet — a new user can ignore it. Switching re-renders the rows below with a short entrance transition.
Pressure-tests the leap
CCharlie Mungerinvestorinvert, always invert
MMichael Moritzventure investoradded by you
curated panel · why · theme
aka: a curated seat, the "why" line, the «theme»
The chosen panel, one persona per row: glyph · name (+ local) · role, then the reason it was invited on its own line, marked by a vertical accent barcoral for the curator's why (short, deliberately evocative — 不明觉厉), grey "added by you" for a hand-picked seat; the bar grows with a wrapped line, × removes. Above the rows sits the panel's theme (a serif-italic « epigraph »); below them, the + Add more from the library slot — now a coral dashed button (1.5px coral border, coral 600-weight text, coral-soft on hover — more inviting than the old faint grey). It hides once the panel is full (the cast cap) and returns when you remove a seat. The redundant name-chip roster that used to sit at the dialog foot was dropped for new-chat (names already show in the rows); only the "Add to your panel" overlay keeps its roster.
“add a character” skeleton
aka: the empty seat, the waiting slot
In the “Hand-pick your panel” dialog (opened from the top-right new-chat button), the add button is a skeleton of a seat-card — a dashed coral frame the same height (71px) as a real seat row, with a dashed circle holding “+” where the avatar goes, the label “add a character” where a name sits, and a muted bar where the role sits. It reads as a seat waiting to be filled; tapping it opens the library overlay. It serves as both the empty-state placeholder and the in-panel “add another”, and disappears at the cast cap.
auto-call escape hatch
aka: “or, describe your topic and → call the panel”
Below the skeleton add-card in the empty hand-pick state only, a deliberately weaker button offers a way back to the describe→call flow: muted text reading “or, describe your topic and” next to a coral “→ call the panel” capsule that echoes the landing “Call the panel” pill (same coral pill, same → -into-trio glyph). It vanishes once any seat is added; clicking it switches to the “What’s on your mind?” dialog with the situation box focused.
Calling your panel — sifting the library…
Warren BuffettConfuciusAda LovelaceJack MaPlatoWarren BuffettConfuciusAda LovelaceJack MaPlato
Steve JobsZaha HadidLiu CixinFeynmanOprahSteve JobsZaha HadidLiu CixinFeynmanOprah
sifting the library
aka: the convening marquee
While DeepSeek curates (a couple of seconds), the panel area fills with a multi-line marquee of persona names drifting past — a pulsing dot and a faint coral scatter, the system "sifting the library." It clears when the three panels arrive.
WWarren Buffettinvestormargin of safety, every time
CCharlie Mungerinvestorinvert, always invert
AAda Lovelacemathematician
library overlay
aka: the hand-pick layer, "Add to your panel"
Hand-pick and + Add more open a layer over the picker — the full library with its own search. It uses the same .castcard component as the invite picker (seat-colour border + a bare seat-colour ✓ when on — unified, no separate circle-checkbox). In the call flow ("What's on your mind?"), the curator's recommended personas — from any of the 1·2·3 panels, deduped — float to the top, each with its coral "why" line, then everyone else alphabetically. Recommended stay pinned at top whether or not they're picked, so tapping one just checks it in place — no reflow. (Hand-pick has no panels → plain alphabetical, no why lines. An active search keeps relevance order instead.) Picks merge into Your panel; the library is never shown inline on the main page.
More settings
aka: the advanced row, the folded dials
The Model · Language · Persona vividness · Floor producer controls now fold behind a flat "More settings" table row (left-aligned, blends into the sheet) so creating a chat isn't intimidating — it expands to the controls below (see New-chat parameters).
The American Dream 2.0
hot-start title
aka: the streamed title, the typing title
A hot start gets an auto title from the same curator call (in the language you typed) — typed into the title bar char-by-char in coral with a caret (Claude-Code style), then it settles to ink. A cold start (hand-pick) types its title in the same way once it has something to name: a solo cast (one persona) auto-names itself "<short>-ish" (e.g. Einstein-ish) at creation; a multi-persona panel stays "New chat" until your first message, then a cheap LLM distils that line into a topic title (e.g. "Math: Discovered or Invented") and types it in. (Both fall back to a plain truncation of the message if the model is unavailable.)
search chrome
aka: the clear-×, “Try searching”
Every persona search box — the three room pickers, the console’s Personas view, the Studio browser — wears the same chrome (PersonaSearch.attachChrome, #23): a clear-× at the right edge (only while there’s text) and, on focusing an empty box, a “Try searching” panel of tag-derived terms that type themselves into the box and run the normal fuzzy search.

New-chat parameters folded behind “More settings” in the new-chat modal

model dropdown
aka: the model picker (new chat)
Sets which model voices the new chat. Five entries: DeepSeek v4 Pro (the default, reasoning OFF = fast) · v4 Pro (reasoning) · v4 Flash · v4 Flash (reasoning) · Sonnet 4.6. The plain names run with the DeepSeek thinking trace off (faster + cheaper); the (reasoning) variants turn it on. Switching between a fast and its reasoning twin keeps the cache warm (same underlying model). The model switcher in the chat-info panel changes it mid-conversation.
language dropdown
aka: the language picker (new chat)
The new chat's language: English · 简体中文 · 繁體中文 · 日本語 — it defaults to your account default language (set in the user menu; a new user is seeded from the OS language, English if unmatched). The panel greets + speaks in it and follows the guest if they switch to predominantly another. Exception: a panel called with the Smart selector (the Describe box) speaks the language you mainly wrote the situation in, regardless of this setting — and on that call this dropdown is pre-filled with the detected language (added as an option if it falls outside the four), so what you see matches the room you'll get.
Persona vividness
True to life Turned up Larger than life
Distinct voices with a notch more presence — leans into each speaker's signature moves.
Persona vividness
aka: the vividness notches — the ONE room dial
A three-notch segmented controlTrue to life (the floor: no directive) · Turned up · Larger than life — for how pronounced every persona's voice is, whole panel. Amplitude on the real person, never a parody; a caption under the notches explains the selected level. Notch values are server-driven (/api/config). Set at creation, baked into the cached prefix. It replaced the old two-dial Temperament + Intensity sliders — who-speaks and who-disagrees is the floor producer's job now. See Room dials.
Floor producer (Off/On)
aka: the FP switch
A segmented Off / On row in New-chat → More settings (new rooms default On) with a read-only echo in the chat-info panel. Hidable per role via the console’s Who-sees-what.

Chat-info panel inside the title dropdown · quick actions: [Theme] [Mute] [Fork]

model switcher
aka: the model tag, the model menu
The model tag is a clickable dropdown — switch the model voicing the panel mid-conversation, among the admin-enabled models (up to all five: DeepSeek v4 Pro / Pro-reasoning / Flash / Flash-reasoning / Sonnet 4.6); it goes read-only when only one is enabled. No model call: the new model picks up the verbatim history next turn, and the cache restarts on it (a switch divider marks where).
Sonnet 4.63 calls · 91.8k · $0.300
DeepSeek v4 Pro2 calls · 61.0k · $0.180
per-model cost rows
aka: the cost breakdown
Replaces the old grand-total stats line. One row per model used● Sonnet 4.6  3 calls · 91.8k · $0.300 — the rows summing to the total. Shown only once >1 model has voiced the room; the current model is subtly coral.
Current Cache 11.9k tok
SPPUG
cache bar
aka: the cache composition bar
The cached megaprompt prefix as a proportional bar — segments in append order, coloured by type: SP (grey, system prompt) · P (green — one profile sheet per character, all the same colour) · U (your turns) · G (the panel's) · (a cast change). Labelled “Current Cache” with just the token total — no cached-vs-new split, and no coral ring on the written tail. Inviting a voice appends a new P segment; a mid-chat model switch shows as a thin divider.
switch divider
aka: the model-switch divider
A thin vertical rule inside the cache bar marking where a mid-chat model switch happened — the prefix to its right was re-written cold on the new model. Same event as the switch capsule in the stream, shown on a second surface.
Einstein-ish
rename pencil
aka: the ✎, the hero edit button
A small at the hero's right edge in the chat-info screen — tap to edit the title in place: Enter/blur saves, Esc discards. Commits optimistically (bar + hero + chats list, then reconciles to the server's normalized title). This is the only in-chat rename now — the old bottom title field was retired — though the chats-list ⋮ menu still retitles from the list. Fades with the hero on scroll; hidden in read-only peek.
Persona vividness
True to life Turned up Larger than life
read-only vividness
aka: the dial echo
The chat-info panel echoes the room's Persona vividness as read-only notches — set at creation, not adjustable here. The scale fades to grey and only the current notch pops (ink), so it reads as informational. (The editable version — coral — lives in the new-chat modal.) A read-only Floor producer Off/On row sits below in the same informational grey.
quick actions
aka: [Theme] [Mute] [Fork]
Three pills directly under the info hero (v366). Theme sits first and uses action semantics like Mute/Unmute — it shows the mode pressing switches to (moon + “Dark” in light mode, sun + “Light” in dark). Fork appears on your own rooms only.

The composer

Message the panel…
composer pill
aka: the composer, the input pill
One rounded pill (Gemini-style), adaptive two-deck since v377: empty shows the field and a lone mic; text makes ↑ send join the mic; past one line the buttons drop to their own bottom row and the text takes the full pill width. The contenteditable grows to exactly 7 lines (max-height:calc(9px + 7 × --lh) — bottom pad only, so the bottom-pinned dictation view shows 7 whole lines with no 8th-line sliver), then scrolls — pinned to the newest words while dictating. The border lives on the pill; the bar is frosted glass — the stream blurs as it scrolls under it. (No STT keys → mic hidden, send always shown, as before.)
dictate mic
aka: the mic
In the composer pill. Tap to dictate — streaming speech (zh + en mixed; iFlytek 中英识别大模型, browser-direct) becomes editable text in the field: the in-flight hypothesis renders muted (.dict-live, var(--muted)) and commits to normal ink as it finalizes. Recording state swaps the glyph to a red stop square (.rec, var(--rec)). The button shows only when the server holds STT keys (body.stt-on, from config.stt) — v375.
send button
aka: the ↑, the send
The round coral button with an up-arrow, at the right of the composer pill. On send the input blurs (dismisses the phone keyboard). While a turn is running it becomes a grey Stop square (the egg colour) — tap to scratch the turn — reverting to the up-arrow when the reply lands.
mention
Li Ka-shing
ДDoronin
Web Search
@-mention autocomplete
aka: the mention popup
Type @ in the composer → the list pops up: each row is just avatar + display name (the old @handle sub was dropped — vestigial once mentions became atomic id-bound chips). ↑/↓ to navigate, Enter/Tab/click to pick; backspace deletes a finished mention whole. The list includes ✦ Web Search — pick it to pull a web dispatch into the room. Since v536 the chips render in host bubbles too: a host may emit @{u:N} when addressing one human directly (server-linted to current members — an unknown id flattens to plain words, never a dead chip), and both bubble renderers (fmtSpeech, renderRich) resolve it to the same coloured @Display span. v537: a mention of the viewer renders as a filled pill (.um-me — coral-soft bg · coral text · 8px radius · weight 600, CSS-owned so the rename relabel pass leaves it alone); everyone else's mentions stay quiet muted text — only your own name jumps out, the WhatsApp idiom.
@ Li@ Doronin
quick @-mention chips
aka: the mention row
A one-tap row of the room's members above the input. Wide only — hidden on narrow (typing @ still works).

Dialogs two named styles — main · sub

Two dialog styles share one box — the scaffold lives ONCE in theme.css: a 580px card centred over a dim scrim on wide (rgba(0,0,0,.40), the one shared value), full-screen on narrow (≤640px) — var(--surface) background, 1px line border, 16px radius (0 on narrow), a fixed height of min(720px, 88vh), fade-and-scale in, backdrop-tap to close. The persona profile opens with the fade only (no rise-in) and its scroller is chromeless (hidden scrollbar, still scrolls). They differ only in their header and their role.

main dialog ♺ theme.css #25
e.g. “Hand-pick your panel” · “Browse personas”
Top-level entry into a flow. Header = a serif 21px title plus its dismissal glyph. Sits at z-index 80. The × is the shared .dialog-x (theme.css): 20px glyph, 40×40 hit area — one spec for every dialog, sheet, and drawer (.flow variant sits in flex bars; only the image-lightbox keeps its circular dark ×). Where that glyph sits is now decided by the surface TYPE below, not by this tier.
sub dialog ♺ theme.css #25
e.g. “Add to your panel” · the persona profile
Opens one level deeper, from a main dialog. Header carries a ‹ back that returns to the parent, with an optional serif 18px title. Stacks above its parent. (Until v516 it also carried an × — the redundant second door was retired on phone by gap 10.)

Dismissal grammar — the four surface types v516 · gap 10

main / sub above says how deep a surface sits. This says where its close lives, and it is the load-bearing axis: a phone already teaches four grammars, and we follow them rather than inventing a fifth. Dismissal is a form of going back — that is why it lives on the left — and an × in the top-right of a surface that has a commit steals the commit slot, creating the "did closing save my changes?" ambiguity the Material layout exists to prevent. Every rule here is phone-only (max-width:760px): in wide mode × top-right is correct for every type, because that is where a window closes.

TypeDismissalOpt-inOurs
Page
navigated into
‹ back, top-left. Never an ×..dialog-back, or .dialog-back.corner + .hbar on the head (+ .hide-narrow on any × it also carries)chat info · profile · forward · notification centre · poster · Archived & Trash · news reader · text sheet · Chats-from-Vibes
Task dialog
has a commit
✕ / Cancel top-LEFT · the verb top-RIGHT..dialog-x.task + .dialog-commit + .hbar on the head; the footer twin and its "N selected" line take .hide-narrownew-chat picker · people picker · account editor · Seen writer confirm · library overlay · crop editor · selection bar · Post-to-Vibes
Overlay
nothing to commit
× top-right, or tap-out.bare .dialog-xthe unchanged defaultdiagram zoom · image lightbox · avatar viewer · every popover
Sheet
rises from the bottom
Grabber; × optional, usually absent.no scaffold glyphreaction details · emoji panel · the Me sheet (keeps its ×, by decision)
The invariant that makes this safe on a shared component: the sweep is opt-in. A bare .dialog-x still renders exactly where it did in v515, so a surface that declares no type cannot shift. Add the class that matches the type — never edit the default. The gesture must always agree with the glyph: whatever ‹ or ✕ does, the back gesture and Esc do the same, through the same close function (registerBackLayers() + backStack.escapeTop()). The header is a BAR, not two floating corners.modal-head.hbar lays out ✕ · title · CTA on one row via flex order, the title being the only element allowed to shrink (flex:1 1 0; min-width:0 + ellipsis). A head that carries a title and a .task/.corner glyph MUST take .hbar: absolute glyphs over a flowing title is the v516 bug — they collide and the title cannot shrink. The CTA is a filled coral rectangle and carries the selection count, so the footer's "N selected" hides on phone. Full per-surface record: the dismissal sweep.

Social & messaging layer Vibes · Moments · Seen · notifications · select & forward · reactions · emoji

The layer that grew the room from a chat into a small social app: a persona feed (朋友圈 on a 今日头条 drip), the Seen read, a durable notification rail, and WhatsApp-grammar message actions (touch long-press → selection + the action bar: reply · save · copy · forward · delete · ⋮ — and, riding the same beat, the reaction bar with its chips). Demos here are representative; the live components live in lib/room-ui.html.

K
kaka
Joined Jun 2026
12
chats
3
notes
Account · Language · Archived & Trash
Me sheet ♺ theme.css #25
aka: the sheet
Opens from the menu button on the shared dialog scaffold (.mep): a hero (avatar + name + Joined), a stat row, and account rows — display name · password · Default language · Archived & Trash · sign out. Carries the notification count.
S
Studio · your build is ready
2m ago
A
Ada invited you to a chat
1h ago
Notification centre
aka: the ≡ dot · the inbox
Live v401→v415. Every event writes a durable notices row, mirrored over the per-user SSE stream. One compact form — small avatar · byline · action · CTA — across three surfaces: a toast, a dot on ≡, and this centre. An inbox, not a log: collapse-key coalescing, revoke-on-counter-event, read-on-use. Kinds: build done / failed, room invite.
S
Build ready
Notice toast ♺ theme.css #30
aka: the toast pill
The SSE mirror of a notice on whatever tab you're on — the softened warm-taupe / bone pill (--toast-*, v420) with an optional avatar + CTA (the .lite variant). Plain toasts ("room is full") use the same pill without the avatar. A toast isn't a notification — it's the transient surfacing of one.
梁宁
产品的本质,是把一件事想清楚。
2h · 今日头条
Vibes feed
aka: Moments · 朋友圈
The cast's feed — famous masters and user-built personas alike — on a WeChat-Moments look, dripped 今日头条-style. Posts are DeepSeek-written, system-curated (the Vibes engine). Each moment opens one shared room. A tab in the head bar.
Share something…
Post to Vibes ♺ theme.css #26
aka: the compose page
A full-screen compose page for a user's own Vibes post — a text area and the coral Post CTA. Lands the post into the Moments feed; it can carry a poster.
The read.
Poster
aka: the magazine poster
A full-screen magazine-style poster (.poster-page) — the shareable image a post or Seen read renders to. A small embed of it also rides inside the sender's message bubble.
A quiet week in markets… HOT REPLIED
A
B
C
the circle is talking
NEWS reader · circle window
aka: the news drip
Real-news items with attribution and slate REPLIED / HOT badges; tapping opens a circle window — a mini group chat where the cast reacts. Feeds the same Vibes surface.
SEEN · your week
You kept circling one question.
— signed, 梁宁 · opens to a long read
Seen card
aka: Seen (never 看见)
An on-demand read that lands in your Notes tab — a batch of chats + saved lines distilled into a keepable piece, written by a voice from your own room. A graphic Card opens to a long read; a writer picker and a confirm (.seen-dg, stacked sub-dialogs) choose who writes it.
× 2 ↩ ☆ ⧉ ↪ ⋮
先说结论:这个功能砍掉一半。
Selection mode & the action bar gap 8 · v443–444
aka: .sel-bar · the row wash · long-press select
A long-press (stationary 480ms — touch AND desktop mouse alike; the old lean floating menu is retired, and a mouse drag still selects text natively → the ✦ Save-line pill) selects the message: its whole row washes translucent violet edge to edgecolor-mix(var(--seat-6) 22%) on a z-index:-1 underlay (+isolation), so the tint covers the empty space beside the bubble while the bubble itself stays untinted; nothing else dims. (Colour chosen 2026-07-17 from the six-candidate study — riding the seat-6 token keeps it theme-correct; slate and coral were retired there.) The stream is treated as rows: messages AND the entity rows between them (web-search cards, the feedback widget, capsules, cost lines) — entities aren't selectable but own their slice, so a band stops at their edge (never washes across them) and a tap on one toggles nothing. The band runs to the exact midpoints between neighbouring rows (JS-fitted): directly-adjacent selections are contiguous, separated by a 1px slate hairline; tapping anywhere in a message's band — the stripe beside the bubble, the gap, the wide-mode margins of the pane — toggles that message. The room header is replaced by the action bar (its content pinned to the bubble column's width on wide screens — the verbs sit over the messages, not the pane's far corners), ✕ + count · ↩ reply (single-select only) · ☆ save (batch → one Notebook entry per message) · ⧉ copy ("Name: text" lines when multi) · Select text (touch + single-select only — opens that message in the double-tap text sheet, next term; hidden on desktop, where a mouse drag-selects directly) · ↪ forward (double-arrow glyph; record-card picker) · ⋮ more (Select more · Message info · Delete · Report). Touching the composer while selecting auto-exits selection mode (you're moving on to type). Delete (in ⋮) goes straight to the app's confirm card — the centred Cancel / red-Delete dialog, no dropdown: an all-your-own selection asks delete for everyone → the replay-honoured redaction and the tombstone bubble (italic muted "This message was deleted"); anything else asks delete for you → the per-user hide. Rows keep their exact layout — selection reflows nothing (the wash is a ±100vw underlay clipped by the pane, so the bubble never resizes and even the narrowest bubble washes the full band); the empty stripe beside a bubble toggles too (Y hit-test). On touch the chat owns every long press — the hold arms anywhere on the row (bubble, quoted block, head), and capsules/empty space never summon the OS selection or context menu (desktop mouse selection untouched). Tap rows to grow/shrink; deselecting the last row, ✕, the back gesture, Esc (desktop), or acting on a verb exits — on iOS the edge swipe exits in place (the hot strip is claimed while selecting, so cancelling the mode never plays the OS page-slide; Android's Back peels the layer as usual).
这份榜单衡量的是公园和咖啡馆,不是生存。真正的宜居,是一个城市能不能在裂缝上建出秩序。
Save line⧉ Copy
Double-tap text sheet gap 8 · v490
aka: .txt-sheet · WeChat's 双击 · 全屏文本
Double-tap a bubble's speech body (double-click on desktop) and its text opens alone, full-screen — the reading font at 1.4 × the bubble text (measured live from the tapped bubble, so it rides the Text-size dial; ≈21.7px on Normal) on the bare surface, starting well below the ‹ zone (an easy back-tap that never lands on text), scrollable (overscroll contained, so the URL bar never moves), and the only text on screen (no sender line, no hints). On touch the sheet uses no native selection at all (user-select:none) — which is the only way to suppress Android's native handles, iOS's long-press callout, AND Chrome's tap-to-search/translate panel (all three are OS chrome a web page can't hide while a native selection exists). Instead it tracks its own {start,end} offsets, paints a violet highlight behind the text, and draws its own violet ball-on-stem handleslong-press or double-tap selects the word (Intl.Segmenter, CJK + latin), drag a handle to adjust (caretRangeFromPoint, aimed at the text). Copy/Save read that range. Desktop keeps native selection (a mouse has none of those quirks; ::selection paints violet, handles hidden). Full rationale + the ten-round history: Text selection on touch. It opens with everything pre-selected and the two-verb pill already up✦ Save line · ⧉ Copy — so acting on the whole message is one tap; drag the OS handles to clip a span (the pill follows, a finger-width below the selection's end — clear of the lifting finger AND of the OS toolbar, which pops above; the OS selection toolbar itself can't be suppressed by a web page, but the pre-selected open state summons none, and Android's long-press context menu is swallowed inside the sheet). Save line keeps the ✦ pill's full plumbing: seat colour, the jump-back anchor to the source message. Acting keeps the sheet open (clip twice, WeChat's way). The only chrome is the ‹ back chevron top-left; ‹, the back gesture, or Esc close. Text is read via speechText(), so emoji survive the picture-text bubbles. This sheet replaced the old "Select text" hand-back flows; single-tap selves (quote-jump, links, profiles), the long-press selection, and the swipe-reply are untouched — tombstoned bubbles don't open it. It is TOUCH-only: on desktop the double-click door is closed entirely — a mouse drag-selects in the chat and the same two-verb pill (✦ Save line · ⧉ Copy, .save-pill wearing this sheet's chrome) rides that selection, so both partial-text surfaces read as one thing.
👍 ❤️ 😂 😮 👏 🙏 😢
你不是怕选错,你是怕选了就没退路。
Reaction bar gap 4 · v453–457
aka: .react-bar · the quick twelve
Rides the same long-press beat as selection (WhatsApp shows both at once): press → the row washes AND this floating pill pops right where the finger pressed — its bottom edge ~12px above the press point (never under the finger; a tall bubble no longer flings it to its own far top), clamped below the action bar, glued to that content point on scroll and folding once it scrolls away. It holds twelve — and the twelve are six fixed then six yours (WhatsApp's shipped shape): the frozen six are 👍 ❤️ 😂 😮 😢 🙏, then your own most-reacted-with, ranked by frecency, deduped against the six and backfilled from a curated tail (👏 🔥 🎉 💯 😍 👌) until you've reacted with six of your own — in a swipeable strip: on touch it shows 6½ slots, the half-cut seventh dissolving into a soft gradient edge as the "more to the right" cue (the mask's fades follow the scroll — the right fade lifts at the end, a left fade appears once off the start, so the gradient only ever hides more content); on desktop the strip widens to all twelve, uncut (@media (pointer:fine), the fades lift themselves). A + is pinned beside the strip — outside the scroll, because an escape hatch you must swipe past twelve slots to reach is not one. It opens the composer's emoji panel in pick mode on that same message (window.openEmojiPanelFor(lid)), so the full 1,897 are one tap away; the bar hides but the row stays selected behind it. Why the 6+6 split matters: the six that never move are exactly the six the 6½-slot strip shows without a swipe, so every target you build a habit on is frozen, and personalisation lives in the half-cut seventh onward. The gradient cue now teases your first personal slot. 29px glyphs in 41px slots, scroll-snap, hidden scrollbar, re-opens at the strip's start. Single-message by nature: it hides the moment the selection grows past one and returns when it shrinks back. Tapping an emoji applies the reaction and exits selection; your current pick reads back lit (.mine, coral-soft). Every glyph in the bar, pills and sheet is a self-hosted Twemoji SVG (/emoji/<codepoints>.svg, CC-BY 4.0 — lib/emoji/LICENSE.txt) so reactions render identically on every OS; alt carries the real character and a missing asset degrades to the native glyph. Gap 12 finished the subsystem: message text renders the same art (img.emj), the panel browses the same set, and a reaction now feeds the same Recents the panel reads — one asset pipeline, one prefs store.
Message… 🎤
😀😄
The in-field emoji door gap 12 · v479
aka: .emo-btn · the smiley · the ⌨ flip
The composer's emoji door lives inside the field pill, at its left edge, muted — WhatsApp's exact placement, not a standalone button beside the composer. It shows a face; while the panel is open it flips to a keyboard glyph (⌨, coral) to toggle back, and body.emoji-open drives both. Structurally it is a sibling of #ta, never a child: the v477 tap router keys on "the target is #ta or inside it", so a door inside the composer could be mistaken for a composer tap and open the keyboard. Its pointerdown only preventDefault()s — that keeps focus and the caret in the composer and does no layout work, because relayouting under a live finger makes iOS cancel the tap outright (trap ②, the virtual keyboard); the swap happens on click, once the tap has completed. In the two-deck morph the door rides the bottom row at the left, beside the mic (.inputrow.multi .ta-wrap{order:-1} lifts the text above it, or the 100%-basis text would strand the smiley alone on the top line). The deck probe subtracts its slot from the inline-room sum, or the morph flaps at the boundary.
Search emoji
Recents
😂 ❤️ 👍 🎉 😮 🙏 😅 🔥
Smileys & people
😀 😄 😁 😆 😅 😂 🤣 🙂
🕐😀🐻🍔🚗💡🚩
The emoji panel gap 12 · v479
aka: .emoji-panel · the keyboard-space occupant
The panel is not a new widget with geometry of its own — it is a keyboard-space occupant: it takes the same space the keyboard takes, at the keyboard's own measured height (mad-kbh, cached per orientation; a sane default before any keyboard has shown), while --app-h shrinks by exactly that much. That is what makes a keyboard↔panel swap read as one surface replacing another instead of two layers fighting: open = reserve the space THEN blur, so focusout's same-task restore (v475) computes the height the keyboard already had and setShell's dedupe makes it a no-op — zero viewport motion. The reservation is subtracted from fullH, the keyboard-free layout height, never the live clientHeight (Android's layout viewport shrinks with the keyboard, so subtracting mid-animation would shrink the shell by ~2× the keyboard and snap back). The ⌨ flip hands focus back through the v477 routing (pre-shrink + forced flush + focus({preventScroll})) — never a raw .focus(). body.kbd-up stays on while it holds the space, or the composer pops up 34px the moment the keys go. Anatomy: search on localized CLDR keywords (follows the UI locale, English fallback — 猫 finds cats), Recents first (frecency = count × a 14-day half-life, so a recent one-off outranks a stale favourite and one stray tap doesn't unseat a habit), seven category tabs, and a long-press tone popup remembered per emoji (the grid then paints your choice; a corner nub marks which cells have tones). The grid mounts in 96-emoji chunks on scroll — a tab holds up to 552 and the set is 1,897. Its data (/emoji/index.json + kw-{en,zh}.json, ~214KB, built from emojibase-data's CLDR annotations) is fetched on first open only and never sits in the boot path. Desktop (fine pointer, wide) gets a popover anchored to the smiley instead — no keyboard space to take. Registered with the back-stack; closes on send.
Offer accepted!! 🎉 the flat is ours 🏡
🎉😂❤️
👍
Emoji in message text · solo emoji gap 12 · v478, v482
aka: img.emj · .bubble.emoji-solo
Every emoji grapheme in a rendered message becomes an <img class="emj"> of the same self-hosted Twemoji art the reactions use — so a 👍 sent from a new iPhone is the same picture on an old Android, and a glyph from a newer OS never lands as tofu □. alt carries the real characters, so select-and-copy and screen readers get genuine Unicode, never a filename. Applies to persona bubbles too (models emit Unicode emoji — cross-platform consistency for the cast, free). Exactly two sizes (v482 scrapped the earlier graded j1/j2/j3, which only confused): 21px inline, and 35px for a message that is one emoji and nothing else. Both are px (a fixed spec, not "1.25× the text") but multiplied by --read-scale so they still honour the Text-size dial. The solo case drops the bubble entirely — transparent, no border, no fill, no padding, a plain emoji floating in the row (WhatsApp/iMessage). The .bubble element stays (lid, long-press, reactions still hang off it); only the chrome goes, via !important so it beats both the human bubble variants and the personas' inline background+border-left. Two or three emoji are no longer special — just inline emoji in a normal bubble. A forwarded record or a quote-reply never goes solo; computed at render time so history and replay agree; the chat-list preview stays plain text. Degrade-never-delete: a missing asset falls back to the native glyph, any failure leaves plain text standing. The trap worth knowing: a rendered bubble is now a picture of its text, and an <img> contributes nothing to innerText — an emoji-only bubble reads back as the empty string. Anything reading a bubble AS text (quote-reply, the pacing matcher, note lookup) must go through speechText().
你不是怕选错,你是怕选了就没退路。
❤️2 👏
Reaction pill v457–459
aka: .reacts · the on-bubble cluster
Landed reactions are ONE small pill overlapping the bubble's bottom corner (WhatsApp's exact look — the old capsule row below the bubble is retired): every distinct emoji (25px glyphs), each followed by its own count once that kind passes one — 👍 ❤️2 😢 🙏3. It rides 20% of a glyph (5px) onto the bubble, inset 10px from its corner, mirrored to the right on your own messages; chrome stays neutral — "which one is mine" lives in the details sheet. A fresh reaction BOUNCES in (WhatsApp's beat: scale-in → 1.32 overshoot → a small dip → settle, rx-pop .42s) — a brand-new pill bounces whole, a new kind or a grown count re-pops just its glyph; live additions only (reloads/repaints stay still, removals never bounce). Tapping the pill opens that sheet (next card); the pill itself no longer toggles. Proven semantics (WhatsApp/iMessage): one reaction per user per message — a second emoji replaces yours, re-picking your own removes it. Plumbing: anchored on the stable line id (the ❝{q:<lid>} contract), stored as append-only react events in the room log; every replay carries the current-state projection (pills survive reload + reconcile on any refetch), a react SSE push updates live viewers, taps apply optimistically, and the local-first cache mirrors it. Read-path rules: a redaction takes its reactions with it (and refuses new ones), delete-for-me hides that line's pill for that user, see_from floors apply. The floor producer reads reactions as a warmth line in f's brief; the panel transcript deliberately carries none (§8 append-only cache). The in-bubble 👍/👎 rating rail stays — private calibration for the producer vs this public warmth. Phase 2 noted: personas reacting sparingly · a "X reacted" notice.
1 reaction
☺+ ❤️ 1
YouTap to remove ❤️
The tool strip + the live card — where the furniture lives v569 · toolbox T-UI · D1–D4
aka: .tool-strip / #tableDock / .ts-chip / .ts-panel · .livetool / #liveGate / #liveDie · .mb-row · evidence base: the UI field study
The dock WAS the anti-pattern. Until v568 the room's furniture stacked four strips — board · circle card · clock · die — between the topbar and the chat. Mid-game that ate a third of a phone screen while the newest message, the thing chat is for, scrolled beneath it. The field study checked five independent app families(Telegram · Discord · WhatsApp · Foundry · D&D Beyond)and found no verified chat-game app ships a permanently pinned HUD; what they all do instead is put the tool in the stream. So the placement rule is rewritten: stream = records AND the live tool · strip = glanceable state · composer = your pending action. D1 · the strip. ONE row of chips under the topbar, in the toolbox's zone order(board · circle/ballot · clock · die), ~35px with all four out and hidden entirely when the room has no furniture. A chip is a live NUMBER, not a container: 「📋 Amy 2 : 1 Ben」·「📊 1/2」·「⏰ 26:07 free discussion」·「🎴 0/2 wolf×2 · villager」. Tap a passive one and its card expands into .ts-panel, which hangs OVER the chat(absolute, top:100%)so the pre-chat chrome never grows past the row; one at a time, dismissed by tap-away, its ✕, a chat scroll, or the back gesture(a registered layer). The clock's digits are written in place by the tick — a row that re-renders every second cannot be tapped mid-second. .ts-phase, the row's left end, is reserved for T5's session phase. D2 · the live card rides the stream. A tool still waiting on a person — an open ballot, a collecting circle, an armed or collecting die, a deal being looked at — is not on the strip at all: it is an in-stream card that re-floats to the bottom whenever anything new lands(the DiscordDiceBot re-post: a control that scrolls out of reach is a control nobody uses). One MutationObserver on #stream does it, so every append path is covered by construction. It is presentation, never an event — the record capsules around it stay the durable history, and the card leaves the moment the tool settles, its detail one chip-tap away. A LIVE chip is therefore a status + a jump, never a second copy of the card. D3 · act-now is the thing itself. ONE member row(.mb-row)for every collecting tool — circle, ballot, collect-roll, deal — with the completed state as a badge on the face(Among Us's grammar)and any extra fact(a thrown number)beside the name. On a ballot the ✓ lands in the pill on the tap, before the round trip, and your own face ticks with it: the card confirms, and nothing else has to. D4 · the animation grammar. Play once, then freeze. The two die-rocks were infinite alternate — a spinner pretending to be a throw; they now run a finite 5×260ms and settle, and the settled face IS the record. Every furniture beat(chip pop, card pop, the ✓)is finite, and prefers-reduced-motion kills all of them and leaves the STATE behind. The one-shot ceremony overlay stays reserved for the two peak moments the study named(the deal flip, the unseal beat)— everything else gets less motion, not more.
The table — the room's die v539–551 · v569 re-homed · placeholder form
aka: .tbl-die / #liveDie · server dice · 服务器掷出 · the handed roll · the collect-roll(v551: for="all" = everyone rolls their own; per-thrower faces tick in on the live card's member row, the server re-rolls top ties itself, and the settled order posts as one record —「Ben 8 · Amy 5 — Ben 先」)
v541 (host-moves step 4): the die is live state, and since v569 that state has two homes depending on whether anyone still owes a move. Pending(dashed coral; 「点一下掷出」 when handed to you — the tap resolves it and wakes the narration turn; 「等 X 掷」 otherwise)is an in-stream card that re-floats to the bottom; tumbling is now a finite rock that settles(D4); idle(the latest face + spec + provenance)collapses to a chip on the tool strip — 「🎲 7」 — whose card opens on a tap. The stream keeps compact 🎲 record capsules (.rollrec, the member-join grammar) either way, and the thrown die flies to the live card rather than to a dock. Hosts request via <roll dice="2d6"/> (own draw, auto-resolves behind a 1.6s beat) or <roll … for="uN"/> (handed — whose fate, whose hand). Placeholder box-with-number until the furniture die lands.
The clock — the room's wall clock v554–556 · toolbox T1 · v569 chip · placeholder form
aka: .ts-chip[data-tool=clock] / #tsClockV · <clock min="5" label="自由讨论"/> · <clock sec="30"/> · <clock away/> · 计时 · the tool strip's THIRD chip(v569; it was the dock's third slot)
Standalone furniture (toolbox ruling ④ — a facilitator does not record time on the flipboard): one clock, on the wall, where everyone watches the same digits. The host sets it as its reply's closing act; a new set replaces the old (the capsule says「重设计时」); <clock away/> takes it down when the phase ends early. Its ⏰ is a house glyph(v556 — see below), the same one running and rung(v558). Since v569 it is a chip on the tool strip, third in the toolbox's zone order — board · circle/ballot · clock · die — and deliberately quiet: ⏰ · tabular-numeral m:ss (so the row never jitters as it counts) · the label; the tick writes those two digits in place rather than repainting the row, and a tap opens the big countdown + who set it. Durations run 5 seconds to 2 hours(v555: sec is its own attribute and a fractional min is honoured — both personas that met this tag in its first hour reached for min="0.25"), and the record capsule says them the way a person would(15 sec · 5 min · 1:30). It turns coral under 30s, and at zero it holds a 「⏰ 时间到」 beat for ~6s before retiring, while the stream keeps the lasting record capsule. The countdown is CLIENT-side — the server publishes end_ts once and never ticks (an SSE tick per second per viewer buys nothing a browser can compute); a left field rides each live payload so a phone whose clock disagrees with the server's still counts to the right moment. The ring is a server timer, re-armed from state.json on every room load: a restart neither loses a future clock nor doubles it, and a deadline missed while the process was down rings once. Ringing posts ⏰ to everyone and hands the host the floor with exactly one cue turn — nobody narrates a countdown, because the room is already looking at the real one. Placeholder form until the furniture clock lands.
Furniture glyphs — house emoji, everywhere v556–558 · owner ruling
aka: tEmo("die") / TOOL_EMO / img.temo · the same /emoji/<codepoints>.svg art as reactions and message text
Every emoji a TOOL wears is self-hosted Twemoji, never the OS font. A die that is a red rounded cube on iOS, a flat white one on Android and a wireframe on Windows is not one shared object — and for furniture the glyph is the object's identity, so it must be the same picture in every seat. Swept in v556: the clock strip and its capsules(⏰)· the table die, the hand, the fly-up ghost and the roll record(🎲)· the envelope(✉)· every circle surface(🔒 🔓). v557 splits the envelope pair: sealed ✉ → revealed 📄, two different OBJECTS on purpose — Unicode has no opened envelope at all(audited across the vendored set: ✉ is the only plain letter, the rest are e-mail variants and mailboxes), so the reveal changes the object rather than opening it, and the sealed capsule and the reveal card stop wearing the same glyph. Stopgap until furniture.html §6's drawn envelope lands its 已启封 state — a state that exists in that spec precisely because no emoji can express it. The rule for new tools: add the glyph to the TOOL_EMO map and call tEmo("name") — never paste the character into a template. ONE glyph per tool, every state(v558): an icon identifies the OBJECT, not what it is doing — the die is 🎲 pending or settled, so the clock is ⏰ running or rung, and the state is carried by the words beside it and by the coral. The ⏱ stopwatch is retired. Sized in emimg.temo, 1.15em; 1em on the die face)so one call fits a 10px capsule and a 17px face, and a missing asset paints the native glyph back(degrade-never-delete). ⚠ A swept surface is now a PICTURE: innerText drops these — read img.temo[alt] when testing.
The circle — sealed bubbles v547–549 · placeholder form
aka: .gate-card(in #liveGate since v569)/ .mb-row / .gate-mode / .msg.held / .msg.sealedph · <gate> + <seal> · 密封收集 · host-moves step 7
Everyone in, then one turn — and every answer is a sealed bubble (v549, owner design). The host opens a collect(<gate>问题</gate> → a 🔒 capsule + the roster card(which since v569 rides the STREAM and re-floats to the bottom rather than pinning above the chat): question · chips ✓/… · n/m · two-tap Release now), and if it plays too, its own move rides the same reply as <seal>出招</seal>one commitment channel for humans and personas alike. Each submission lands in the stream immediately as a sealed bubble: yours shows your text(dashed + 🔒, send again to revise), everyone else's is author + 「🔒 已密封」only — the content never leaves the server. The composer strip tells you before you type(「你的下一条消息将密封提交 · 全员交齐后一起公布」). Release(all-in · the card's button · <gate away/>): the 🔓 capsule beats, then every bubble opens in place — persona moves first (they committed first) — and ONE host turn reads the round as a set; a playing host that sealed nothing is told it has no move. Placeholder form until the furniture circle lands.
The ballot — the room's vote v559 · toolbox T2 · rebuilt v570 · placeholder form
aka: .vo / .vo-bar / .gc-by / .av-stack / .gc-hide / .votecap / #voteScrim · design: poll.html · <vote options="加辣, 不加辣, 弃权" anon/> · <vote close/> · 投票 · the circle card's second KIND
The same card as the circle, tapped instead of typed(and since v569 the same in-stream home) — a vote is a gate kind, not a new object, so the roster card, the chips, the two-tap close and the fire path are all the circle's, reused. The head wears 📊 + the question(or just「Vote」— the persona asks in its own words, so an options-only ballot is normal), an anonymous tag when the ballot is anon, and n/m; below it the option pills(tap to vote, your pick fills coral and since v569 takes a ✓ inside the pill on the tap itself — before the round trip, WhatsApp’s beat — re-tap another to change), then the shared member row(a badge on the voter’s face)and a two-tap Close now. v570 rebuilt the card around THE REVEAL GATE(design: poll.html). It carries its opener's face(「Ben started a poll」— a poll is somebody's MOVE, and an unattributed card reads as the room's own furniture appearing by itself), one ROW per option(tick · label · the faces of everyone who picked it · the count · a bar)and a 「waiting for」 row naming who is still out. Showing all that is safe because the SERVER withholds the standings from anyone who has not voted: before you vote every count reads 「—」(a refusal to answer, where「0」would be an answer)and the bars sit empty — genuinely withheld, not hidden, so devtools reads nothing either. ⚠ The gate SSE is viewer-less and can therefore only ever carry the un-revealed shape; the tapper's reveal rides their own tap response, every other voter re-reads GET /gate. MULTI-CHOICE<vote … multi/> · a drawer toggle)wears square boxes and a Vote button, because a set is committed rather than tapped — ticks stay LOCAL until then, so nothing leaks by considering a vote — and its counts legitimately sum past the number of people. No leader star: the boundary law says the server states counts and the room decides what wins. v571 · A VOTE IS FINAL(owner ruling). Once yours is in, both kinds, the rows stop being controls and a 🔒 line says so — refused in vote_tap, not merely greyed(a re-submit of the SAME set stays an idempotent no-op, since a retry must not read as a change). Locking is what completes the anti-anchoring story: the gate stops you being swayed before you vote, the lock stops you acting on what it showed you after. An option may now be a sentence(120 chars)and its row wraps rather than ellipsing. And the card never scales under the finger that taps it — the v570 pop fired on every gate event, so your own vote made the card flinch; the numbers moving is the feedback. v574 · THE EDITOR IS THE HOUSE DIALOG#pollScrim / .modal: × top-right, Cancel + commit foot, full-screen on a phone by the house rule)— a poll editor is a form with a commit, which is what that dialog is for. The drawer row 「Create poll」 is a direct action: one tap opens it, nothing expands. ⚠ The option list appends and pops rows one at a time, never re-rendering — a rebuild destroys the node you are typing in and the virtual keyboard retracts(the other half of never-relayout-under-a-finger: never DESTROY the focused node either). The card also gained an OPEN / CLOSED pill(--green / --rose), the opener's name in the speaker-name grammar, and bubble-sized type tied to --read-scale(15px body, 12.5px head, 16px question)— a card in the stream is a bubble and should read like one. ⚠ .vo-bar carries flex:0 0 5px, not bare height: the row is a COLUMN flex container, so its height is the MAIN size and flex-shrink was free to eat it. WhatsApp's shape, and its two habits: the option list adds its own next field(fill the last one and an empty one appears; empty the tail and the spare collapses — you never hunt for「+ add」)and every option carries a grip, with touch-action:none on the grip alone so a caret drag inside a field never moves the option. ONE draft object is the truth and the DOM is a view of it, so a reorder is an array splice plus a repaint. Allow multiple answers + Anonymous as pill toggles, a Who votes row defaulting to everyone(an untouched picker sends nothing; the server reads that as the whole room), a coral send FAB, and a leave-guard when there is typing to lose. The count bar fills at 48% coral(72% for your own pick)— full strength read as a warning rather than a count. v572: the「X opened a vote」capsule is retired(the card announces itself and stays as the result — the vote_open event lives on, only its render went), options sit 16px apart, a persona cannot title a poll(the SP asks for the question in the character's own words in the same reply, so a q= would say it twice — stripped and ignored; a guest keeps theirs), and a persona's face on the card is drawn exactly as a message head draws it: the seat colour as BACKGROUND under a white monogram, never a coloured glyph on nothing. ⚠ Any surface rendered from BOTH an event and a broadcast needs one field list, checked against both — the closed card wore the 📊 fallback and the wrong tick shape for a day because vote_finish()'s return was thinner than its event. THE CLOSED POLL IS THE SAME CARD, LOCKED(v571, owner: 「the bubble IS the result」). A fired ballot used to vanish and leave a one-line capsule, so the thing you were watching disappeared exactly when it got interesting. It now closes in place — identical rows, real counts, the voters' faces where they were, the coral edge relaxed to a plain one, 「投票已结束 · 计票为准」— and stays tappable for the who-voted sheet(the faces cap at three per row). ⚠ It renders from the vote_result event through the same row builder: the live card is presentation and dies with the container, this is the durable record that survives a reload and a replay. Two things it deliberately does NOT do.It never holds the room: unlike the circle, chat and panel turns flow all the way through a vote — the composer strip stays off and your message posts as an ordinary bubble, because a ballot is taps, not words. ② It never declares a winner: the result capsule reads「📊 加辣 4 · 不加辣 2」in option order, never re-sorted into a ranking — the count is the server's fact, what it MEANS is the room's rule (the boundary law). Firing (all-in · <vote close/> · the card's Close) clears the card and posts that capsule; tap a non-anon one for the who-voted sheet(the reaction sheet's furniture: counts, each with the people under it, back-gesture registered). An anon ballot has no sheet at all — the server discards the author map at the count, so there is nothing left in state, event or replay to open. Placeholder form until the furniture ballot lands.
The deal — the face-down card v562–563 · toolbox T3 · placeholder form
aka: .hand-card / #handCard / .dealcap / the die dock's deal state · <roll deck="狼人×2, 平民×3" for="all" into="safe"/> · <reveal who="u3"/> · 身份牌 · randomizer × safe
Three surfaces, and the card is on only one of them.My hand.hand-card, the die's slot above the composer): face down 「🎴 点一下看牌 · 狼人×2 · 平民×3」 until I lift it — the tap is the pickup, and the card comes back in that one response and nowhere else. Lifted, it turns SOLID coral with the card's own word and 「只有你能看到」; tapping again puts it face down. It re-opens face down on every load — a shared screen is the threat model, so a card that shows itself when you re-enter the room would be the leak the safe exists to prevent. ② The die's live card(in the stream while anyone still owes a look; a 🎴 chip on the tool strip once they don't): the DECK(public — everyone may know it holds two wolves)plus 已看牌 3/6 and one badged face per hand(the shared member row, v569). Conceal is visible; WHAT is never on this card. ③ The stream: a 🎴 record capsule for the deal itself(「发牌(面朝下)· 狼人×2 · 平民×3 · 6 人」), one when everyone has looked, and the reveal card(the envelope's .env-card)when the host opens one entry: the card, then 「发牌于 21:04 · 服务器存证」+「发牌后未曾改动 ✓」 — the deal time is the proof line, and it is what makes「我一直是平民」provable instead of arguable. Why the shuffle is at ARM: the whole assignment is drawn and timestamped the moment the tag lands, before anybody touches a card, so tap order cannot matter by construction — the tap is kept only because choosing WHEN to peek is real privacy on a shared screen. A deck with no into="safe" is the public variant — turn order, teams — posted as one multi record the instant it is dealt, no pickup at all. Ending it(v563): a dealt card stays in its hand until the host puts the deck away — <roll away/> collects every card and the room sees 「收牌 · 2 张」, the un-conceal twin of the deal capsule. Until that lands, everyone is still holding one: the table clearing is not the deal ending. The glyph is 🎴, not the 🂠 card-back the plan sketched: 🂠 is a Unicode symbol with no house art(and no font on most phones), and the standing rule is one shared object. Placeholder form until the furniture card lands.
The tool drawer — the guests' own hands v564–565 · toolbox T4 · the parity law
aka: #toolsBtn / #toolScrim / .tl-sec · 房间工具 · the composer's SECOND door · /user_roll · /user_deal · /user_gate · /user_vote · /user_note · /user_board · /user_clock
Touch, never syntax(toolbox ruling ⑧)— and since v565's parity law(owner: 「whatever a persona can do, a human can do」)the drawer carries every mechanism the panel can arm, not a chosen four. A second muted door sits inside the composer pill beside the smiley — a die outline, deliberately not a ➕, because ➕ reads "attach a file" everywhere else on a phone and this one opens the room's own objects; it lights coral while its sheet is up. The sheet is a sheet-type dialog.rx-sheet: grabber, no close glyph, tap-out + back gesture + Esc; it rides the keyboard on --app-h because unlike its siblings it takes typed input)holding eight rows: 掷骰(1d6 · 1d20 · 抛硬币 = a two-faced die · 自定义 NdM · 每人各掷一次)· 转盘(a uniform draw over a named set — 狗, 猫, 大象, 蜥蜴)· 发牌(a custom deck, face down or up)· 密封收集(the sealed round)· 发起投票 · 封存笔记 · 白板 · 设个计时, plus a 收牌/清桌 row that appears only when there is something to take down. The state is in the HINT, never inside the row. Each row's right edge carries its live state — 「已封存 1 条」·「牌局进行中」·「计时中」·「桌上还有一掷未落」— because a count that lives inside a collapsed accordion body is a count nobody finds(the v565 bug report, in one line). The two one-tap tools(die, clock)wear their choices as chips, always out; the six that need words open an accordion editor, one at a time. The point is that nothing new appears in the room. Every action lands as the SAME furniture the panel arms — the same die on the same dock, the T3 deck in the same hand slot, the circle's own roster card and held bubbles, the T2 ballot, the envelope's capsule, the one board, the one wall clock — on the same events, channels, capsules and replay, with a person's name where a persona's slug would be(「1d20 → 10 · You」, 「Amy 封存了一条笔记」, 「Ben 设置计时」). There is no second renderer and no second record. The acceptance is an all-human room: every tool works end to end with no persona seated at all. Refusals, all spoken — and the STATE refusal always out-ranks the timer(v566). A guest never takes a tool away from somebody mid-move: an armed die(chips grey, hint 「桌上还有一掷未落」), an open container, a live deal(hint 「牌局进行中」)and a running clock(「计时中」)each refuse in their own words, with the sheet left open so another tool is one tap away. The rate cap(30s, per person per tool)applies to the die and the note only — everything else is already one-at-a-time by its own state, so a timer on top could only ever fire in the honest case(收牌 then deal again; update the score twice in a round). The board has neither guard: its latest word has always won. Every collecting tool takes a CAST, and every container has a way out(v568). A who row.tl-who: one chip per member, all lit by default — you deselect)sits in the sealed round, the ballot, the deck and the collect-roll, because「who is playing」is a fact about this move and rarely the whole chat. Nobody outside the cast is touched: a bystander talks normally straight through a round they were not asked to join. And the two containers gain the third door beside release-and-close — cancel, which DISCARDS and says so(「Ben 取消了这一轮 · 2 份回答已作废」), two-tap, from the same row that shows the container is open. A sheet that accepts typing is not the sheet that only shows things(v567). Three interaction rules its siblings never needed: tap-out must BEGIN on the scrim(a drag-select that strays past an input's edge resolves its click to the scrim, and a bare target check closes the sheet mid-selection); every commit button preventDefaults on pointerdown so the field never blurs, the keyboard never leaves and no row moves under the finger(the emoji door's discipline — a click whose target relayouts is cancelled); and the keyboard-riding --app-h height binds on coarse pointers only, since a desktop has no keyboard to make room for and the var is written and cleared as the subsystem changes its mind. A tool that is stuck carries its own cure. While a deal is live the 发牌 row shows 「收牌」 beside 「亮我的牌」 and greys the deck field — the fix for a greyed button must never live in a row further down a scrolling sheet. Two secrets have two keys. A guest's sealed note joins the panel's own notebook but carries its owner's id: the panel is told it exists and never what it says, its <reveal/> reaches only its own, and 「打开最早的一条」 is the only door out. A guest's deal is BLIND — the dealer is dealt in like everyone else and the map goes nowhere, because the panel holds it only as GM and a player who dealt would be the wolf-knower; 「亮我的牌」 opens your own entry and no one else's, so an all-human endgame is everyone choosing to turn their card over.
Roll card — superseded v539–540 history
aka: .rollcap (removed v541 — the moment moved to the table)
Server truth (host-moves step 3): a host requests fair chance in its reply(<roll dice="2d6"/> · <roll pick="a, b, c"/>); the tag strips, the server rolls CSPRNG after the reply lands, and the card posts for everyone at once — centered, bordered, never anything a persona "said": big mono result, spec line(2d6 · 4 + 3 = 7), provenance(Server roll · asked by 马云). The host narrates from a pending note next turn — it never announces an unposted number. Current form is the placeholder box-with-number; the furniture session owns the real die, and only the rendering will swap.
Reaction details sheet v457–458 · v538
aka: .rx-sheet · "{n} reactions" · who reacted — hosts included since v538: a persona may attach ONE emoji to the guest message it answers (host-moves step 2; a react-only reply = the nod without the bubble); host reactors ride the same sheet as inert rows — glyph avatar in the seat colour, no remove affordance — and humans' reactions reach the panel as delta [Reactions just now: …] notes
Tap a message's reaction pill → a bottom sheet (WhatsApp's grammar, from the user's reference screenshot; on wide layouts it centres in the chat pane, past the sidebar — the chat-info precedent): drag-handle grip, the title "{n} reaction(s)", then the pill row — an EDIT control, not filters (WhatsApp, web-checked): your emoji's pill sits highlighted — tap it to remove your reaction, tap another kind to switch yours onto it, and the smiley+ re-opens the twelve quick reactions inline (your current pick lit) for a kind not on the message yet — then the who-reacted list, always everyone: identicon + display name per row, the reacted emoji on the right, you sorted first. Your own row reads "Tap to remove" and does exactly that; other rows are read-only. The sheet mirrors the line live — every reconcile and SSE push re-renders it, it closes itself when the last reaction leaves, and a room repaint closes it too (lids collide across rooms). Dismiss: scrim tap or the back gesture (registered layer).
把一件事想清楚,是把它放回…
Swipe to reply
aka: 右滑引用 · WhatsApp's gesture
Drag a bubble to the RIGHT (touch only) and it rides the finger 1:1 to the commit stake, then with growing resistance — Apple's rubber-band curve (x·d·c/(d+c·x), c=0.55), asymptotic to 35% of the screen (WhatsApp's feel) — while a bare reply arrow (22px, muted, no disc) fades in at the row's very left and glides a step rightward with the pull; crossing the 56px stake gives a haptic tick and the hollow arrow FILLS SOLID with a pop — an overshoot settling bigger + inked — hollowing again if the pull backs off (and re-filling on a re-cross); release past it docks the quote — the same Reply as the long-press menu (which ticks as it pops, too). Short of the stake it springs back; vertical movement stays the scroller's. Bubbles with sideways-scrolling content (code · wide math · tables) opt out and keep their scroll. Haptics: navigator.vibrate, Android only — iOS has no vibration API, so the arrow pop is the iPhone's cue.
梁宁把一件事想清楚,是把它放回它自己的语境里,再看它还成不成立…
Reply preview card
aka: the quote bar (.quote-bar) · WhatsApp's grammar
The docked quote (.quote-bar) pinned above the composer input — the speaker's name in their seat colour (matching the 3px accent bar), then up to 3 clamped lines of the quoted text, and an ×. On send it rides as an id-anchored marker line (❝{q:<lid>} — the quoted line's stable id, the same anchor-on-ids contract as @{p:slug} mentions); cleared on send or dismiss.
梁宁把一件事想清楚,是把它放回它自己的语境里,再看它还成不成立。你现在拿到的是别人的结论…
Which two? The invite step feels load-bearing to me.
Quoted block
aka: .q-ref · 引用
The reply bubble carries its quoted line above the message body (.q-ref): the speaker's name line in their seat colour — resolved from the anchored line's own bubble by data-lid, never the name — then up to 3 clamped lines; inside a human bubble it steps down to --bg. Tap → jump to the original message with a ring flash; a legacy un-anchored quote unfolds in place (›) instead.
CHAT RECORD · 4 messages
Ada: where do we start?
Kant: with the premise.
Forwarded chat-record
aka: .fwd-card · forward picker
A bundle of forwarded messages rendered as a chat-record card (.fwd-card) inside the target chat. Forwarding opens a "Select a chat" target picker (.fwd-page) — a searchable list of your chats.
FLOOR PRODUCER · admin
王强 lead ≤120字 · Plato ≤65字 · push: medium
FP debug bubble
aka: the staging readout
Admin-only. An in-stream bubble showing the floor producer's per-turn staging — who leads, length envelopes, push level, mode/intent labels. Never rendered to a persona; the "all" tier shows a user-safe view. See Floor producer.
王强
founder · operator
tags · about · start a chat
Profile page · PEEK ♺ theme.css #25
aka: the persona card
A persona's profile opens as a sub-dialog (fade-only, chromeless scroll) — hero avatar, role, tags, about, and a start a chat door (greyed with "This is a private persona" when not discoverable). PEEK is the same view opened read-only mid-flow.

Direct messages the zero-persona room — 1:1 · group · notes-to-self

A DM is the zero-persona room (v465) — the missing cell of the cardinality matrix, not a new subsystem. While the cast is empty all panel machinery is dormant (no kickoff · no floor producer · no LLM, pure SSE relay; the composer reads just “Message…”). Design: Proven Better New §gap 9.

M
Mary private chat
DM header
aka: the 1:1 title bar
The title bar of a 1:1: the counterpart's identicon + name plus a muted “private chat” tag (#dmAv/#dmSub beside #roomTitle; 私聊 — v466 retired “direct message”). Rendered client-side from membership — never a stored title (display names change; the two-stores trap). Notes-to-self reads your own identicon + “notes to self”; the chat-list row mirrors both (title = counterpart / “Notes to self”, meta = the tag). Tapping the identicon itself opens the counterpart's profile page (v468); the rest of the title keeps chat info. A 1:1 has no rename — the hero pencil hides, and the row ⋮ drops it too. Since v515 the name comes from the immutable pair binding rather than the live member list, so it survives any membership churn instead of decaying to a nameless row. Group titles follow the same rule (v466): the other members' names per viewer — “Dan, Amy, Ben, CW…”, capped at 24 chars incl. the ellipsis — live-updating with renames and joins; a group rename stores a real title, which wins (its list row then regains the “with X · Y” second line). A group can never lose its name (v528): because the derived title reads from the current membership, a group shrinking to one human would render as “New chat”, so the server stamps the departing roster at the 2→1 edge — exactly the name that member had been seeing — and the client falls back to “Group chat”.
小胖看房 joined · sees the chat from here on
invite capsule · floor note · the duo→group note
aka: the “sees from here” capsule
Seating a persona in a 1:1 converts that room into a group (v528) and releases the pair binding, so Message privately always reaches an AI-free thread. The conversation stays where it is — scrollback intact, the persona floored at its join — and the room takes the persona's name as its title so it stops rendering under the counterpart's name like the DM it just left; the next tap on Message privately mints a clean, empty 1:1. The capsule says so: “· this is now a group — your private chat stays separate”. WeCom's group-only bots are the precedent. Without this, one person's invite permanently destroyed the pair's only private channel.
A persona invited into a quiet room joins in place (the normal seating flow, may_discover unchanged) and its history floor sits at the join moment: dormant-era lines never reach the model (nothing was queued or pended; the floor producer's tail starts here too). The join capsule says it out loud — “· sees the chat from here on” — and the woken panel's first turn is the persona's arrival line (a mid-room seat addition, never a fresh-room kickoff).
or, describe your topic and call the panel
or, just people — New group
New-group door
aka: the human-group door
The hand-pick sheet's empty state carries a second escape hatch — “or, just people — New group” — opening the people picker in group mode: candidates are your contacts (everyone you share a room with — no global directory; discovery stays social), a pinned “Message yourself” row on top (the notes-to-self freebie). One pick routes to the canonical 1:1; two-plus creates a zero-persona group — titled client-side from the members (nothing stored) until a rename.
Message privately
on a co-human's profile page
Message privately
aka: the DM door
Tap a co-human anywhere they appear (member list · their name/avatar in a room) → their profile pageMessage privately: opens or creates THE canonical 1:1 for the pair (POST /api/dm; one DM per human pair, forever — re-tapping returns the same room, a trashed one resurrects). Your own page reads Message yourself. Growing a 1:1 by adding a human always spawns a NEW group (the invite picker becomes the new-group door: “This starts a new group — your private chat stays intact.”) — never an in-place conversion. An empty DM is never pushed (v515): opening one seats only you and notifies nobody — the other person's thread begins with your first message, so an open network can't be spammed with empty chats.
🔕Mutekeep it, silence it
🗂Archivefiled, stays filed
🗑Deleteyours only · comes back
Duo-chat exits
aka: what a 1:1 has instead of “leave”
Leave and remove are group verbs — no messenger offers either on a 1:1, and ours only did because the people list was built for groups (v515 removes them, server-side too: 409 in a pair/self room, along with rename and private history, both meaningless there). A duo chat also has no owner — the badge is suppressed and both people are seated equally. What remains is the proven set: mute, archive (a deliberate filing that a new message leaves alone), and delete — which is per-person (room_members.status): it leaves your list, never anyone else's, and a new message brings it back. Delete-forever refuses while another human is still there — erasing the room off disk isn't one member's call. Real prevention (block) waits on the friend/discovery model; until then unwanted contact is ignorable, not preventable.

User profiles a face, a name, one line — WhatsApp's contact-info anatomy

A human is a face + a name + a one-liner (gap 11 · v468). Tapping a person anywhere they appear — a bubble's name or avatar, a chat-info member tile, the 1:1 header's identicon — opens the same page. Visibility is room-mates only (the DM-able predicate, enforced server-side). Design: Proven Better New §gap 11.

Mary @mary
● online
“Renovating. Slow replies.”
Message privately · Shared rooms
human profile page
aka: the dossier · contact info
The persona page's sibling (same .vp shell, same hero collapse): a 74px photo-or-monogram hero (tap a real photo → the avatar viewer) · display name · @handle · the online dot · the About line (riding the persona tagline slot) · one primary verb — Message privately (gap 9's door) — then Shared rooms: only rooms you are also in (the server sends ids only; rows render from the viewer's own list, so nothing they can't see ever rides the wire; tap a row → jump into that chat). SWR — a repeat open paints instantly from the last visit, the fresh dossier repaints behind it.
online
online dot
aka: presence
RAM-only presence, displayed: green while the person's per-user event stream is live (the app is open right now). Never persisted — not in state.json, not in the DB — and shown only when on (no grey "offline" state). Last-seen is deliberately out (a later gap, with the per-field privacy matrix).
→ photo, else → M
avatar photo
aka: profile photo
humanAvatar() layers the uploaded photo over the identicon, so the monogram is the forever fallback (no photo, a slow load, or a removed one = today's look) — one function, every surface: bubbles, chat rows, member tiles, pickers, DM header, reactions, notices, Vibes. Colour stays reserved for persona seats — humans are photo-or-grey. Plumbing: one upload writes two tiers (v512) — a 256px thumb (~6KB) that every surface renders, and a 640px full copy (~30–50KB, WhatsApp's own profile-photo size) used only by the viewer. Both are pixel-only re-encodes (EXIF stripped), user-scoped runtime data, never the repo; neither ever upscales a small source. Served immutable under a versioned URL — a change mints a new URL and a live member_avatar repaint on every open room.
drag · pinch · zoom
crop editor
aka: move and scale
Picking a photo never uploads it blind (v512) — it opens WhatsApp's move-and-scale over Edit profile: a dark stage, the photo under a circular window (the shape an avatar actually renders as), drag to pan, pinch / wheel / slider to zoom, cover-clamped so the circle can never go empty. Done canvases just the framed square (≤1024px, upright — the <img> decode bakes EXIF — and metadata-free), so the wire only ever carries the square the user chose. The frame is the file: the photo is pinned to the stage's own origin (top/left:0), because a centred static position would silently offset what you see from what gets saved.
avatar viewer
aka: tap the face
A profile hero carrying a real photo opens full-screen on tap (v512; personas and monogram-only humans keep an inert hero). FLIP: the image is planted exactly on the hero circle and expands to the centred square — border-radius 50% → 16px on the way — so the photo grows out of the avatar instead of cutting to it; tap / ✕ / Esc / back flies it home. Lazy by design: the 640 copy is referenced nowhere in the DOM, so ordinary browsing never fetches it; the viewer paints the already-decoded 256 thumb for the first frame and swaps the full in when it lands.
Edit profile
photo · display name · About (140) · live preview
Edit profile
aka: your profile
Me sheet → the hero pencil or your photo → a main dialog: the photo picker (opens the crop editor, then uploads; Remove photo returns the monogram), display name, and the ~140-char About with a counter — all above a live “how others see you” preview, the signup identity screen's treatment. The About shows under the hero handle in the Me sheet, and to room-mates on your page.

Terminology the canon — one concept, one word

Every concept has exactly one user-visible word. New features inherit this table; renames land on every surface (app, pickers, Studio, console, manifest) in the same ship.

persona
The word for an AI participant, everywhere. Never character, voice, AI char, or bot. (“Character” survives only in its fiction sense; “voice” only for TTS audio and literary voice.)
panel
The personas of one chat, collectively. Never cast (code-side name only), crew, or lineup.
people
The humans. Copy about post-join visibility says newcomers. Never guests or members in visible copy; account age reads “Joined …”.
chat
The unit of conversation, in-app — list, titles, prose. Room is brand only (“Live Room” in the manifest, login, share landing).
Studio · build
Studio is the persona-creation surface; build is its verb. “The builder” never appears in visible copy (it's the internal route name).
profile
A persona's page (“view profile”). Its about-structure is Occupation then Essence — the only heading scheme. Humans share the format (v468, the full dossier): photo-or-identicon hero · large display name · @username · online dot · About line · Message privately · Shared rooms. Profiles open full-screen instantly on narrow and repeat opens paint from their cache (no loading).
Call the panel
The verb for summoning the panel — button and prose (“we'll call a panel for you”). Convene is retired.
Invite · Add
On an existing chat the membership pair is “Invite people” / “Invite persona” (and “Remove people” / “Remove persona”). Add is reserved for composing a new chat's panel (“Add to your panel”, “add a persona”).
Built by me · Popularity
The owner filter and the usage sort, spelled exactly this way on every persona list (pickers, Studio browser, console).
pill · rect
The two button languages (v359): a coral pill is a floating call-to-action (“Call the panel”, the profile “Chat”); a rect radius-10 button is an in-dialog action (Start chat, Done, Cancel — .btn-primary/.btn-ghost, the console's .adm-btn). Both are defined once in theme.css.
Notes · Seen · Vibes · Energy
Feature names are fixed proper names — never paraphrased.
Default language
One setting rules both axes (v382): what the panel speaks in new chats and the app-UI language — the Me-sheet row's sub-line reads “App and new chats”. UI copy is English-as-key in lib/i18n.js: static markup auto-translates via I18N.apply(), JS strings through t()/tn(), dates via fmt.js locale packs; brand names (Dialogue · Studio · Vibes · Seen) never translate. python lib/i18n_audit.py prints what's untranslated; smoketest gates it (the static ratchet sits at 0 since v384 — new English ships with its zh entry or the build fails). English + 简体中文 across EVERY user-facing surface (v386: room UI, login, share, personas, the Studio; server errors translate at display sites via t(d.error)); the console + md-viewers stay English by design.

Vocabulary terms, not pixels

the panel
The room's AI personas, collectively. “Ask the panel”, “the panel confers”.
confer
The panel's backstage deliberation before answering — 3 passes through the cast, flowing/unlabelled. Surfaced by the confer capsule + drawer.
seat colour
A persona's colour is a per-room seat, not an identity — handed out by seating order (rotated per room) from the 8-slot hybrid palette (coral·teal·green·orchid·gold·slate·violet·rose, the two purples kept apart for contrast). Same persona, another room, maybe another colour.
known-as
A persona's short label — the compact name they're recognised by (surname Euler, handle MrBeast, initials FDR, iconic first name Elon). A panel chat lists each member by their known-as; a solo chat uses the full name. (@-mention still finds casual aliases via tags.)
panel cap
A room seats at most max_personas personas — admin-set, default 5; 8 (the seat-slot count) is only the hard ceiling. At the ceiling the picker warns “a room caps at N AI personas”. (The old solo / large-panel footnotes were dropped.)
system grey
The non-identity tone for everything that isn't a persona: the egg, telemetry, the leave capsule, notes, your own bubble.
megaprompt
The engine: one model voicing the whole cast from a single cached context. (Not shown in the UI; it's what “the panel” runs on.)
fork
Cloning a chat to a fresh “<name> (fork)” that carries the model, language, cast, and the whole conversation. The original is untouched; the two diverge from the fork point.
artifact
A non-dialogue block a host hands over — document, code, table, image, note, diagram, or a web dispatch. Filed as a card in the wildcard drawer, reached by a link chip in the host's bubble.
aside
An @human-only line — posted to the room but the panel doesn't answer it (no turn, no egg). A side conversation between people the hosts overhear.
peek
A read-only view of any room the admin console opens via ?peek=<id> — the stream shows, but the composer, model switch, and chats drawer are hidden. Look, don't touch.
theme
The app is light / dark only — no e-ink (e-ink lives only in the room2 design docs). One toggle themes the whole app at once, including the embedded Studio; there is no per-view theme.
hero collapse
The iOS-large-title scroll effect: a big hero header (avatar + title) fades and recedes as the content scrolls, while a compact “(avatar) title” fades in centred in the sticky bar (which carries no hairline). Used by the chat-info title and the persona page. Scroll-driven, direct handler (no rAF).
the shared layer
theme.css — tokens (palette · fonts · radius/space/shadow · the 5-step avatar scale) + the once-defined components (dialog scaffold + .dialog-x, search chrome, popover base, pills, toast) — and fmt.js, the one display voice (ago · row time · en-GB dates · tokens · money · durations). Every page links both with a ?v=N buster: bump it alongside BUILD whenever either file changes.