UI code structure¶
How the web UI's JavaScript is laid out, and the rules a page follows. The UI stays framework-free with no build step: hand-written HTML templates, plain CSS on the design system's tokens, and JavaScript as native ES modules the leader serves as they are.
Where the code lives¶
src/silent_swarm/leader/web/templates/
shared/
chrome.js the shell's chrome, the first module on every page:
the CSRF fetch wrapper, the sidebar (drawer, rail,
pool switcher, account menu, feedback), banners,
the session-timeout warning, the public top bar
globals.d.ts types for the vendored classic scripts
ss/ the shared helpers, one module per kind
index.js the SS namespace: re-exports the helpers below
dom.js esc(), el(), cssVar(), trapFocus()
api.js api(), errorText()
icon.js toast.js icon(); toast()
dialog.js dialog(), confirm(), prompt(), field()
modal.js modal.open/close for a template's .modal-backdrop
menu.js tabs.js menu() (a disclosure popover); tabs()
copy.js copy(), copyButton(), codeblock()
blocks.js badge(), meter(), empty(), skeleton()
announce.js announce(): a live region, throttled per key
active_pool.js activePool(), activatePool(), onPoolChange()
address_field.js addressField(): owner + slug, checked as you type
dropzone.js dropzone() over a file input
passwords.js enhancePasswords(): show/hide on data-reveal
palette.js command(), openPalette(): Cmd/Ctrl-K
page.js pageData(), pageText()
render.js lit-html: html, mount(), the directives
store.js createStore(): a page's state, and who to tell
data_table.js dataTable(): the one table component (UM7)
poll.js poll(): a timed refresh that rests on a hidden tab
stream.js stream(): an EventSource that rests and backs off
format.js plural(), sentence(), fmtInt(), fmtBytes(),
fmtDate(), ago()
status.js STATUS_KIND, statusKind(): one tone per state
<family>/<page>.html the markup
<family>/<page>.js the page's entry module
<family>/<page>/*.js a large page's parts: pools/pool_detail/ has a
state.js (its store) and one module per tab
ops/ the Training console: ops_join.js (entry), state.js
(its one store), graph, panel, fellows, logs,
sessions, chart.js (ECharts) and training/
inference/ the Inference console: inference.js (entry),
state.js (its one store), conversation.js (pure),
transcript, markdown, chat, chats, models, perf,
tokens, panels
vendor/ pinned third-party libraries (not in git)
A page names its module when it is built:
_page("pools/pool_detail.html", scripts=("pools/pool_detail.js",))
_page writes the module tags before </body>, the shell's chrome first,
each at a versioned URL (?v= is the start of the file's SHA-256):
<script nonce="__CSP_NONCE__" type="module" src="/ui/shared/chrome.js?v=70a4398c0e9a"></script>
<script nonce="__CSP_NONCE__" type="module" src="/ui/pools/pool_detail.js?v=1c0f..."></script>
and, in <head>, an import map and a preload for every module the page
imports (web/module_graph.py reads the static imports from the files):
<script nonce="__CSP_NONCE__" type="importmap">{"imports":{"lit-html":"/vendor/lit-html/lit-html.js",
"/ui/shared/ss/dom.js":"/ui/shared/ss/dom.js?v=a274abf705af", ...}}</script>
<link rel="modulepreload" href="/ui/shared/chrome.js?v=70a4398c0e9a">
...
A module still imports its neighbours by relative path (./dom.js); the
browser resolves that to /ui/shared/ss/dom.js and the map turns it into the
versioned URL, so every module has one URL and one instance. The preloads
fetch the whole graph at once rather than one level of imports per round
trip.
Code a page may never need is loaded with a dynamic import('./x.js') with a
literal path: the public pages' hero scenes and Three.js, which a phone or a
reduced-motion visitor never fetches (Hero scenes).
The module graph follows such an import too, so what it reaches gets a
versioned URL in the import map, but no preload. A computed path
(import(`./${name}.js`)) is not followed; list the literal imports
instead, as public/hero_scene.js does.
GET /ui/{path} serves any .js file under templates/, except vendor/,
which has its own route. Code under admin/ and kit/ goes to admins only;
anyone else gets a 404, as when that code sat inline in an admin-only page.
At its current version a module is cached for good
(Cache-Control: public, max-age=31536000, immutable; private for admin
code), so a repeat view loads no module at all and a deploy is new URLs. Any
other URL - an old version, or none - gets today's code with no-cache and a
content ETag. An edited module gets a new version on the next page render,
without a restart.
Load order¶
Module scripts run after parsing, in document order, before
DOMContentLoaded. shared/chrome.js comes first on every page, so the CSRF
fetch wrapper and the page-wide listeners (data-close-modal, Cmd/Ctrl-K)
are in place when the page's own module starts, and every element of the page
exists by then. Password fields marked data-reveal get their toggle at
DOMContentLoaded, after the page's modules have drawn what they draw at
start; a page that draws one later calls SS.enhancePasswords(root).
Nothing is global: there is no window.SS. A module imports what it uses.
The only globals are the vendored libraries that load as classic scripts
(echarts, marked, DOMPurify, mermaid, highlight.js, KaTeX's auto-render),
listed in biome.json and typed in shared/globals.d.ts.
Anything that has to happen before the first paint, such as restoring a panel
so it does not flash, stays a short inline script marked data-pre-paint
(at most 30 lines).
Rules for a page module¶
- One entry module per page,
<family>/<page>.js. It starts with a header comment: one line saying what the page does, then the licence lines. Sections that share nothing may be modules of their own, imported by the entry module. - Imports are relative (
../shared/ss/page.js). Vendored libraries (echarts, marked, DOMPurify, mermaid, highlight.js, KaTeX) stay classic globals. - No server tokens in code. A module is a static file, so the server cannot substitute into it.
- Server values: the page builder writes them as attribute-escaped
data-*attributes on<main id="main">, and the module reads them withpageData()(data-pool-idbecomespoolId). - Translated text: pages the server translates (the landing and business
pages run their German catalogue over the whole page) keep their strings
in a
<script type="application/json" id="page-text" nonce="...">block, and the module reads them withpageText(). The catalogue sees the block, so the strings are translated with the page. - No inline handlers, no inline page code. Bind with
addEventListener, or with delegation and adata-*attribute. The CSP allows only scripts with the response's nonce. - Use the shared helpers instead of copying one into the page. Import
SSas a namespace, so calls readSS.api(...),SS.toast(...), and tsc checks them against the helpers' JSDoc. Import the other helpers by name:
import * as SS from '../shared/ss/index.js';
import { dataTable } from '../shared/ss/data_table.js';
import { plural } from '../shared/ss/format.js';
import { statusKind } from '../shared/ss/status.js';
- Type-checked. The header is followed by
// @ts-check;jsconfig.jsontakes every.jsfile undertemplates/butvendor/. Where tsc cannot know an element's type, say it with a JSDoc cast:/** @type {HTMLInputElement} */ (document.getElementById('name')).value. - Module semantics. Modules are strict, and their top-level names are
private to the module. Nothing outside can call a page function by name,
and
thisat the top level isundefined.
Rendering with lit-html¶
Pages that render lists or changing state draw them with lit-html templates from a store. A form with a submit handler stays plain DOM code.
import * as SS from '../shared/ss/index.js';
import { html, mount, repeat } from '../shared/ss/render.js';
import { createStore } from '../shared/ss/store.js';
const store = createStore({ datasets: null });
const view = (s) => (!s.datasets ? SS.skeleton({ lines: 3 }) : repeat(s.datasets, (d) => d.id, (d) => html`
<div class="ds-row">
<span class="dsname">${d.name}</span>
<button type="button" class="btn btn-ghost btn-sm" ?disabled=${d.busy}
aria-label=${`Remove dataset ${d.name}`} @click=${() => remove(d)}>${SS.icon('trash-2')}</button>
</div>`));
mount(document.getElementById('ds-list'), view, store);
store.set({ datasets: await SS.api(`/api/v1/buckets/${id}/datasets`) });
- A view is a function of the state that returns an
htmltemplate.mount(container, view, store)draws it, then again on everystore.set(...), at most once per animation frame. The first draw replaces what the server put in the container (a "Loading…" placeholder). - Interpolated text is escaped. There is no
innerHTMLand noesc().unsafeHTMLis only for DOMPurify output (the chat's markdown). - Bindings:
@click=${fn}binds an event, which isaddEventListenerunderneath, so the no-inline-handler rule holds.?hidden=${flag}binds a boolean attribute,ifDefined(v)an optional one, andstyleMap/classMapset styles and classes. Nodes fromSS.badge,SS.iconandSS.emptygo straight into a template. - Lists whose rows hold focus, a menu or an input use
repeat(items, key, row), so a redraw keeps the row and its focused element. Dialogs stay outside re-rendered containers. - Imports come from
shared/ss/render.jsonly. It is the one module that nameslit-html, which the import map_page()writes resolves to/vendor/lit-html/. So the page has one lit-html.
The UI kit (/admin/ui-kit, section "Rendered with lit-html") shows a sorted
table, a row menu and a dialog drawn this way. The buckets pages were the
pilot (buckets/buckets.js, buckets/bucket_detail.js); the dashboard, the
pool page, the profile and the orgs pages followed (UM6).
A large page is split by what it shows. The pool page's entry module
(pools/pool_detail.js) loads who is looking and the pool into one store
(pools/pool_detail/state.js), sets up the tabs and starts one module per
tab (overview.js, nodes.js, jobs.js, models.js, members.js,
settings.js, plus header.js). Each tab module loads its part into the
store and draws from it, so the header's grade and the node table read the
same snapshot. A tab module may import another's loader (a role change
reloads health and events); the store, not the DOM, is how they share state.
Tables are dataTable(container, opts) (shared/ss/data_table.js), drawn
with the same renderer: search, filters, sorting, pages, row menus and the
stacked phone layout in one place. Its options are in the
design system; a page does not build a
<table> by hand.
Timed refreshes go through poll(fn, {every}) (shared/ss/poll.js), not
setTimeout: it pauses while the tab is hidden and runs at once when it
shows again, never overlaps a run with the one before, and doubles the wait
after a failed run (up to maxEvery). A page whose load catches its own
errors rethrows them to get the back-off; now() refreshes on demand.
const refresh = poll(load, { every: 15000 });
switchEl.addEventListener('change', () => refresh.now());
Server-sent events go through stream(url, {events})
(shared/ss/stream.js), not new EventSource: it closes while the tab is
hidden and opens again when it shows, and after an error it waits - longer
each time, up to maxRetry - instead of letting the browser retry every few
seconds forever. onOpen runs on every (re)connection, for a stream that
replays from the start (a join session's setup log): skip what you have.
test_ui_modules.py fails on a new setInterval or new EventSource outside
shared/ss/.
The consoles keep their state in one store each. The Training console
(ops/state.js): the join graph, the fellows, grades, jobs, the run on show,
the selected node. The Inference console (inference/state.js): the model
and its status, the open chat and its messages - the conversation is data, so
the transcript, the saved chat and the request to the model read the same
list. In both, a module's own working state (the log ring buffer, a
chart's layout) lives inside the function that starts it, never in a
top-level let, which test_ui_modules.py also holds. ECharts stays
imperative behind ops/chart.js (createChart: resize observer, theme
switch, dispose; token() and tokenAlpha() read the colours); its tooltips
are templates rendered to a node, never markup strings.
Chat replies are the one place markup from outside reaches the page:
inference/markdown.js turns a reply into HTML with marked and DOMPurify,
and the transcript puts it in with unsafeHTML. highlight.js, the code
headers and the reasoning toggles run on the message's node after it is
drawn; mermaid and KaTeX run on the final render only. A streamed reply is
redrawn at most once a frame, and only the reply being written is parsed
again (the others are cached by their text).
What checks it¶
| Check | What it holds |
|---|---|
tests/unit/leader/web/test_ui_modules.py |
Hard rules: no inline script but the listed pre-paint ones, each shared helper defined once in shared/ss/, no markup written as a string (innerHTML, insertAdjacentHTML; unsafeHTML only for chat markdown), // @ts-check in every module, no window.SS |
tests/unit/leader/web/test_ui_module_route.py |
/ui/ serves .js only, keeps admin code to admins, caches a versioned URL for good and revalidates any other; the head maps and preloads the page's whole graph |
biome ci . |
Lints every .js file under templates/; the only globals it knows are the vendored classic scripts |
npx typescript@5.7.2 tsc -p jsconfig.json |
Type-checks every module under templates/ (// @ts-check + JSDoc) |
node --test tests/js/*.test.mjs |
The pure modules (shared/ss/ format, status, store, api, palette, poll, stream; the consoles' formats, loss and conversation), without a browser |
node --test tests/ui/*.test.mjs |
What a user can do on each page, in a browser; library.test.mjs drives each shared component and chrome.test.mjs the shell's chrome |
node scripts/frontend_smoke.mjs |
Every page loads without a console error, failed request or serious accessibility finding |