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.
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 →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.
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.
Click any card to open that surface in the skeleton. class lines are the app's
real selectors — grep them.
-soft partner used as the bubble fill. Colour means AI: humans are never given one.--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.onerror falling back to the monogram. Rebuilding one re-decodes its image, which flashes on iOS.-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.--seat-6. Already follows the grammar — ✕ left, Done right — which is why the dialog sweep copied it.--av-l; a real photo opens full-screen with a FLIP out of the circle.opacity:0 alone leaves an invisible button live and focusable, which this app paid for once..dialog-commit is the one deliberate hybrid — a rect living in chrome, phone-only..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.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.
| Type | Dismissal | Opt-in | Where |
|---|---|---|---|
| Page | ‹ back, top-left. Never an × | .dialog-back · .corner + .hbar | chat info · profile · forward · notifications · poster · Archived & Trash |
| Task dialog | ✕ top-LEFT · the verb top-RIGHT | .dialog-x.task + .dialog-commit + .hbar | new-chat picker · people picker · account editor · crop editor · selection bar |
| Overlay | × top-right, or tap-out | bare .dialog-x — the default | diagram zoom · lightbox · avatar viewer · popovers |
| Sheet | Grabber; × optional | no scaffold glyph | reaction details · emoji panel |
| Rule | Why |
|---|---|
| Register every layer | An unregistered layer means Back exits the app instead of closing it. Shipping the surface without the registration is the bug. |
| Never register something invisible | Worse 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 gesture | One handler peels the same top layer. A dialog must also take focus on open — a keydown never crosses an iframe boundary. |
| Tabs are roots | Chats · 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(). |
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.
Companion to the skeleton · the dismissal sweep · the lessons · rebuilt from lib/room-ui.html @ v526