| name | webui-design-system |
| description | NiceGUI web UI design system, color semantics, CSS patterns, and testing conventions. Use when modifying scripts/webui/ pages, theme.py, or writing test_webui_* tests. |
Web UI Design System
Context
The NiceGUI web UI (scripts/webui/) serves three roles: SuperManager
(fleet deployment), Manager (host management), and Kiosk (app launcher).
A shared theme.py defines the dark design system used across all pages.
Rules
- NEVER use red (
COLOR_ERROR, t-error) for non-error states. Red = critical
error/failure ONLY. "No data," "stopped," "not configured," "no WoL" are NOT errors.
- ALWAYS use grey (
TEXT_DISABLED, t-disabled) for unknown/no-data/inactive states.
- ALWAYS use orange (
COLOR_WARNING, t-warning) for limitations, cautions, or
unconfigured states that need attention but are not failures.
- NEVER hardcode hex colors in page files. Use theme constants or theme helper
functions (
signal_color(), health_score_color()).
- ALWAYS use design system CSS classes (
.action-btn, .outline-btn, .t-primary,
.mono-val, .metric-label, .card-title, .section-label, .sep-accent)
instead of inline styles for recurring patterns.
- NEVER use broad
except Exception in UI code. Catch specific exceptions
(httpx.HTTPError, OSError, ValueError).
- ALWAYS ensure sidebar/nav icons are visually distinct — no two nav items
should share the same icon.
- All new data helpers MUST have type hints and tests in
test_webui_data.py.
8b. NEVER show raw exit codes, return codes, or internal identifiers to users.
Map them to human-readable descriptions (e.g., exit code 2 → "host
unreachable or task failed").
- NEVER hardcode UI strings in tests. Import
Routes, PageTitles, Labels,
ApiRoutes from scripts.webui.data. Change the constant once, tests follow.
- ALWAYS use
data.DISPLAY_APPS, data.HUB_SERVICES, data.NAV_SECTIONS,
data.KIOSK_NAV_ITEMS in tests instead of repeating labels/routes/vmids.
- When adding a new page: add its route to
Routes, title to PageTitles,
and any shared labels to Labels. Use these in both the page module and tests.
- When adding a new button label used across pages, add it to
Labels class.
MVC Architecture: Domain Model → Data Layer → UI
The web UI follows a strict Model-View-Controller separation. Business logic
lives in domain objects (data.py), and page modules are thin UI renderers
that read properties — never compute state.
Three-layer rule
- Domain model (
Host, Fleet, DeployRecord in data.py) —
owns all state, health computation, and error descriptions. Properties
like .healthy, .errors, .last_deploy encapsulate the rules.
- Factory functions (
build_fleet(), get_known_hosts() in data.py) —
wire data sources (env vars, JSON files) into domain objects.
- UI pages (
pages/dashboard.py, etc.) — read domain object properties
and render. ZERO if/else health logic. ZERO exit code mapping.
NEVER put business logic in page modules
if last.exit_code == 0:
ui.badge("success", color="green")
else:
desc = EXIT_CODES.get(last.exit_code, "unknown")
ui.badge(f"failed — {desc}", color="red")
if fleet.healthy:
ui.badge("success", color="green")
else:
ui.badge(data.exit_code_label(last.exit_code), color="red")
for err in fleet.errors:
ui.label(err)
Domain object design
Domain classes MUST expose:
.healthy → bool — single source of truth for health state
.errors → list[str] — human-readable error descriptions
- Telemetry properties (
.online, .disk_pct, .memory_pct, .uptime,
.guests, .guest_count, .version, etc.) — graceful defaults when
no heartbeat data is available
- Aggregate objects (e.g.,
Fleet) delegate to children and provide fleet-wide
metrics (.online_count, .total_guests, .avg_disk_pct, .health_score)
Host class (identity + deploy + telemetry)
class Host:
telemetry: HostTelemetry | None
@property
def healthy(self) -> bool: ...
@property
def errors(self) -> list[str]: ...
@property
def online(self) -> bool: ...
@property
def disk_pct(self) -> float: ...
@property
def guests(self) -> list[GuestInfo]: ...
@property
def guest_count(self) -> int: ...
@property
def vms(self) -> list[GuestInfo]: ...
@property
def containers(self) -> list[GuestInfo]: ...
def attach_telemetry() -> :
Fleet class (aggregate)
class Fleet:
@property
def healthy(self) -> bool: ...
@property
def errors(self) -> list[str]: ...
@property
def online_count(self) -> int: ...
@property
def total_guests(self) -> int: ...
@property
def avg_disk_pct(self) -> float: ...
@property
def health_score(self) -> int: ...
@property
def worst_disk(self) -> Host | None: ...
@property
def has_telemetry(self) -> bool: ...
def get_host(self, name: str) -> Host | None: ...
Key design decisions
HostTelemetry is a SEPARATE dataclass — telemetry may be None (no heartbeat)
and this cleanly separates "configured host" from "observed host"
GuestInfo is parsed ONCE in attach_telemetry(), not on every property access
Fleet replaces FleetHealth as the primary aggregate — one object, not two
build_fleet() is the SINGLE entry point — reads env, deploy history, AND
node registry, producing a fully wired Fleet
Factory pattern: wiring data to domain objects
def build_fleet(env: dict[str, str], state_dir: Path) -> Fleet:
host_infos = get_known_hosts(env)
history = load_deploy_history(state_dir)
nodes = load_node_registry(state_dir)
node_map = {n.hostname: n for n in nodes}
hosts = []
for info in host_infos:
host = Host(name=info.name, ip=info.ip, ...)
for record in history:
if _deploy_targets_host(record, host.name):
host.deploys.append(record)
node = node_map.get(info.name)
if node:
host.attach_telemetry(HostTelemetry(...))
hosts.append(host)
return Fleet(hosts)
Per-host detail page (/nodes/{hostname})
The detail page is a Proxmox VE-inspired single-host view:
- Summary header with status dot, hostname, IP, uptime, version
- Resource gauges (disk, memory) with color thresholds
- Guests table (VMID, name, type, status) from
host.guests
- Network info from
host.local_ips and host.extensions.network
- Deploy history with pass/fail badges
- Extensions panel (WireGuard, Docker, etc.)
- Resource history sparklines
ALWAYS navigate to /nodes/{hostname} when a host card is clicked.
When to add a new domain class
Add a domain class when:
- A UI page computes state from raw data (exit codes, timestamps, counts)
- The same computation appears in multiple pages
- Tests need to verify business logic without spinning up NiceGUI
Keep as plain functions/dataclasses when:
- The data is pass-through (display only, no derived state)
- There's no
.healthy/.errors pattern to encapsulate
Testing domain objects
Domain classes are pure Python — test without NiceGUI:
class TestHost:
def test_recovers_after_success_following_failure(self):
host = data.Host("home", "192.168.86.201")
host.deploys.append(make_record(exit_code=2))
host.deploys.append(make_record(exit_code=0))
assert host.healthy is True
assert host.errors == []
class TestHostWithTelemetry:
def test_online_with_telemetry(self):
host = data.Host("home", "192.168.86.201")
host.attach_telemetry(make_telemetry(status="online"))
assert host.online is True
assert host.guest_count == 3
class TestFleetAggregates:
def test_health_score_all_online(self):
fleet = data.Fleet([make_host_with_telemetry("a"), ...])
assert fleet.health_score == 100
Color Semantics
Green (COLOR_SUCCESS / t-success) — running, healthy, reachable, ready
Orange (COLOR_WARNING / t-warning) — stale, limitation, caution, unconfigured
Red (COLOR_ERROR / t-error) — error, crashed, failed, critical alert
Grey (TEXT_DISABLED / t-disabled) — offline, unknown, no data, inactive
Grey (TEXT_SECONDARY / t-secondary) — stopped (intentional), muted info
Blue (info) — informational, first-time setup prompts
Patterns
Status indicator colors
return ui.icon("circle").classes("t-error")
return ui.icon("circle").classes("t-disabled")
if status == "stopped": return theme.COLOR_ERROR
if status == "stopped": return theme.TEXT_SECONDARY
if status in ("error", "crashed"): return theme.COLOR_ERROR
Signal strength colors
Use theme.signal_color(quality) instead of hardcoding:
if signal_dbm > -50: sig_color = "#2dd4bf"
elif signal_dbm > -60: sig_color = "#4ade80"
sig_color = theme.signal_color(signal_quality(signal_dbm))
Scalable test patterns
Tests import constants from scripts.webui.data — never hardcode UI strings:
from scripts.webui.data import Routes, PageTitles, Labels, ApiRoutes
await user.open(Routes.DEPLOY)
f"{Routes.LAUNCH}?vmid={_moon['vmid']}"
await user.should_see(PageTitles.NODES)
user.find(Labels.START_DEPLOY).click()
resp = await client.post(ApiRoutes.CHECKIN, json=payload)
_hub_svc = {s.key: s for s in data.HUB_SERVICES}
await user.should_see(_hub_svc["jellyfin"].tag)
_moon = data.DISPLAY_APPS["MOONLIGHT_URL"]
f"{Routes.LAUNCH}?vmid={_moon['vmid']}&title={_moon['label']}"
for section in {s.section for s in data.get_hub_services()}:
await user.should_see(section)
_BRIDGE_NODES = data.get_bridge_nodes()
_MESH_AP, _MESH_STAS = data.get_mesh_nodes()
Kiosk nav bar
The kiosk nav bar shows "Home" (icon button) + breadcrumb, NOT "Home Hub".
Tests use Labels.HOME — await user.should_see(Labels.HOME).
Hub sections
Sections are derived from data.HUB_SERVICES — never hardcode the list.
Tests iterate {s.section for s in data.get_hub_services()}.
Previous bugs
- Red overuse: status dots, stopped containers, "No WoL" badges, disconnected
mesh nodes, "No env file" messages all used red. Users interpreted these as
errors. Fix: systematic audit replacing with grey/orange per semantics above.
- Duplicate icons: Containers and Hosts both used
dns icon. Fix: Containers
changed to view_in_ar.
- Hardcoded batman color
#4ade80 in mesh.py instead of theme.COLOR_SUCCESS.
- Hardcoded signal colors in bridge.py instead of
theme.signal_color().
except Exception in containers.py caught too broadly. Fix: catch
(httpx.HTTPError, OSError).
format_last_seen_relative crashed on timezone-aware ISO timestamps due to
naive/aware datetime subtraction. Fix: .replace(tzinfo=None).
- Tests referencing old section names ("Desktop & Media") after hub sections
were renamed to "Entertainment". 26 tests failed silently until full suite run.
Fix: all tests now import constants from
data.py (Routes, PageTitles,
Labels, ApiRoutes) so label changes propagate automatically.
- Magic strings in 6 test files duplicated 200+ labels, routes, and page titles.
Changing a button label required updating 5+ test files. Fix: centralized all
UI strings into
data.py constants consumed by both page modules and tests.
- "rc=2" in deploy badges was meaningless to users (mistaken for "release
candidate"). Fix: map Ansible exit codes to human-readable descriptions
(e.g., "failed — host unreachable or task failed") via
_exit_code_label().
- "monitoring" Material Icon in the fleet View button looked like a desktop
monitor and overlapped adjacent buttons. Fix: changed to
lan icon.
- Fleet card showed "No nodes registered" when no callhome server was running,
giving the impression the hosts didn't exist. Fix: show configured host names
from env as grey badges with "waiting for heartbeats" message.
- Business logic (exit code mapping, health computation, error aggregation)
was embedded in
dashboard.py UI functions. Changing health rules required
editing UI code and couldn't be unit-tested without NiceGUI. Fix: extracted
Host, Fleet domain classes and build_fleet() factory into data.py.
Dashboard became a thin renderer reading .healthy and .errors properties.
29 pure-Python tests cover all health scenarios without UI overhead.
Related skills
- webui-ux-principles — Higher-level UX design principles (color language,
icon choices, layout rules, information hierarchy, click reduction).
- webui-manual-testing — Step-by-step manual testing checklist for all
three UI roles (SuperManager, Manager, Kiosk).
- code-review-checklist — MVC/OOP review questions for web UI diffs.