Admin UI — the VayuOS console
Last reviewed: 2026-10-17, against VayuPress 3.18.7
The operator console lives at /os. It is server-rendered Go HTML with
HTMX fragment swaps, Alpine.js islands where a page needs local state
(ADR-0136), and one same-origin script per app. There is no client framework
and no build step.
The earlier consoles are gone. /admin, /admin/v2[/…] and /admin/v3[/…]
answer with a permanent redirect to their /os equivalent
(cmd/vayupress/admin_legacy.go). The JSON API (/api/v1/*) and the operator
sub-pages such as /admin/modes and /admin/faults have their own lifecycle and
are not redirected.
Design constraints
Every console page is served under the strict policy built by
render.BuildCSP (internal/render/csp.go):
default-src 'self'; font-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline';
script-src 'self' 'nonce-<nonce>' <theme-toggle hash> <Halcyon prefs hash>;
img-src 'self' data: https:; connect-src 'self'; frame-ancestors 'none';
base-uri 'self'; form-action 'self'; report-uri /csp-report
Violations are reported to /csp-report and logged. The only relaxation in
the codebase, BuildCSPAllowingEval, is an opt-in for one hosted static site
and is refused on the console, the API and any path that carries a session.
What that means in practice:
- No CDN, no
eval, nonew Function. HTMX 2 is vendored asstatic/js/htmx.min.js, and Alpine as its eval-free CSP build (alpine-csp.min.js, with the island registryvayu-islands.js). Everything else is first-party. - Every inline
<script>carries the per-request nonce (render.CSPNonce). Event handlers are attached from script, never withonclick=/onerror=attributes. - Stylesheets are
'self'only. Inlinestyle="…"attributes are allowed (style-src-attr);<style>elements are not. - Fonts are self-hosted woff2 under
/static/fonts/: Space Grotesk (headings), Inter (body), JetBrains Mono (code). Newsreader beside them is Halcyon's, for the public site.
A page that breaks one of these does not degrade gracefully — the browser blocks it. Test in a real browser against a built binary, not only with Go tests.
Assets
Console assets are compiled into the binary. At boot they are written to
STATIC_DIR, and serveAdminOSAsset serves the disk copy, falling back to the
embedded one when the directory is missing or read-only. URLs carry
?v=<assetVer> so a release never serves a stale script.
| File | Role |
|---|---|
static/css/vayuos.css |
The console stylesheet, Still Air: tokens, shell, components |
static/js/admin-os.js |
Shell bootstrap: theme, toasts, command palette, list keyboard layer, shared helpers |
static/js/admin-os-<app>.js |
One script per app (twenty: mail, talk, members, editor, website, storage, …), loaded only by that app's page |
static/js/htmx.min.js |
Vendored HTMX |
static/js/alpine-csp.min.js, vayu-islands.js |
Alpine's CSP build and the islands that use it |
admin-os.js is one closure of init blocks. Anything that uses its helpers
($, $$, on) must sit inside it; the list keyboard layer once sat after the
closing line and threw on every keypress. tests/e2e/console.spec.js runs the
shell in Chromium and fails on any page error.
Shared client helpers
Use these rather than writing another copy:
| Helper | Where | Use |
|---|---|---|
vpToast(msg, kind) |
admin-os.js |
Non-blocking feedback. Kinds: ok, error, warn, info (success/danger/warning are mapped) |
vpConfirm(opts, onYes) |
inline shell script, admin_os_ui.go |
The console's confirmation modal |
vpPrompt(opts, onDone) |
inline shell script, admin_os_ui.go |
The console's single-field input modal |
vpPost(url, body, onok, onerr) |
admin-os.js |
CSRF-protected JSON POST that reports every failure |
vpCsrf() |
admin-os.js |
The double-submit token for hand-rolled requests (X-CSRF-Token) |
vpRefresh(), vpRelTime(t) |
admin-os.js |
Redraw the current page's live regions; "3 min ago" |
The command palette's actions are not a client registry. The server builds
them (cmdAction in admin_os_ui.go), each gated on the operator's access
to the page it acts for, so the palette never offers what the role cannot do.
Native alert/confirm/prompt are forbidden in console scripts;
TestConsoleScriptsUseTheConsolesOwnDialogs scans every file. HTMX's own
hx-confirm is routed through vpConfirm by the shell.
Design tokens
Tokens are CSS custom properties on .vp-os[data-ui="still-air"], redefined
for the light theme and for prefers-color-scheme when the theme is auto.
Hex values appear only in those token blocks; a rule never names a colour. The
authoritative values are in static/css/vayuos.css; the ones most code needs:
| Token | Role |
|---|---|
--color-canvas, --surface-1, --surface-2, --surface-float |
Page and layered surfaces |
--text-1, --text-2, --text-3 |
Primary, secondary and helper copy |
--accent, --on-accent |
Primary actions and the text on them |
--ok, --warn, --danger (each with -soft) |
Status colours and their tinted backgrounds |
--border-1, --border-2 |
Hairlines |
--space-1 … --space-10, --radius-*, --type-* |
Space, shape and type |
The classic names (--brand, --bg-surface, --sp-4, …) are aliases onto
these, kept for the rules carried from the classic sheet; new rules use the
Still Air names.
Contrast is enforced, not hoped for: TestStillAirPaletteClearsAA fails when
a reading pairing drops below its WCAG bar in either theme. The console also
carries its own prefers-contrast and forced-colors rules.
Theme. light / dark / auto, toggled from the top bar and saved as the
admin.theme setting. The console mirrors the choice into
localStorage['vp-os-theme'], which the sign-in pages read (os-theme.js)
because nobody is signed in there to read the setting.
Media storage
Uploads are written to MEDIA_DIR (default /var/lib/vayupress/media) and
served same-origin from /media/{file}.