Skip to content

UI and the design system

The operator console is a SolidJS SPA styled with daisyUI 5 on Tailwind CSS 4. It is a generated client of the API (typed via openapi-fetch off the committed openapi.json). The same surfaces are also the learning surfaces (see the learning-tool restriction).

ConcernChoice
FrameworkSolidJS (solid-js, @solidjs/router)
Components / themedaisyUI 5 on Tailwind CSS v4 (the omniglass-dark / omniglass-light themes)
Interactive primitivesKobalte (Dialog for the palette and Drawer; daisyUI dropdown for menus), styled by daisyUI
Data fetching@tanstack/solid-query over a typed openapi-fetch client
Build / testVite, Vitest, @solidjs/testing-library
Flow / graph viz (future)for the learning + explore surfaces; not built yet
Dashboards (future)a widget grid for the dashboards surface; not built yet

The typed client is generated, never hand-written: openapi-typescript turns openapi.json into schema.gen.ts, so a route or shape change surfaces as a TypeScript error in the SPA. The cobra CLI is generated the same way. make gen regenerates all of it; a non-empty diff fails the slice.

  • One inventory shell: ListView<N>. Every inventory page (Components, Systems, Locations) is a ListConfig over the one shell, never a fork. The shell owns the faceted filter header, the action rail (tree/list toggle, expand/collapse, column visibility + drag reorder, the primary create), tree and flattened rendering, the stacked detail blades, the full-page detail, the create/edit Drawer, and an optional summary widget board. Adding an entity of this class is a data layer + a config + a route (see the add-inventory-view skill).
  • The faceted filter is a tested engine. lib/predicate is the pure matcher: values within a chip are OR, chips across keys are AND, clicking an active facet removes it. FilterBar is the thin staged combobox over it; the genuinely tricky list derivations (index, ancestor paths, flatten-vs-tree rows, client-preference parsing) are pure in lib/listmodel. Both are unit tested; FilterBar has a component test.
  • can(me, resource, action) from /auth/me. The console reads the principal’s flat, wildcard-expanded permissions once and gates UI affordances with O(1) checks; ListView gates create/update/delete by the entity’s resource name. The server is the authority; this is a hint only.
  • Blades are ephemeral, the full page is addressable. A row opens a stacked blade (the Azure model); Maximize promotes it to the /<entity>/:name URL. The blade stack holds node ids, so a blade survives a refetch.
  • The shell owns the action rail; the body registers, never draws. A panel’s buttons are declared, not laid out: a blade body binds through lib/blades (destructive, secondary, primary, plus the Edit/Save cycle) and a Drawer form body binds through lib/formactions (submitLabel, submitIcon, submit, busy, disabled, cancel). BladeStack and Drawer each draw the resulting bar, both through the one PanelFooter rail, so spacing and chrome cannot drift between them. A body that renders its own button row is a bug, and rail-ownership.test.ts fails on it. This replaced an opt-in DrawerFooter helper that each form had to remember to wrap its buttons in: two forms forgot, and stayed wrong for months while the helper was copied into six new pages around them. A convention can be forgotten; a slot cannot. Full-page create forms still draw their own inline rail and converge when the CRUD form primitive lands.
  • Client preferences in localStorage, for now. Column order/visibility and the widget board persist per browser; the eventual home is a per-principal user-preferences endpoint (a read/write swap), not the cascade.
  • Learning surfaces ride the real engine. A concept page renders the actual pipeline against real or lab-simulated data, not a static diagram. The flow/graph library for these lands with the explore/learn surfaces.

Buttons use a small set of semantic intent classes defined in app.css, never the raw daisyUI color/emphasis classes, so styling is unified and a future theme restyles every button from the theme tokens in one place. One intent per button; structural btn, size (btn-sm / btn-xs), and shape (btn-square) still come from daisyUI.

IntentClassUse
Primary actionbtn-actionthe main action (Save, Create, Edit, New): filled
Secondary / quietbtn-quietCancel, icon buttons, low-emphasis actions
Destructivebtn-dangerrevoke, delete
State togglebtn-warn / btn-oka reversible toggle that reads its state (Disable is a warning, Enable a success)

The edit flow reads the same everywhere: Edit is a filled btn-action with a pencil, Save is a filled btn-action with a disk icon, Cancel is a btn-quiet with an X. Create submits are btn-action with a plus. The icons come from the local icons set, so a control looks identical on every page.

The intents are @apply-composed from daisyUI in app.css, so they inherit the theme’s tokens: color lives in the tokens, not the markup. A style-guard test scans the source and fails the build on any raw daisyUI color/emphasis button class (btn-primary, btn-ghost, btn-outline, btn-soft, btn-error, btn-success, btn-warning, and the rest), so the vocabulary cannot drift back to one-off styling.

The primary button (and every other primary-filled surface: solid badges, selected chips, avatars) is the bright brand teal with a dark ink foreground, driven by the theme’s own --color-primary / --color-primary-content. daisyUI 5’s @apply btn-primary drops the primary foreground and lets the filled button inherit the near-white base-content (1.8:1 on the teal, failing WCAG), so a single unlayered .btn-action:not(:disabled) rule restores the theme foreground. The dormant light theme also needs a darker teal for teal text on white (a [data-theme=light] .text-primary rule), since the bright teal is unreadable as small text on white (1.9:1) while it reads 8.3:1 as a fill with dark ink; that rule is inert while the console is dark-only.

Status badges use badge badge-sm with a soft hue for a signalled state (badge-soft badge-success for up/enabled/responding, badge-soft badge-error for down, badge-soft badge-warning for stale). A neutral state (a node that has never checked in, a disabled task, an unknown verdict) does not use badge-neutral or badge-ghost: against this theme’s dark base-100 (#080c16), badge-neutral renders near-black and badge-ghost renders transparent, so both read as invisible. Use a soft grey fill tinted from the text color instead (bg-base-content/10 text-base-content/70 border-transparent), which reads as a visible pill in both themes at the same weight as the soft hues. The same reason keeps type values (interface/task type) as plain font-data text, not a badge-neutral chip.

ListView, FilterBar, Drawer, PanelFooter, Donut, Badge, Fact, Page, DataTable, CommandPalette, plus the Sidebar / TopBar shell. New inventory pages consume these; new surface classes (dashboards, alarms, explore, learn) add their own primitive rather than bending ListView.

The SPA builds with Vite (npm run build, into internal/webui/dist) and is embedded into the Go binary under the web build tag, served at /web. One artifact serves the API and the console. In dev, npm run dev serves the SPA on :5173 with /api proxied to a locally-running omniglass server, so the frontend loop needs no rebuild.

Component-level tests (Vitest + @solidjs/testing-library) cover the interactive widgets and the pure list/filter logic (lib/predicate, lib/listmodel, FilterBar, the data layers). The browser-driven e2e tier (drive the console as a user against the full stack) is the remaining gate per the test-first doctrine; until it lands, user-observable behavior is verified by hand/Playwright and is not yet a committed gate.

This page is the build and dev guide for the console: the stack, the generated client, the ListView shell and its primitives, and the build-and-embed pipeline. The architecture (the information architecture, the read-side BFF, the live-update model) is UI on the architecture spine. Build mechanics live here; the model lives there.