Skip to content

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 with pageData() (data-pool-id becomes poolId).
  • 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 with pageText(). 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 a data-* attribute. The CSP allows only scripts with the response's nonce.
  • Use the shared helpers instead of copying one into the page. Import SS as a namespace, so calls read SS.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.json takes every .js file under templates/ but vendor/. 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 this at the top level is undefined.

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 html template. mount(container, view, store) draws it, then again on every store.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 innerHTML and no esc(). unsafeHTML is only for DOMPurify output (the chat's markdown).
  • Bindings: @click=${fn} binds an event, which is addEventListener underneath, so the no-inline-handler rule holds. ?hidden=${flag} binds a boolean attribute, ifDefined(v) an optional one, and styleMap/classMap set styles and classes. Nodes from SS.badge, SS.icon and SS.empty go 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.js only. It is the one module that names lit-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