Dialogue· UI Kit

UI Kit

The app's UI modules, named, with the one thing about each that is not obvious from looking at it. Every entry links straight into the surface in the skeleton — which is the real app with its behaviour removed, so what you see there is what ships.

the reference itself

The skeleton is the truth

docs/skeleton.html is generated from lib/room-ui.html by scripts/build_skeleton.py: it keeps ~2,900 lines of the app's stylesheet and ~840 lines of its markup verbatim, and drops ~11,900 lines of behaviour. No network, no auth, no back-stack — but every colour, radius, font and layout is the real one, because it is the real one.

Open it, then use the bar along the bottom to bring up any of the 29 surfaces. Resize the window to cross the 760px breakpoint and watch the dismissal grammar change. Flip the theme.

Open the skeleton →
why this page no longer draws anything It used to. The first version reproduced each component by hand — and got three of them wrong: the persona bubble is filled with var(--seat-N-soft), not --surface; your own turns are --surface-2 with no colour bar at all; and it drew the chat list wrong (which since v531 carries one avatar tile per row — a single party's own face, or one plain glyph for every group, told apart by the seat-blend disc behind it). It also invented class names (.msg-h, .msg-av) copied from a retired mockup. A reference that teaches things which are not true is worse than none — so the drawing moved to a generated file, and this page kept only what a generator cannot produce: the names, and the reasons.

Rebuilding it

Run this after any UI change. It is one command, deterministic, and takes under a second:

python scripts/build_skeleton.py
  → docs/skeleton.html   (source: lib/room-ui.html @ v526)

The script refuses to write a skeleton whose markup came out suspiciously small, so a structural change to room-ui.html fails loudly rather than silently producing an empty shell.

The modules

Click any card to open that surface in the skeleton. class lines are the app's real selectors — grep them.

Foundations
Colour seats
A persona has no intrinsic colour — its hue is a per-room seat, so the same persona differs between rooms. Each seat has a -soft partner used as the bubble fill. Colour means AI: humans are never given one.
--seat-0…7 · --seat-N-soft
Typography 3 families
Serif carries what was said, plus titles and tab labels. Sans is UI. Mono is metadata. Also --read-font / --read-scale: the user can switch the reading voice to serif and scale it to 1.32, so anything sized in the stream must multiply by it.
--serif · --sans · --mono · --read-scale
Avatar 5 steps
A persona is a filled disc in its seat colour. A human is never filled — a ring around a hashed greyscale identicon, with any photo layered over it and onerror falling back to the monogram. Rebuilding one re-decodes its image, which flashes on iOS.
--av-xs 24 · s 30 · m 40 · l 64 · xl 84 · .avatar / .havw
Surfaces · the four dismissal types
Page ‹ left
Navigated into. ‹ back top-left, never an ×. Full-screen, slides in from the right. The gesture and the chevron do the same thing, because dismissal here is going back.
.fwd-page / .notif-page / .poster-page · .pp-bar > .pp-back
Dialog ✕ left · verb right
Has a commit. The one type where × top-right misleads — it takes the slot muscle memory reserves for “yes, do it”. On phone the header becomes a real bar: ✕ · title · CTA, the title the only thing allowed to shrink. Cross 760px in the skeleton to watch it swap.
.modal-head.hbar · .dialog-x.task · .dialog-commit
Overlay × right
Content over content with nothing to commit. × top-right stays — the one type where the web idiom and the platform agree. The lightbox keeps its own dark circular × by design.
bare .dialog-x — the unchanged default · .lb-x
Sheet grabber
Rises from the bottom over what it describes. A grabber and no × — the affordance is the shape. On wide it insets past the sidebar so it never covers the chat list.
.rx-scrim > .rx-sheet · .rx-grip · max-height 60dvh
Chrome
Chat list chatlist
One avatar tile per row since v531 (WeChat's list, not Telegram's): a single party wears its own face, every group wears the same plain two-person glyph, and the seat-blend disc behind it is what tells the groups apart. Tile · title · time, the last line, then who is in it — and the unread dot rides the tile's corner (v532), not the title. The four tabs are peer roots, not a stack: Back at any of them leaves the app.
.chat-item > .ci-avw > .ci-av · .ci-main · .ci-title / .ci-time / .ci-meta / .ci-humans
Message message
A persona turn: 3px left edge in its seat colour, name to match, bubble filled with that seat's -soft. Your own turns sit right on neutral --surface-2 with no colour bar — the eye belongs on what others said. Another human's turn borrows a seat hue on the border only, via --hc.
.msg.persona / .msg.human(.other) · .head > .avatar+.name+.role · .bubble > .speech
Composer composer
One 24px pill with the emoji door inside it (WhatsApp's placement, never standalone) and the mic at the right, swapped for a 42px coral send disc once there is text. The field is a contenteditable, not a textarea, capped at seven lines.
.composer > .inputrow > .emo-btn + .composer-input + .comp-mic/.send
Selection mode select
Long-press puts the chat into a mode: verbs replace the title bar, selected rows get a full-bleed wash in --seat-6. Already follows the grammar — ✕ left, Done right — which is why the dialog sweep copied it.
body.select-mode · .sel-bar > .sel-x / .sel-count / .sel-verb / .sel-done
Components
Picker card picker
Unselected, the disc is an outline; selecting fills it with the seat colour and the border takes that hue. Cards are built once per session and reused — rebuilding one recreates its image, and WebKit re-decodes asynchronously, which is a visible flash.
.castcard(.sel/.human) · .cc-av / .cc-name / .cc-role / .cc-check
Popover popover
One engine behind the row ⋮, the mention list and the model switcher. Tap-out closes it and it registers with the back-stack, so it peels first. A horizontal variant (icon over label) serves the long-press bubble menu.
theme.css #29 · .popmenu / .rowmenu / .cardmenu · .popmenu.hz
Profile page profile
One shape for personas and humans, reached by tapping a name anywhere. It is a page — ‹ back, no × on phone. The hero is 74px, a deliberate step up from --av-l; a real photo opens full-screen with a FLIP out of the circle.
.vp > .vp-card · .vp-head / .vp-av / .vp-tagline / .vp-chat
Me sheet mesheet
Hangs below the nav bar rather than centred — it drops out of the ≡ that opened it. On wide it occupies the sidebar column only. Its own bar is hidden by design: the ≡ ⇄ × in the nav bar is the toggle.
.mep > .mep-card · .mep-hero / .mep-av / .mep-id
Emoji panel emoji
Not a widget — a keyboard-space occupant. Fixed to the bottom at the last measured keyboard height while the shell above shrinks by exactly that much, so keyboard ⇄ panel moves the viewport by zero. On a fine pointer it becomes a popover instead.
.emoji-panel(.pop) · .ep-search / .ep-grid / .ep-tabs / .ep-tone
Toast toast
Warm taupe pill, bottom-centre, self-expiring, at most one action. Not a back-stack layer. An expired toast must stop hit-testing — opacity:0 alone leaves an invisible button live and focusable, which this app paid for once.
theme.css #30 · .toast / .toast.show / .toast-cta
Buttons & pills buttons
Two languages, enforced: a PILL is a floating CTA, a RECT is an in-dialog action. Mixing them is the tell that a surface has not decided what it is. .dialog-commit is the one deliberate hybrid — a rect living in chrome, phone-only.
theme.css #26 · .btn-primary / .btn-ghost / .cta-pill base
Empty & skeleton empty
Two states never to skip. The empty state is serif italic and says how to make content appear. The loading skeleton mirrors .msg exactly — same gaps, radius, avatar size — so nothing shifts when the turn lands. The app is usually waiting on a model, so this is load-bearing.
.chats-empty · .sk-wrap > .sk-msg > .sk-head + .sk-bub

Dismissal grammar

Which type a surface is decides where its close sits. Opt-in and phone-only: the bare .dialog-x default is unchanged, so a surface that declares no type cannot shift — and in wide mode × top-right is correct for every type, because that is where a window closes.

TypeDismissalOpt-inWhere
Page‹ back, top-left. Never an ×.dialog-back · .corner + .hbarchat info · profile · forward · notifications · poster · Archived & Trash
Task dialog✕ top-LEFT · the verb top-RIGHT.dialog-x.task + .dialog-commit + .hbarnew-chat picker · people picker · account editor · crop editor · selection bar
Overlay× top-right, or tap-outbare .dialog-x — the defaultdiagram zoom · lightbox · avatar viewer · popovers
SheetGrabber; × optionalno scaffold glyphreaction details · emoji panel
the invariant A shared component can only be changed safely by addition. Editing the default moves the × on every surface at once — including the ones where it was already right. Per-surface record: the dismissal sweep.

Back stack

RuleWhy
Register every layerAn unregistered layer means Back exits the app instead of closing it. Shipping the surface without the registration is the bug.
Never register something invisibleWorse than not registering: the gesture animates, nothing changes, and it reads as “Back is broken”. Gate on visibility — !(NARROW() && chat-open), not just a flag.
Esc is the desktop gestureOne handler peels the same top layer. A dialog must also take focus on open — a keydown never crosses an iframe boundary.
Tabs are rootsChats · Notes · Studio · Vibes are peers. Back at a root exits — you switch between roots, you do not back out of one.
Never iframe.src =It pushes a history entry, and an iframe shares top-level history — Back then navigates the frame instead of peeling a layer. Use contentWindow.location.replace().
on the slide The animation on a back gesture is the browser's preview of the previous history entry — it applies to same-document traversals and has no opt-out (csswg#8333, open). Our layers are history entries because that is the only way to intercept Android Back. Accepted, not a bug.

Adding a module

There is nothing to add here for a new component — rebuild the skeleton and it appears, because the skeleton is the app. This page only needs an entry when the module deserves a name and a reason: a card above, with the non-obvious thing about it and its real selectors. If a new surface is a whole layer, add it to LAYERS in scripts/build_skeleton.py so it gets a button and a deep link.

the discipline A surface gets its skeleton entry in the same change that ships it — same rule as the Style Guide sync, but now cheap enough to actually keep, because the drawing is generated and only the sentence is written.

Companion to the skeleton · the dismissal sweep · the lessons · rebuilt from lib/room-ui.html @ v526