# Dashboard UI cleanup sweep (2026-08-11) Runs the family admin-UI cleanup playbook (`../scadaproj/admin_ui_cleanup.md`) against the MXAccess Gateway Blazor dashboard. Third app in the family after the ignitionoee router and the OtOpcUa AdminUI. ## 0. Platform correction — this app is *not* Bootstrap-free The umbrella index describes mxaccessgw as the family's Bootstrap-free app. That is wrong, and every Bootstrap-dependent recipe in the playbook applies here unchanged. What actually ships: | Layer | Evidence | |---|---| | Bootstrap 5.3.3, self-hosted | `libman.json:5`; `wwwroot/lib/bootstrap/css/bootstrap.min.css` | | Linked first in the head | `Dashboard/Components/App.razor:7` | | `ZB.MOM.WW.Theme` 0.3.1 at discovery, **0.4.0 after §6** | `ZB.MOM.WW.MxGateway.Server.csproj` ``; `` at `App.razor:8` | | App stylesheet, loaded last | `App.razor:9` → `wwwroot/css/site.css` | | Bootstrap JS bundle | `App.razor:14` | What CLAUDE.md actually forbids is Blazor **component libraries** (MudBlazor, Radzen, FluentUI) — not Bootstrap's CSS/JS. No Bootstrap was introduced by this sweep. Playbook §2 foundation item 1 (*app stylesheet after `` so it wins the cascade*) was therefore **already satisfied** before the sweep. ## 1. Discovery ### 1a. Foundation / CSS audit **Shipped-vs-used class matrix.** Every class in `Dashboard/Components/**/*.razor` checked against theme 0.3.1 `staticwebassets/css/{theme,layout}.css`, `bootstrap.min.css`, and `site.css`. Three classes are defined nowhere: | Ghost class | Site | Consequence | Verdict | |---|---|---|---| | `tree-load-status` | `Shared/BrowseTreeNodeView.razor:42`, `:49` | **Real visual defect.** The row's indent spacer is ``; `.tree-toggle` sets `flex:none;width:1.1rem`, which only takes effect on a flex item. `.tree-row`/`.tree-attr` are flex; this container is an undefined block, so the span stays inline and `width` is ignored — "⌛ Loading…" and "Failed to load: …" render flush left, out of alignment with every sibling row. | **Define it** | | `browse-stale-banner` | `Pages/BrowsePage.razor:78` | The banner carries `@onclick="ClearStaleBanner"` with no pointer affordance and no styling of its own — a click-to-dismiss control that does not look clickable. | **Define it** | | `tree-node` | `Shared/BrowseTreeNodeView.razor:15` | Structural wrapper only; nothing needs to style it. A no-op, but a deliberate one. | **Leave** | (Reported as ghosts by the raw extractor but false positives: `h-100` at `Shared/MetricCard.razor:1` — Bootstrap-defined, mangled by the extractor's handling of the inline `@(...)` class expression.) **Scoped-CSS bundle.** N/A — the project contains **zero** `*.razor.css` files, so no `*.bundle.scp.css` is emitted and nothing is missing from the head. (This was OtOpcUa's finding; it does not exist here.) **Phantom CSS custom properties.** Zero. `Dashboard/Components/` contains no `var(--…)` at all — tokens are used only from `site.css`, and every one of them (`--ink`, `--ink-soft`, `--ink-faint`, `--card`, `--rule`, `--rule-strong`, `--mono`, `--accent`, `--accent-deep`, `--ok`, `--ok-bg`, `--bad`, `--bad-bg`, `--warn`, `--warn-bg`, `--idle`, `--idle-bg`) resolves against theme 0.3.1. This app has the OtOpcUa-clean result, not the router's. **Button sizing — the one real foundation defect.** Theme 0.3.1 ships no `.btn` rule (confirmed: `.btn` appears in `layout.css` only inside a comment). But `site.css:187` does, and it sets `font-size` **directly** rather than through Bootstrap's variable: ```css .btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; } ``` Bootstrap renders `.btn { font-size: var(--bs-btn-font-size) }`, and `.btn-sm` / `.btn-group-sm > .btn` size themselves purely by *redefining that variable* (`.btn-sm{--bs-btn-font-size:0.875rem}`). A literal `font-size` on `.btn` at equal specificity, loaded later, wins over the variable-driven declaration for **every** button — so `btn-sm` and `btn-group-sm` are font-size no-ops app-wide and small buttons differ from full-size ones by padding alone. This is the same class of defect the other two apps hit from the opposite direction (no `.btn` rule at all), and it takes the same fix. **Dark scheme.** Theme 0.3.1 is light-only; `site.css` makes no `prefers-color-scheme` / `data-bs-theme` claim, and its header comment ("Layers over theme.css … every colour resolves to a theme.css token") is accurate. Nothing to correct. ### 1b. Button inventory `grep -rn "btn-group"` returns **three** — this app already uses the convention where it matters: | Site | Members | State | |---|---|---| | `Pages/ApiKeysPage.razor:189` | Rotate / Revoke, or Delete | Correct — `btn-group btn-group-sm`, no per-button `btn-sm`, `@if` inside the group | | `Pages/SessionsPage.razor:90` | Close / Kill | Correct | | `Pages/SessionDetailsPage.razor:34` | Close session / Kill worker | Correct | Adjacent related buttons **not** yet grouped (both are feet, the playbook's named case): | Site | Members | Fix | |---|---|---| | `Shared/ConfirmDialog.razor:17,22` | Cancel + `@ConfirmButtonClass` confirm, in a `modal-footer` | Wrap in `btn-group` | | `Pages/ApiKeysPage.razor:136,137` | Save (`type="submit"`) + Cancel, in the create-key card body | Wrap in `btn-group btn-group-sm`; fold the two `btn-sm` and drop the `me-1` spacer | Refused / not candidates: - `Pages/WorkersPage.razor:74` — a lone Kill button. Nothing to group. - `Pages/ApiKeysPage.razor:22` — lone page-head "Create API Key". - `Pages/BrowsePage.razor:109` (`Clear all`) and `:167` (`Remove`) — lone buttons. - `Shared/BrowseTreeNodeView.razor:19` `.tree-toggle` — a bare expander, deliberately unstyled as a button; `MainLayout.razor:32` Sign Out / `:36` Sign In are the theme's `rail-btn`, one per auth branch and mutually exclusive. **Row actions styled as links**: none. Every action in the app is already a real `