The Theme bump is separable from the sweep and lands as its own commit: 0.4.0
upstreamed the button-sizing block the sweep had installed locally, and this
app's local .btn rule was trimmed rather than deleted because it also carried
border-radius/font-weight/white-space that the kit does not ship.
| 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``<PackageReference>`; `<ThemeHead />` 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 `<ThemeHead />` 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 `<span class="tree-toggle tree-toggle-empty">`; `.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`,
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 `<button>`; the
only `<a>` in `Dashboard/Components/` is `MainLayout.razor:36`, which navigates. `btn-link`: zero
uses. `·` action separators: zero — the `·` occurrences (`BrowsePage.razor:54,102,106,287`,
`BrowseTreeNodeView.razor:71`, `AlarmsPage.razor:220`) all separate *metadata facts* or serve as a
| `Pages/GalaxyPage.razor:134-138` | "Browse data is served by the `galaxy_repository.v1.GalaxyRepository` gRPC service. Clients call `DiscoverHierarchy` for the full tree and `GetLastDeployTime` to detect redeployments." | **DELETE** — unconditional client-API documentation on an operator page. Every fact is already in `docs/GalaxyRepository.md` (service name at :44, `GetLastDeployTime` at :49, `DiscoverHierarchy` at :50). Plain delete, no relocation needed. |
| `Pages/AlarmsPage.razor:151-154` | "Cleared alarms are not retained — this list reflects only alarms currently Active or ActiveAcked, refreshed every 3 seconds." | KEEP — decodes what the list contains and does not contain; the operator cannot infer "cleared alarms are absent" from the data. |
| `Pages/AlarmsPage.razor:26-30` | Alarms-disabled banner citing `MxGateway:Alarms:Enabled` | KEEP — conditional state banner. **Config key verified against the options class**: `GatewayOptions.Alarms` → `AlarmsOptions.Enabled`, present in `appsettings.json`. |
| `Pages/BrowsePage.razor:33-35`, `:41`, `:120-124` | Empty states | KEEP |
| `Pages/BrowsePage.razor:70`, `:90` | "Showing the first N matches — refine the filter." / "Double-click a tag, or right-click for the menu." | KEEP — truncation notice, and the only decoder of two interactions that have no visible affordance. |
| `Pages/SessionDetailsPage.razor:115-118` | "Waiting for events. The dashboard mirrors the session's gRPC event stream — events appear here only while a gRPC client is also consuming this session's events." | KEEP — explains an empty state that otherwise reads as a bug. |
| `Pages/GalaxyPage.razor:33-38` | Unknown-status empty state | KEEP |
| `Shared/BrowseTreeNodeView.razor:51` | (tree row) | `@Node.LoadError` — **worst case**: `.tree-attr`/`.tree-row` siblings are `white-space: nowrap` inside a fixed-height scroller, so a long browse error stretches the left pane horizontally |
| `Pages/DashboardHome.razor:47` | Galaxy panel | `Snapshot.Galaxy.LastError` on the compact overview |
Deliberately **not** truncated (the full text must stay reachable — playbook rule):
`SessionDetailsPage.razor:84` "Last fault" and `GalaxyPage.razor:47` "Last Error" are both
full-width rows on the drill-down page each summary links to.
Not firing: **identity slam** — `SessionsPage.razor:75-81` puts a worker pid next to a status chip,
which the playbook explicitly permits ("chips stay with the name line"); no cell renders two
identifiers. **Inline full-width expander rows** — zero `colspan` detail rows in the app.
### 1e. Tree tables
**No adoption.**`BrowsePage` + `BrowseTreeNodeView` is a lazy-loading nav/picker tree with a
context menu and no columns — the rubric's explicit "different animal, leave it alone". No table in
the app flattens hierarchy through a path-prefix column, hand-rolled `rowspan`, indent-by-padding,
or faked group-header rows; `Sessions`/`Workers`/`Events`/`Alarms`/`Galaxy` are flat fact lists and
`Top Templates` is rank-ordered (a tree would destroy load-bearing ordering). Porting the router's
`TreeTable` here would create an orphan.
### Guard tests
Checked for source-scan tests pinning dashboard markup: none. The only `.razor` reference in the
test project is a prose comment (`Gateway/GatewayApplicationTests.cs:116`). Nothing to re-pin.
## 2. Changes
### Foundation
1.**`wwwroot/css/site.css` — button sizing via Bootstrap CSS variables.** Replace the literal
`font-size` on `.btn` with `--bs-btn-*` overrides and add the `btn-sm` / `btn-group-sm` block, so
the small-button distinction works again. Box properties are *not* redefined wholesale —
`border-radius: 5px` stays literal because the three existing `btn-group`s already render seamed
under it (`.btn-group > .btn:not(:first-child)` outranks `.btn`). Carries the header comment the
other two apps carry: delete the local copy when a `ZB.MOM.WW.Theme` release ships a `.btn` rule.
**That release shipped the same day — see §6.**
2.**`site.css` — define `.tree-load-status`** as a flex row matching `.tree-row`, restoring the
indent alignment of the tree's loading/error rows.
3.**`site.css` — define `.browse-stale-banner`** with `cursor: pointer` and tightened padding, so
the click-to-dismiss banner reads as clickable. No behavior change.
- [x]`dotnet test src/ZB.MOM.WW.MxGateway.Tests` — 879/879 passed, the macOS baseline.
### Post-merge greps
- [x] Zero phantom `var(--…)` usages (there were none to begin with).
- [x] Zero ghost classes remaining except the deliberate `tree-node`.
- [x]`btn-group` count 3 → 5, matching the 1b inventory.
- [x] Zero `·` action separators, zero inline `width:` hacks (none existed).
- [x] Scoped-bundle link: N/A, no `*.razor.css`.
### Live browser gate
Recorded in §4 below.
## 4. Live browser gate — results
Run against a local `dotnet run` of the gateway on macOS (`http://localhost:5120`, the launch
profile's port), not windev — the sweep is CSS/markup-only and redeploying the shared NSSM service
was not warranted. Rig configuration (env overrides only, no committed config touched):
`Dashboard:DisableLogin=true` (to reach the Admin-only surfaces), `Ldap:Enabled=false`,
`Authentication:Mode=Disabled`, SQLite + Galaxy snapshot paths under the session scratchpad,
`ApiKeyPepper` set locally. The rig was stopped and its throwaway auth DB deleted afterwards.
**Realistic erroring data**: the Galaxy SQL Server (`localhost`, `ZB`) does not exist on macOS, so
every refresh fails with a genuine 250-character `Microsoft.Data.SqlClient` SSPI exception, and the
alarm monitor's auto-opened session fails on the missing x86 worker — both real, uncontrolled
error text of exactly the kind §1d is about.
| # | Check | Result | Evidence |
|---|---|---|---|
| 1 | Computed-style probes | **PASS** | body `14.4px`; `.btn``13.6px` (0.85rem); `.btn-sm``12.48px` (0.78rem) — the small/full distinction is live, where before the fix both computed to `13.12px`. `btn-group-sm` members: both `12.48px`, first member's right radius `0px`, last member's `5px` → **seams intact**, confirming the literal `border-radius` does not break group machinery. `.tree-load-status` → `display: flex`; `.browse-stale-banner` → `cursor: pointer`. Stylesheet order: bootstrap → theme → layout → **site.css last**. The theme's own `.rail-toggle btn-sm` also picks up `12.48px`, so the block reaches theme-owned buttons, not just app markup. |
| 2 | Every page leads with data, no unconditional doc block above the fold | **PASS** | All 9 routes' first blocks are `dashboard-page-header` → `metric-grid` / `dashboard-section` / state `alert`. `/galaxy` now ends at the Sync Info table — the deleted client-API paragraph is gone. |
| 3 | Seamed `btn-group`s; destructive members red | **PASS** | `/apikeys` row actions render Rotate + Revoke as one seamed group with Revoke in `btn-outline-danger` red. The `ConfirmDialog` foot renders Cancel + a solid red Revoke seamed together. |
| 4 | Arm→confirm: arm → confirm UI appears → **Cancel** → verify disarm | **PASS** | Revoke → "Revoke API key?" dialog → Cancel. Post-cancel probe: `dialogOpen:false`, `backdrops:0`, key status still `Active`, row action back to `btn-outline-danger` outline. No write completed. Save/Cancel in the create-key modal also verified: Cancel closes without creating; Save creates (validation message surfaced correctly on the first attempt with a missing display name). |
| 5 | Anything the scoped bundle resurrected | **N/A** | No `*.razor.css` in the project — see 1a. |
| 6 | KEEP-list legends / hints / empty states still render | **PASS** | `/browse`: both empty states plus the "Right-click a tag … Add to subscription panel" hint. `/alarms`: the "Cleared alarms are not retained …" legend under the table. `/galaxy`: Object Categories / Top Templates empty states. |
| 7 | Density with realistic data; sections as separate cards; no horizontal scroll | **PASS** | `/` renders the SQL error truncated to one line with `…`; `/galaxy` renders the same error in full (the drill-down) — the intended split is visible side by side. `/apikeys` with a 3-entry `read_subtrees` constraint wraps inside the column with no table overflow. `documentElement.scrollWidth == clientWidth` on every page walked. Each group is its own card. |
**Not exercisable on this rig** (recorded, not claimed as passing): the `Sessions` / `Workers` /
`Recent Faults` truncation call sites and the `BrowseTreeNodeView` load-error row need a *registered*
session, which requires the x86 worker — on macOS the launch fails before the session is ever
registered, so those tables stay empty. Their shared helper and the container CSS were both verified
live by other means (the `/` Galaxy error for `Abbreviate`, the computed-style probe for
`.tree-load-status`). Re-check them on windev the next time that service is redeployed.
**Side note surfaced, not acted on**: `SettingsPage` renders `LDAP service password` as
`[redacted]` in the browser — confirming `GatewayConfigurationProvider`'s substitution end to end.
Not part of the sweep. Run because the same stale "mxgw is Bootstrap-free" claim corrected in §0 was
the stated reason this app was **excused** from the 2026-07-19 family-wide `/admin/secrets` modal
sweep: `ConfirmDeleteModal` shipped a bare `class="modal"`, which Bootstrap 5's `.modal{display:none}`
made permanently invisible on every Bootstrap host. The exemption's reasoning was false, so mxgw was
in scope and its delete modal had never been live-gated. `8f7ee49` bumped `ZB.MOM.WW.Secrets.Ui` to
`0.2.3`, which is supposed to carry the fix — but "supposed to" is what the original claim was.
**Static check** — decoding the UTF-16 literals out of `ZB.MOM.WW.Secrets.Ui.dll` 0.2.3 shows the
component now inlines its own `<style>` under a private namespace: `.zb-secrets-modal` with
`display: flex; position: fixed; inset: 0; z-index: 1081`, over a `.zb-secrets-modal-backdrop` at
`z-index: 1080`. No bare `modal` class.
**Live check** — local rig, scratch secrets store (`Secrets:SqlitePath` + `ZB_SECRETS_MASTER_KEY`
under the session scratchpad), throwaway secret `uisweep/throwaway`, delete armed but **never
confirmed**:
| Probe | Result |
|---|---|
| Modal root classes | `zb-secrets-modal` — and `document.querySelector('.modal')` is `null`, so Bootstrap's `.modal{display:none}` has nothing to bite |
/* Click-to-dismiss "Galaxy redeployed" notice above the tree. The whole banner
is the dismiss target, so it has to read as one. */
.browse-stale-banner{
cursor:pointer;
padding:0.4rem0.7rem;
margin-bottom:0.6rem;
}
.browse-search-note{
margin-top:0.5rem;
font-size:0.74rem;
@@ -334,6 +353,22 @@ code {
}
.tree-toggle-empty{cursor:default;}
/* Loading / failed-to-load rows sit among .tree-row siblings and carry the same
leading .tree-toggle-empty spacer. The spacer only takes its width as a flex
item, so this container has to be flex or the row loses its indent. */
.tree-load-status{
display:flex;
align-items:center;
gap:0.35rem;
padding:0.12rem0.5rem;
font-size:0.82rem;
}
.tree-load-status>span:not(.tree-toggle){
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.tree-label{
display:flex;
align-items:center;
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.