Skip to content

D227 — The embedded Atlas UI

Decision. Cartographer ships a read-only web Atlas inside the one binary, served at /ui/ in HTTP mode and disableable with web.enabled: false. The frontend is React + Vite + TypeScript with Sigma.js over Graphology; its production bundle is committed under internal/webui/dist and embedded with go:embed. make gate stays Go-only: a pure-Go test compares a provenance hash of web/ against the one the bundle carries, and the frontend build and tests run in a separate CI job scoped to the frontend paths.

Why. An operator had no way to see what a KB holds. The UI has to reach every installation path — Homebrew, winget, the install script, the container, go install, the native service — and go install cannot run npm, so the bundle has to be in the repository. The cost is a generated artifact under version control and a rebuild step a contributor can forget; the provenance test is what makes forgetting loud instead of silent.

Alternatives rejected. Building the UI in CI and shipping it only in release archives: it would leave go install without a UI, which is the path most contributors use. Diffing the whole rebuilt bundle in CI: Vite output is not byte-stable across Node patch releases, so the check would fail for reasons unrelated to the change, and a gate that cries wolf gets bypassed — only provenance.json, a hash of the sources, is compared. Putting Node in make gate: it would make every pure-Go change depend on a toolchain it does not need. Exempting / from authentication so a bare address reaches the UI: it would weaken the middleware's default for every future endpoint, so the courtesy redirect is applied outside the auth chain instead (webui.RedirectRoot), while /ui/ itself is exempt because it is the page that asks for the token.

Consequences. The SPA fallback serves the shell for any unknown path below /ui/ and for nothing outside it; a route-boundary test asserts that /mcp, /health, /ready, /clients, the RFC 9728 metadata and /api/ all still reach their own handlers with the UI mounted. The CSP is default-src 'self' with no inline script and no eval. Two failures found while building this are now design rules with tests behind them: the appearance functions may never emit a node type stock Sigma has no program for, because an unknown type throws inside the renderer and takes the page down to a blank background. Graph layout stays deterministic — the same KB state seeds the same coordinates.

Amended: the graph is still, and the graph is the page. The first version animated the resting graph (a per-node "breath") and re-ran ForceAtlas2 in a blob worker while a node was dragged. The two wrote the same coordinates: the graph shivered at rest and flew apart on a drag, because the simulation re-settled around a node pinned far from its neighbours. Both are gone, and with them the worker and the CSP's worker-src 'self' blob:. The deterministic layout is the picture; the entry stagger is its only motion; a drag moves the one node held, and Reset layout restores the computed positions. Nodes with no link sit on a ring just outside the connected body, so a few orphans no longer decide the zoom. The layout is graph-first: the canvas takes the whole main area, the navigation rail and the node list start folded and open on demand (the choice is remembered per viewer), and the inspector exists only while a concept is selected, floating over the canvas with the selection centred in the strip it leaves visible. [[wiki-links]] in a concept body render as in-atlas links.

Encoding. Node colour has two channels the viewer switches between, and the choice is remembered: by Map (which collection a concept is filed in) and, by default, by community — Louvain over the link graph (graphology-communities-louvain), which answers "what does this belong with" and agrees with what the force layout draws, where a per-Map hue paints a whole Map one flat colour. Communities are made deterministic like the layout — the input in server order, Louvain seeded and without its random walk, community numbers discarded and re-ranked by size then smallest member — because a node that changes colour on reload is as disorienting as one that moves. The twelve largest communities take the twelve audited hues and the long tail shares one neutral slot: thirteen colours cannot be told apart. There is no shape channel: stock Sigma registers one node program, and a shape would need a further node-program dependency; expanded concepts read as heavier instead. The type and status filters need type and status on each graph node, an additive field on D226's snapshot.