- matrix-rain reference is in packages/dashboard/src/home.js, not swarm-ui — cite it correctly (dashboard package) rather than implying it lives inside this doc's own scope. - drop FormField from the visible component-first inventory (it has no /components demo, deliberately — a real exception to the 'every new ui/ component gets a demo' rule stated two paragraphs later) and note the exception explicitly instead of leaving the contradiction.
9.9 KiB
swarm-ui design guide
Scope: swarm-ui only (the swarm-level Preact app — not the per-hive
dashboard, which has its own older visual language). This doc is the
why: the principles behind how swarm-ui looks and behaves, and the
concrete rules that follow from them. It deliberately doesn't show what
things look like — that's ComponentsPage (/components), the living,
always-current demo of every primitive in src/ui/. Code can't go stale
the way a doc's screenshots can, so treat ComponentsPage as the source
of truth for appearance and this doc as the source of truth for intent.
Colour variables specifically are docs/web-ui/css-vars.md's job, not
repeated here.
Distilled from the design-language discussion in issue #3444 — read that thread for the full reasoning behind any rule below that needs more context than fits here.
Visual language
- Basis: Material Design, the Material You generation (Android's current design language) — but swarm-ui strays fairly far from its theming approach; base16/stylix (below) is the actual colour contract, not Material's dynamic-colour system.
- Frosted / tinted glass where it fits — translucency over flat panels, not a hard rule for every surface.
- Playful use of the multiple accent colours, without tipping into
visual noise — the base16 palette has several accent slots
(
--purple,--cyan,--pink, …); use more than one where it adds distinction, not decoration for its own sake. - Whimsy — small, delightful touches are welcome (the per-hive
dashboard's home-page matrix-rain background,
packages/dashboard/src/ home.js, is the reference example — currently in the dashboard package, not swarm-ui itself, but the pattern it sets applies here too). Whimsy still has to clear the accessibility bar below (motion, in particular). - Efficient navigation — minimize clicks/hops for a common task.
- Avoid junk drawers. A control belongs next to the thing it affects, not tucked into a catch-all menu. Concrete anti-example (not swarm-ui, but the standard to avoid): the per-hive agent terminal's model selector living in a three-dot overflow menu instead of next to the thing that shows the current model.
Motion
General rule, not case-by-case: every non-essential CSS animation
gates on prefers-reduced-motion and pauses when its tab/section is
hidden. The dashboard's matrix-rain background (packages/dashboard/ src/home.js/.css) is the existing reference implementation of this
pattern — genuinely does both today, confirmed by reading it — even
though it lives in the dashboard package rather than swarm-ui; every
future swarm-ui animation follows the same pattern, not just whimsy
pieces.
An in-app override (independent of the OS-level media query, for someone who wants motion on despite a system-wide reduced-motion setting, or vice versa) is tracked separately — see #3456, which needs the client-settings surface from #3453 first.
Prefer CSS-driven animation over JS-driven where possible, and avoid jarring content swaps (layout shift, hard cuts) where a transition can smooth them instead.
Theming
The mechanical contract (base16 slots, semantic vars, what a page's CSS
is and isn't allowed to reference) lives in docs/web-ui/css-vars.md —
read that for the how. This section is the policy layered on top:
- User-theming compatible by construction. The whole point of the
base16/
colors.cssswap contract is that a user's own theme (stylix today) takes over with zero swarm-ui code changes — a generator replaces exactly one file. Don't build anything that assumes a specific palette's exact colours (contrast ratios, "this accent is always purple") beyond what the semantic var names promise. - stylix wins outright when it's active, no in-between state.
nix/host-modules/hive-c0re/theme.nixdecides at build time whether a hive is stylix-themed; when it is, that palette is authoritative — swarm-ui's own light/dark logic (below) doesn't run at all rather than trying to negotiate with it. This is a deliberate simplification, not a limitation to fix: a stylix-using operator has already made their choice. - Light theme is accessibility, not a cosmetic extra — confirmed position on #3444/#3452: some people need light for contrast/low-vision reasons, others need dark for photosensitivity, so "OS preference by default + explicit override" is the right shape, same as motion above. Not shipped yet — tracked as #3452 (light palette itself), #3453 (the client-settings surface an override setting needs), #3454 (the override setting). Until #3452 lands, swarm-ui is dark-only.
Data freshness & refresh
The governing question for anything that shows time-sensitive data: would a user returning to this tab expect current data? If yes, it needs a refresh story; a value that's silently gone stale with no way to tell is worse than one that's visibly stale.
- Relative-time labels must actually tick. A
StatusChip-style "fresh (5s ago)" computed once at fetch and never updated again reads as current when it isn't. Tracked as #3445 (RelativeTimecomponent: takes a UTC datetime, not a precomputed age, and re-renders itself on an internal interval; pauses while its tab is hidden). - Pages that poll get a refresh-interval control, not silent
staleness and not push. SSE/WebSocket push was considered and
rejected for now — "overkill, leads to the same mess we have in
core." Instead: a small, Grafana-like "refresh every: off / 10s / 30s
/ 1m / …" control the operator sets per page, paused while the tab is
backgrounded and resumed on foreground. Tracked as #3446
(
OverviewPage's hive-status roster is the first real caller). - A refresh must never clobber input the operator is mid-edit on. Any future polling component's contract needs to make this the caller's problem to opt out of correctly, not something the next adopter discovers by shipping a bug.
Errors
ApiErrorPanel + the RFC 9457 ProblemDetails shape it renders is
the canonical error surface for every API failure in swarm-ui, not
just its original callers — nobody should build a softer/quieter error
UI later. Concretely: full untruncated detail, a copy button, no
"something went wrong, try reloading."
This follows from power-user-first, one of the standing principles: swarm-ui instances are mostly self-hosted and operators are techies, so errors should give them what they need to actually diagnose a problem rather than a friendly wall. Power-user-first doesn't mean newcomer-hostile — the UI should still be self-explanatory, warn or ask for confirmation before a destructive/dangerous action, and offer helpful hints — it just shouldn't get in the way of someone who already knows what they're doing.
Empty states
Graceful fallbacks, always — a table with zero rows should show something that represents "no rows" (a real empty-state message), not just bare column headers floating over nothing.
Layout & viewport
- swarm-ui adapts to screen size and fully supports touch — this is
not a desktop-only app. Concrete floor: every interactive control from
the shared
ui/kit (buttons, inputs, selects) carries a minimum touch target (2.75em≈ 44px, WCAG 2.5.5) by default, so this isn't something each page has to remember. Tracked follow-through: #3447 (page-level layout — nav wrap, no fixed-width assumptions). - Phone is a second-class citizen for now, not an unsupported one. Things must not break at phone width, but don't over-invest in phone optimization beyond that yet. A PWA manifest (installable, basic status-check/interaction use case) is wanted eventually but is its own future slice, not implied by "supports touch" today.
- A narrow viewport isn't only a phone — a tiling-window-manager user with swarm-ui in a narrow tile hits the same layout constraints a phone does. Design for the constraint (narrow viewport), not the device.
Component-first design
When something needs a table, a form field, a button — build (or adopt)
a src/ui/ primitive that enforces consistent theming and behaviour,
rather than styling it inline on the page that happens to need it first.
Panel/StatusChip/Table/TextField/SelectField/Button are the
primitives that exist as of this writing, each with its own
/components demo; /components always has the current, complete list
— this doc won't try to keep a duplicate inventory in sync.
Default heuristic for when to promote page-scoped styling into a shared primitive: the day a second real page needs the same thing, not speculatively ahead of one. (Exception: when a peer explicitly asks for a primitive ahead of a second caller, as happened with the form kit — that's a judgement call each time, not a rule change.)
Every new ui/ component gets a demo section on /components the
same day it lands — no primitive without a place to see it. One
deliberate exception exists today: FormField, the internal label+
control wrapper TextField/SelectField share, isn't itself a
primitive a page reaches for directly, so it has no demo of its own —
that's a real, considered exception (see its own doc comment), not an
oversight this rule missed.
Attention
"Attention-optimized" here means the system should lead the operator's attention to where it actually matters (an error, a state change worth noticing) — not attention-optimized in the engagement/growth sense of maximizing time-on-page.
Open questions
Tracked separately rather than asserted here as settled:
| Question | Tracked in |
|---|---|
Light theme (palette + prefers-color-scheme default) |
#3452 |
| Client-local settings surface (page + storage) | #3453 |
| Theme override setting | #3454 |
| Reduced-motion override setting | #3456 |
| PWA manifest / installable phone experience | not yet filed — mentioned in #3444, no concrete scope agreed |