The frontend
The room UI's layout, dev server, and how to add a translation key.
frontend/ is the collab room from Using the room:
vanilla JS and CSS built with vite, no framework and no code-splitting beyond
what vite does automatically.
frontend/
index.html static shell: overlays, bottom bar (tiles, invite tile, chat column), modals
vite.config.js dev server + build config
package.json
src/
room.js nearly everything: websocket, media capture/playback, DOM rendering, drag-and-drop
translation.js one object literal per language, plus getTranslator()
room.css all styling
touchOpacity.js touch-gamepad opacity, split out of room.js so it can be tested
*.test.js tests for the split-out helpers (node --test)There is no component framework: the chrome is static markup in
index.html, wired once at bootstrap (initDock), and room.js fills in the
parts that depend on who is looking (renderMainRoom) or builds them from
HTML strings as people arrive (addRemoteStream). It is one large file by
design, not by neglect; keep new UI in that same direct-DOM style rather
than introducing a second pattern. The one reason to move code out is a
test: logic that runs without a browser can live in its own module beside
its *.test.js, with room.js still doing the DOM wiring.
Almost nothing here is tested in CI.
ci.yml runs npm test in frontend/, which covers only the few helpers
that live in their own module with a *.test.js beside them. It uses Node's
built-in runner, so it needs no install. room.js itself has no tests, lint,
or type checker. Read your diff carefully, because nothing else will catch a
mistake there before it ships.
Running it
Inside a dev-mode container, vite already serves
the page with hot reload on port 5173, proxied through nginx at
SUBFOLDER. Edit frontend/src/* and the running room picks it up live.
Outside the container, frontend/ runs standalone but has nothing to talk
to unless a broker is also listening on 8000:
cd frontend
npm install
npm run devvite.config.js proxies <SUBFOLDER>/api and <SUBFOLDER>/ws to
127.0.0.1:8000, so run the broker itself alongside it (see
Dev mode) to get a
real session to point the room at. Without one, GET api/session/context
fails and the room shows its "no active session" overlay, which is enough to
work on layout and styling but not on anything that needs live state.
npm run build produces the static dist/ the image bakes in
(SUBFOLDER=/streaming/ npm run build, matching what
image-internals does at image build time).
The build uses a relative base so the same dist/ works under any runtime
SUBFOLDER; only the dev server needs the real prefix, since nginx proxies
the full path through to it.
Where things live in room.js
- Bootstrap (
DOMContentLoaded): fetchesGET api/session/context, which returnsCOLLAB_DATA(role, permission, token, game name). Everything else in the file reads off that object and the websocket messages that update it. - Media capture and playback: the top of the file is WebCodecs plumbing,
a shared worker that wraps
MediaStreamTrackProcessor/MediaStreamTrackGeneratorwhere the browser has them, falling back to a hidden<video>and canvas readback where it doesn't (Firefox, and Safari's audio side). Unrelated to the DOM/UI code below it; skip this block unless you're touching webcam/mic capture itself. - Bar and panels:
initDockwires the static chrome once: the self-view buttons, the chat column (openChat/closeChat, raised while the input has focus, folded to a tab whenupdateBarLayoutfinds the tiles crowding it), the invite tile (initInviteControls), and the document-level dismissal that closes all of them on an outside pointer.renderRoomthen either shows the username modal (a viewer with no name yet) orrenderMainRoom, which fills in the local tile's label and controller-only buttons, shows the invite tile to the controller, and builds the gamepad tray.applyTranslationsresolves everydata-i18n*attribute at bootstrap. - Webcam compose (
composeFrame): every captured frame is drawn onto a 240x180 canvas before it reaches the encoder, rotated upright (derived fromwindow.orientationon mobile WebKit, whose frames carry no orientation; applied bydrawImageitself on Chromium) and fitted with black bars rather than stretched. The receiver paints whatever arrives, so this is the only place orientation is handled. - Websocket (
connectWebSocketand itsonmessage): chat, roster updates, gamepad/MK reassignment broadcasts, resolution changes. The wire format is documented on theroommodule in the Python reference. - Drag-and-drop (
videoStrip.addEventListener('pointerdown', ...)andbeginDrag/endDragnear the bottom): the controller-assignment mechanic from the room guide, plus tile reordering. It is pointer events with an in-page ghost element rather than native HTML5 drag-and-drop, because the native drag image is drawn by the OS and does not show over a fullscreen element. Only the controller's icons carry thedraggableclass; a viewer's tiles are valid drop targets but never drag sources.
Adding or changing a translation
UI text reaches the page two ways:
- Static markup in
index.htmlcarriesdata-i18n="key"(innerHTML),data-i18n-placeholder="key", ordata-i18n-title="key".applyTranslationsresolves these at bootstrap. - Anything
room.jsbuilds itself callst('key')directly inside the template string, e.g.t('tooltips.invite').
Both go through the same lookup in translation.js: t(key, variables)
walks the dotted key ('settings.microphoneLabel') into the active
language's object, falls back to en if the key or the language is missing,
and substitutes {placeholder} tokens from variables. A {count, plural, one {...} other {...}} block picks the one/other branch off a numeric
variable if you need pluralization.
To add a new string:
Add the key to en
en at the top of translation.js is the fallback every other language
reads through, so it has to be complete. Nest it under whichever existing
group it belongs with (tooltips, chat, alerts, ...) or start a new one.
Reference it
t('yourGroup.yourKey') in room.js, or data-i18n="yourGroup.yourKey" in
index.html.
Add it to the other 17 languages (optional, but don't skip it silently)
A missing key in any language falls back to the English string automatically
and logs Translation key not found to the console, so the room never
breaks, it just shows English for that one string. If you don't have a
translation to hand, leave it in English rather than guessing, and say so
in the PR description so someone who does can fill it in.
getTranslator matches on the base language tag (navigator.language split
on -, lowercased), so es-MX and es-ES both resolve to es. There's no
region-level fallback chain beyond that, and no in-room language switcher:
whatever the browser reports is what's shown, per
Using the room.
Styling
room.css is one flat stylesheet, no preprocessor, no CSS modules. Class
names are the only namespacing there is, so a new component's classes should
read unambiguously (prefix with the feature name) rather than something
generic enough to collide. The color palette and the scrollbar styling are
both lifted from RomM's own design tokens, noted in comments at the top of
the file: keep pulling from there rather than hand-picking new colors, so the
room stays visually consistent with the RomM UI it's embedded in.