Keep some back, whatever you choose.
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.
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.
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.
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.
| # | module | classes | used by |
|---|---|---|---|
| #22 | dialog close / back | .dialog-x · .dialog-back | every dialog · sheet · drawer |
| #22b | dismissal grammar v516–517 | .modal-head.hbar · .dialog-x.task · .dialog-back.corner · .dialog-commit · .hide-narrow | phone-only, opt-in — see the four types |
| #23 | search chrome | .search-box · .search-x · .ps-suggest | all three persona search boxes |
| #25 | dialog scaffold | .modal-scrim/.modal · .vp · .mep — the 580 card + the .40 scrim | main & sub dialogs · Me sheet · profile · survey |
| #26 | buttons | .btn-ghost · .btn-primary · the coral CTA base | every dialog action · Call pill · Post · Done |
| #28 | pill family | .lib-mine (the spec other pills follow) | library filters · toggle chips |
| #29 | popover engine | .popmenu · .rowmenu · .cardmenu | ⋮ menus · model switcher · quick actions |
| #30 | toast | .toast (+ the --toast-* tokens) | every transient notice |
| — | palette · fonts · scales · seats | the token blocks above | the whole app + the embedded Studio iframe |
Three flats, one buyer — here's the revised split…
--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.
/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 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.::-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.Keep some back, whatever you choose.
Does the newest one have a metro line coming?
Which of the three appreciates most?
@name reply, or the opening) has no backstage confer, so it shows no capsule.· 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.!!! — it carries failures the room must never swallow, e.g. "the panel didn't respond — try sending again".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
@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).@Mary ignore the hosts a sec — did you call the bank?
@person mention, no host): it posts in the stream for everyone, but the panel does not answer — no turn, no egg. A side conversation the hosts simply overhear (they catch up on their next real turn). @Web Search is never an aside.I'd hold the Pudong one — newer build, better resale.
Two cases, then the bound:
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.名不正,則言不順。
If names are not correct, language is not in accord with the truth of things.
$…$ 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.Three flats, one buyer — here's the revised split and the exit timeline…
@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.```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.DOC 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.Keep some back, whatever you choose.
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.".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..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.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.(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./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.● 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.P segment; a mid-chat model switch shows as a thin divider.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.).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.@ 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.@ still works).
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.
.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.
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.
| Type | Dismissal | Opt-in | Ours |
|---|---|---|---|
| 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-narrow | new-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-x — the unchanged default | diagram zoom · image lightbox · avatar viewer · every popover |
| Sheet rises from the bottom | Grabber; × optional, usually absent. | no scaffold glyph | reaction details · emoji panel · the Me sheet (keeps its ×, by decision) |
.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.
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.
.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.--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..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..seen-dg, stacked sub-dialogs) choose who writes it..sel-bar · the row wash · long-press selectcolor-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)..txt-sheet · WeChat's 双击 · 全屏文本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 handles — long-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..react-bar · the quick twelve@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..emo-btn · the smiley · the ⌨ flipbody.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..emoji-panel · the keyboard-space occupantmad-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.img.emj · .bubble.emoji-solo<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()..reacts · the on-bubble clusterrx-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..tool-strip / #tableDock / .ts-chip / .ts-panel · .livetool / #liveGate / #liveDie · .mb-row · evidence base: the UI field study.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..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 先」).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..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)<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.tEmo("die") / TOOL_EMO / img.temo · the same /emoji/<codepoints>.svg art as reactions and message textTOOL_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 em(img.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..gate-card(in #liveGate since v569)/ .mb-row / .gate-mode / .msg.held / .msg.sealedph · <gate> + <seal> · 密封收集 · host-moves step 7<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..vo / .vo-bar / .gc-by / .av-stack / .gc-hide / .votecap / #voteScrim · design: poll.html · <vote options="加辣, 不加辣, 弃权" anon/> · <vote close/> · 投票 · the circle card's second KINDanonymous 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..hand-card / #handCard / .dealcap / the die dock's deal state · <roll deck="狼人×2, 平民×3" for="all" into="safe"/> · <reveal who="u3"/> · 身份牌 · randomizer × safe.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.#toolsBtn / #toolScrim / .tl-sec · 房间工具 · the composer's SECOND door · /user_roll · /user_deal · /user_gate · /user_vote · /user_note · /user_board · /user_clock.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..rollcap (removed v541 — the moment moved to the table)<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..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: …] notesx·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..quote-bar) · WhatsApp's grammar.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..q-ref · 引用.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..fwd-card) inside the target chat. Forwarding opens a "Select a chat" target picker (.fwd-page) — a searchable list of your chats.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.
#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”.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).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.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.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.
.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.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).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.<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.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.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.
.btn-primary/.btn-ghost, the console's .adm-btn). Both are defined once in theme.css.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.@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=<id> — the stream shows, but the composer, model switch, and chats drawer are hidden. Look, don't touch.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.