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
# BAD: health logic in the UI layer
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")
# GOOD: UI reads domain object properties
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 # None = no heartbeat yet
@property
def healthy(self) -> bool: ... # deploy health
@property
def errors(self) -> list[str]: ... # deploy + offline errors
@property
def online(self) -> bool: ... # from telemetry.status
@property
def disk_pct(self) -> float: ... # 0.0 if no telemetry
@property
def guests(self) -> list[GuestInfo]: ... # parsed from services
@property
def guest_count(self) -> int: ...
@property
def vms(self) -> list[GuestInfo]: ...
@property
def containers(self) -> list[GuestInfo]: ...
def attach_telemetry(self, t: HostTelemetry) -> None:
"""Wire heartbeat data; parses guests once."""
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: ... # 0-100
@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) # heartbeat data
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(...)) # from RegisteredNode
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
# BAD: red for "no data"
return ui.icon("circle").classes("t-error")
# GOOD: grey for "no data"
return ui.icon("circle").classes("t-disabled")
# BAD: red for stopped container
if status == "stopped": return theme.COLOR_ERROR
# GOOD: neutral grey for stopped, red only for crashed
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:
# BAD: hardcoded gradient in page
if signal_dbm > -50: sig_color = "#2dd4bf"
elif signal_dbm > -60: sig_color = "#4ade80"
# GOOD: centralized via theme
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
# Routes
await user.open(Routes.DEPLOY) # not "/deploy"
f"{Routes.LAUNCH}?vmid={_moon['vmid']}" # parameterized routes
# Page titles
await user.should_see(PageTitles.NODES) # not "Fleet Nodes"
# Button labels
user.find(Labels.START_DEPLOY).click() # not "Start Deploy"
# API routes
resp = await client.post(ApiRoutes.CHECKIN, json=payload)
# Hub service data — derive from data model
_hub_svc = {s.key: s for s in data.HUB_SERVICES}
await user.should_see(_hub_svc["jellyfin"].tag) # not "Media Server"
# Display apps — derive vmid/label from data
_moon = data.DISPLAY_APPS["MOONLIGHT_URL"]
f"{Routes.LAUNCH}?vmid={_moon['vmid']}&title={_moon['label']}"
# Hub sections — loop instead of hardcoded list
for section in {s.section for s in data.get_hub_services()}:
await user.should_see(section)
# Bridge/mesh nodes — from data helpers
_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.
RegisteredNode (heartbeat data) and Host (env config) were separate
objects representing the same entity. Dashboard read raw nodes.json in
parallel with Fleet. Nodes list used RegisteredNode directly. Fix: merged
telemetry into Host via HostTelemetry dataclass and attach_telemetry().
build_fleet() now loads all three data sources (env, deploy history,
nodes.json) and produces a single fully-wired Fleet. Added GuestInfo
for parsed service entries, Host.guests/.vms/.containers properties,
and Fleet-level aggregates (.online_count, .total_guests, .health_score).
50+ new domain model tests cover telemetry, guests, and aggregates.
- Per-host detail page was missing. Users had to scan fleet cards for host
info. Fix: added
/nodes/{hostname} route with Proxmox VE-inspired layout
(resources, guests table, network, deploy history, extensions, sparklines).
Node list cards are now clickable → detail page.
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.
1---2name: webui-design-system3description: 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.4---56# Web UI Design System78## Context910The NiceGUI web UI (scripts/webui/) serves three roles: SuperManager11(fleet deployment), Manager (host management), and Kiosk (app launcher).12A shared theme.py defines the dark design system used across all pages.1314## Rules15161. NEVER use red (`COLOR_ERROR`, `t-error`) for non-error states. Red = critical17 error/failure ONLY. "No data," "stopped," "not configured," "no WoL" are NOT errors.182. ALWAYS use grey (`TEXT_DISABLED`, `t-disabled`) for unknown/no-data/inactive states.193. ALWAYS use orange (`COLOR_WARNING`, `t-warning`) for limitations, cautions, or20 unconfigured states that need attention but are not failures.214. NEVER hardcode hex colors in page files. Use theme constants or theme helper22 functions (`signal_color()`, `health_score_color()`).235. ALWAYS use design system CSS classes (`.action-btn`, `.outline-btn`, `.t-primary`,24 `.mono-val`, `.metric-label`, `.card-title`, `.section-label`, `.sep-accent`)25 instead of inline styles for recurring patterns.266. NEVER use broad `except Exception` in UI code. Catch specific exceptions27 (`httpx.HTTPError`, `OSError`, `ValueError`).287. ALWAYS ensure sidebar/nav icons are visually distinct — no two nav items29 should share the same icon.308. All new data helpers MUST have type hints and tests in `test_webui_data.py`.318b. NEVER show raw exit codes, return codes, or internal identifiers to users.32 Map them to human-readable descriptions (e.g., exit code 2 → "host33 unreachable or task failed").349. NEVER hardcode UI strings in tests. Import `Routes`, `PageTitles`, `Labels`,35 `ApiRoutes` from `scripts.webui.data`. Change the constant once, tests follow.3610. ALWAYS use `data.DISPLAY_APPS`, `data.HUB_SERVICES`, `data.NAV_SECTIONS`,37 `data.KIOSK_NAV_ITEMS` in tests instead of repeating labels/routes/vmids.3811. When adding a new page: add its route to `Routes`, title to `PageTitles`,39 and any shared labels to `Labels`. Use these in both the page module and tests.4012. When adding a new button label used across pages, add it to `Labels` class.4142## MVC Architecture: Domain Model → Data Layer → UI4344The web UI follows a strict Model-View-Controller separation. Business logic45lives in domain objects (`data.py`), and page modules are thin UI renderers46that read properties — never compute state.4748### Three-layer rule49501. **Domain model** (`Host`, `Fleet`, `DeployRecord` in `data.py`) —51 owns all state, health computation, and error descriptions. Properties52 like `.healthy`, `.errors`, `.last_deploy` encapsulate the rules.532. **Factory functions** (`build_fleet()`, `get_known_hosts()` in `data.py`) —54 wire data sources (env vars, JSON files) into domain objects.553. **UI pages** (`pages/dashboard.py`, etc.) — read domain object properties56 and render. ZERO if/else health logic. ZERO exit code mapping.5758### NEVER put business logic in page modules5960```python61# BAD: health logic in the UI layer62if last.exit_code == 0:63 ui.badge("success", color="green")64else:65 desc = EXIT_CODES.get(last.exit_code, "unknown")66 ui.badge(f"failed — {desc}", color="red")6768# GOOD: UI reads domain object properties69if fleet.healthy:70 ui.badge("success", color="green")71else:72 ui.badge(data.exit_code_label(last.exit_code), color="red")73 for err in fleet.errors:74 ui.label(err)75```7677### Domain object design7879Domain classes MUST expose:80- `.healthy` → `bool` — single source of truth for health state81- `.errors` → `list[str]` — human-readable error descriptions82- Telemetry properties (`.online`, `.disk_pct`, `.memory_pct`, `.uptime`,83 `.guests`, `.guest_count`, `.version`, etc.) — graceful defaults when84 no heartbeat data is available85- Aggregate objects (e.g., `Fleet`) delegate to children and provide fleet-wide86 metrics (`.online_count`, `.total_guests`, `.avg_disk_pct`, `.health_score`)8788#### Host class (identity + deploy + telemetry)8990```python91class Host:92 telemetry: HostTelemetry | None # None = no heartbeat yet9394 @property95 def healthy(self) -> bool: ... # deploy health96 @property97 def errors(self) -> list[str]: ... # deploy + offline errors98 @property99 def online(self) -> bool: ... # from telemetry.status100 @property101 def disk_pct(self) -> float: ... # 0.0 if no telemetry102 @property103 def guests(self) -> list[GuestInfo]: ... # parsed from services104 @property105 def guest_count(self) -> int: ...106 @property107 def vms(self) -> list[GuestInfo]: ...108 @property109 def containers(self) -> list[GuestInfo]: ...110111 def attach_telemetry(self, t: HostTelemetry) -> None:112 """Wire heartbeat data; parses guests once."""113```114115#### Fleet class (aggregate)116117```python118class Fleet:119 @property120 def healthy(self) -> bool: ...121 @property122 def errors(self) -> list[str]: ...123 @property124 def online_count(self) -> int: ...125 @property126 def total_guests(self) -> int: ...127 @property128 def avg_disk_pct(self) -> float: ...129 @property130 def health_score(self) -> int: ... # 0-100131 @property132 def worst_disk(self) -> Host | None: ...133 @property134 def has_telemetry(self) -> bool: ...135136 def get_host(self, name: str) -> Host | None: ...137```138139#### Key design decisions140141- `HostTelemetry` is a SEPARATE dataclass — telemetry may be None (no heartbeat)142 and this cleanly separates "configured host" from "observed host"143- `GuestInfo` is parsed ONCE in `attach_telemetry()`, not on every property access144- `Fleet` replaces `FleetHealth` as the primary aggregate — one object, not two145- `build_fleet()` is the SINGLE entry point — reads env, deploy history, AND146 node registry, producing a fully wired Fleet147148### Factory pattern: wiring data to domain objects149150```python151def build_fleet(env: dict[str, str], state_dir: Path) -> Fleet:152 host_infos = get_known_hosts(env)153 history = load_deploy_history(state_dir)154 nodes = load_node_registry(state_dir) # heartbeat data155 node_map = {n.hostname: n for n in nodes}156157 hosts = []158 for info in host_infos:159 host = Host(name=info.name, ip=info.ip, ...)160 for record in history:161 if _deploy_targets_host(record, host.name):162 host.deploys.append(record)163 node = node_map.get(info.name)164 if node:165 host.attach_telemetry(HostTelemetry(...)) # from RegisteredNode166 hosts.append(host)167 return Fleet(hosts)168```169170### Per-host detail page (`/nodes/{hostname}`)171172The detail page is a Proxmox VE-inspired single-host view:173- Summary header with status dot, hostname, IP, uptime, version174- Resource gauges (disk, memory) with color thresholds175- Guests table (VMID, name, type, status) from `host.guests`176- Network info from `host.local_ips` and `host.extensions.network`177- Deploy history with pass/fail badges178- Extensions panel (WireGuard, Docker, etc.)179- Resource history sparklines180181ALWAYS navigate to `/nodes/{hostname}` when a host card is clicked.182183### When to add a new domain class184185Add a domain class when:186- A UI page computes state from raw data (exit codes, timestamps, counts)187- The same computation appears in multiple pages188- Tests need to verify business logic without spinning up NiceGUI189190Keep as plain functions/dataclasses when:191- The data is pass-through (display only, no derived state)192- There's no `.healthy`/`.errors` pattern to encapsulate193194### Testing domain objects195196Domain classes are pure Python — test without NiceGUI:197198```python199class TestHost:200 def test_recovers_after_success_following_failure(self):201 host = data.Host("home", "192.168.86.201")202 host.deploys.append(make_record(exit_code=2))203 host.deploys.append(make_record(exit_code=0))204 assert host.healthy is True205 assert host.errors == []206207class TestHostWithTelemetry:208 def test_online_with_telemetry(self):209 host = data.Host("home", "192.168.86.201")210 host.attach_telemetry(make_telemetry(status="online"))211 assert host.online is True212 assert host.guest_count == 3213214class TestFleetAggregates:215 def test_health_score_all_online(self):216 fleet = data.Fleet([make_host_with_telemetry("a"), ...])217 assert fleet.health_score == 100218```219220## Color Semantics221222```223Green (COLOR_SUCCESS / t-success) — running, healthy, reachable, ready224Orange (COLOR_WARNING / t-warning) — stale, limitation, caution, unconfigured225Red (COLOR_ERROR / t-error) — error, crashed, failed, critical alert226Grey (TEXT_DISABLED / t-disabled) — offline, unknown, no data, inactive227Grey (TEXT_SECONDARY / t-secondary) — stopped (intentional), muted info228Blue (info) — informational, first-time setup prompts229```230231## Patterns232233### Status indicator colors234235```python236# BAD: red for "no data"237return ui.icon("circle").classes("t-error")238239# GOOD: grey for "no data"240return ui.icon("circle").classes("t-disabled")241242# BAD: red for stopped container243if status == "stopped": return theme.COLOR_ERROR244245# GOOD: neutral grey for stopped, red only for crashed246if status == "stopped": return theme.TEXT_SECONDARY247if status in ("error", "crashed"): return theme.COLOR_ERROR248```249250### Signal strength colors251252Use `theme.signal_color(quality)` instead of hardcoding:253254```python255# BAD: hardcoded gradient in page256if signal_dbm > -50: sig_color = "#2dd4bf"257elif signal_dbm > -60: sig_color = "#4ade80"258259# GOOD: centralized via theme260sig_color = theme.signal_color(signal_quality(signal_dbm))261```262263### Scalable test patterns264265Tests import constants from `scripts.webui.data` — never hardcode UI strings:266267```python268from scripts.webui.data import Routes, PageTitles, Labels, ApiRoutes269270# Routes271await user.open(Routes.DEPLOY) # not "/deploy"272f"{Routes.LAUNCH}?vmid={_moon['vmid']}" # parameterized routes273274# Page titles275await user.should_see(PageTitles.NODES) # not "Fleet Nodes"276277# Button labels278user.find(Labels.START_DEPLOY).click() # not "Start Deploy"279280# API routes281resp = await client.post(ApiRoutes.CHECKIN, json=payload)282283# Hub service data — derive from data model284_hub_svc = {s.key: s for s in data.HUB_SERVICES}285await user.should_see(_hub_svc["jellyfin"].tag) # not "Media Server"286287# Display apps — derive vmid/label from data288_moon = data.DISPLAY_APPS["MOONLIGHT_URL"]289f"{Routes.LAUNCH}?vmid={_moon['vmid']}&title={_moon['label']}"290291# Hub sections — loop instead of hardcoded list292for section in {s.section for s in data.get_hub_services()}:293 await user.should_see(section)294295# Bridge/mesh nodes — from data helpers296_BRIDGE_NODES = data.get_bridge_nodes()297_MESH_AP, _MESH_STAS = data.get_mesh_nodes()298```299300### Kiosk nav bar301302The kiosk nav bar shows "Home" (icon button) + breadcrumb, NOT "Home Hub".303Tests use `Labels.HOME` — `await user.should_see(Labels.HOME)`.304305### Hub sections306307Sections are derived from `data.HUB_SERVICES` — never hardcode the list.308Tests iterate `{s.section for s in data.get_hub_services()}`.309310## Previous bugs311312- Red overuse: status dots, stopped containers, "No WoL" badges, disconnected313 mesh nodes, "No env file" messages all used red. Users interpreted these as314 errors. Fix: systematic audit replacing with grey/orange per semantics above.315- Duplicate icons: Containers and Hosts both used `dns` icon. Fix: Containers316 changed to `view_in_ar`.317- Hardcoded batman color `#4ade80` in mesh.py instead of `theme.COLOR_SUCCESS`.318- Hardcoded signal colors in bridge.py instead of `theme.signal_color()`.319- `except Exception` in containers.py caught too broadly. Fix: catch320 `(httpx.HTTPError, OSError)`.321- `format_last_seen_relative` crashed on timezone-aware ISO timestamps due to322 naive/aware datetime subtraction. Fix: `.replace(tzinfo=None)`.323- Tests referencing old section names ("Desktop & Media") after hub sections324 were renamed to "Entertainment". 26 tests failed silently until full suite run.325 Fix: all tests now import constants from `data.py` (`Routes`, `PageTitles`,326 `Labels`, `ApiRoutes`) so label changes propagate automatically.327- Magic strings in 6 test files duplicated 200+ labels, routes, and page titles.328 Changing a button label required updating 5+ test files. Fix: centralized all329 UI strings into `data.py` constants consumed by both page modules and tests.330- "rc=2" in deploy badges was meaningless to users (mistaken for "release331 candidate"). Fix: map Ansible exit codes to human-readable descriptions332 (e.g., "failed — host unreachable or task failed") via `_exit_code_label()`.333- "monitoring" Material Icon in the fleet View button looked like a desktop334 monitor and overlapped adjacent buttons. Fix: changed to `lan` icon.335- Fleet card showed "No nodes registered" when no callhome server was running,336 giving the impression the hosts didn't exist. Fix: show configured host names337 from env as grey badges with "waiting for heartbeats" message.338- Business logic (exit code mapping, health computation, error aggregation)339 was embedded in `dashboard.py` UI functions. Changing health rules required340 editing UI code and couldn't be unit-tested without NiceGUI. Fix: extracted341 `Host`, `Fleet` domain classes and `build_fleet()` factory into `data.py`.342 Dashboard became a thin renderer reading `.healthy` and `.errors` properties.343 29 pure-Python tests cover all health scenarios without UI overhead.344- `RegisteredNode` (heartbeat data) and `Host` (env config) were separate345 objects representing the same entity. Dashboard read raw `nodes.json` in346 parallel with `Fleet`. Nodes list used `RegisteredNode` directly. Fix: merged347 telemetry into `Host` via `HostTelemetry` dataclass and `attach_telemetry()`.348 `build_fleet()` now loads all three data sources (env, deploy history,349 nodes.json) and produces a single fully-wired `Fleet`. Added `GuestInfo`350 for parsed service entries, `Host.guests`/`.vms`/`.containers` properties,351 and Fleet-level aggregates (`.online_count`, `.total_guests`, `.health_score`).352 50+ new domain model tests cover telemetry, guests, and aggregates.353- Per-host detail page was missing. Users had to scan fleet cards for host354 info. Fix: added `/nodes/{hostname}` route with Proxmox VE-inspired layout355 (resources, guests table, network, deploy history, extensions, sparklines).356 Node list cards are now clickable → detail page.357358## Related skills359360- **webui-ux-principles** — Higher-level UX design principles (color language,361 icon choices, layout rules, information hierarchy, click reduction).362- **webui-manual-testing** — Step-by-step manual testing checklist for all363 three UI roles (SuperManager, Manager, Kiosk).364- **code-review-checklist** — MVC/OOP review questions for web UI diffs.