Merge feat/dashboard-ui-cleanup-sweep: admin-UI cleanup pass + ZB.MOM.WW.Theme 0.4.1
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m28s
ci / portable (push) Successful in 9m25s

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.
This commit is contained in:
Joseph Doherty
2026-08-11 05:49:11 -04:00
12 changed files with 443 additions and 26 deletions
@@ -0,0 +1,352 @@
# 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` `<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`,
`--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 `<button>`; the
only `<a>` in `Dashboard/Components/` is `MainLayout.razor:36`, which navigates. `btn-link`: zero
uses. `&middot;` 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
bullet glyph, never actions. Inline `width:` sizing hacks: zero. `py-0`: zero.
**Arm→confirm flows** (restyle-only; the two-step must survive):
| Page | Flow |
|---|---|
| `SessionsPage` | Close / Kill → `ConfirmDialog``ConfirmPendingAsync` |
| `SessionDetailsPage` | Close session / Kill worker → `ConfirmDialog` |
| `WorkersPage` | Kill → `ConfirmDialog` |
| `ApiKeysPage` | Rotate / Revoke / Delete → `ConfirmDialog`; Create → modal form |
**Size mismatches within a group**: none.
### 1c. Prose inventory (DELETE / KEEP / RELOCATE)
| Site | Text | Verdict |
|---|---|---|
| `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 |
**Stale-claim hunt** (wrong facts outrank style): none found. No milestone labels ("F8/F9 pending",
"Batch 2"), no dead repo hyperlinks, no superseded architecture claims. Every config key cited in
markup resolves — `MxGateway:Alarms:Enabled` (above) is the only one. Also checked and **cleared**:
`SettingsPage.razor:32` renders `Ldap.ServiceAccountPassword`, but
`Configuration/GatewayConfigurationProvider.cs:30` substitutes `RedactedValue` before the snapshot
is built, so no credential reaches the page.
### 1d. Density / layout scan (per page, not per file)
Structural mitigations already present app-wide: `site.css:137` caps `.dashboard-table td` at
`max-width: 26rem` with `overflow-wrap: break-word`, so the OtOpcUa `/hosts` failure mode (one long
exception widens the table until the actions column scrolls off) **cannot happen here**. Every table
is also inside `.table-responsive`. `panel-head` appears zero times, so the "sections running
together" tell does not fire — each group is its own `section.dashboard-section` card already.
Residual finding — **unbounded free-text columns**. The 26rem cap converts the horizontal blowout
into vertical blowout: a multi-line exception makes one row several times taller than its
neighbours and pushes the rest of the table off-screen.
| Site | Column | Bound to |
|---|---|---|
| `Shared/FaultList.razor:28` | Message | `@fault.Message` — worker/COM fault text, uncontrolled |
| `Pages/SessionsPage.razor:86` | Fault | `session.LastFault` |
| `Pages/WorkersPage.razor:70` | Fault | `worker.LastFault` |
| `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.
4. **`Dashboard/Components/DashboardDisplay.cs` — add `Abbreviate(string?, int)`.** Safe helper
(length-checked, never a raw `[..n]` slice — the OtOpcUa #504 failure), `-` for null/whitespace,
ellipsis on truncation.
No prose relocation task: the single DELETE is already docs-covered.
### Page batches
**Batch A — free-text truncation** (`Shared/FaultList.razor`, `Pages/SessionsPage.razor`,
`Pages/WorkersPage.razor`, `Shared/BrowseTreeNodeView.razor`, `Pages/DashboardHome.razor`):
`Abbreviate` + full text on `title`, at the five sites in 1d.
**Batch B — button feet** (`Shared/ConfirmDialog.razor`, `Pages/ApiKeysPage.razor`): the two
`btn-group` wraps from 1b. `@onclick`, `disabled`, `type="submit"`, and both arm→confirm flows
unchanged.
**Batch C — prose** (`Pages/GalaxyPage.razor`): delete `:134-138`.
## 3. Verification
### Build + tests
- [x] `dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx` — 0 warnings, 0 errors
(`TreatWarningsAsErrors=true`).
- [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 `&middot;` 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.
## 5. Follow-up gate: `/admin/secrets` delete modal (scadaproj#2)
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 |
| Modal computed style | `display: flex`, `position: fixed`, `z-index: 1081`, `visibility: visible`, `opacity: 1`, box `1600×827` |
| Backdrop | `display: block`, `position: fixed`, `z-index: 1080`, `rgba(15,18,20,.45)`, covers the full viewport |
| Confirm button hit-testable | `elementFromPoint` at its centre returns the button itself — not covered by the backdrop |
| Cancel → disarm | modal and backdrop both removed; secret still listed; **no delete performed** |
**PASS.** The `0.2.3` fix holds on this Bootstrap host. The rig was stopped and both throwaway
stores (secrets + auth) deleted.
</content>
## 6. Follow-up: `ZB.MOM.WW.Theme` 0.3.1 → 0.4.0 (dependency bump, not UI cleanup)
0.4.0 upstreams the button-sizing block this sweep installed locally, written against the finding in
§1a — the kit sizes **only** through `--bs-btn-*` variables and carries a comment in `layout.css`
saying why, so nobody simplifies it back into literals.
- `ZB.MOM.WW.MxGateway.Server.csproj`: `0.3.1``0.4.0`. No `Directory.Packages.props` in this repo;
the version lives on the `PackageReference`.
- `site.css`: the four `--bs-btn-*` overrides and the whole `.btn-group-sm > .btn, .btn-sm` rule
deleted — `layout.css` now supplies them and `<ThemeHead />` emits it before `site.css`.
**Trimmed, not deleted wholesale.** The local `.btn` rule also carried `border-radius: 5px`,
`font-weight: 500`, `white-space: nowrap`, which predate the sweep and which 0.4.0 does **not**
ship — it upstreamed sizing only. Removing the block entirely would have silently dropped three
app-specific declarations. What remains is `.btn { border-radius: 5px; font-weight: 500;
white-space: nowrap; }` — shape, not size. `border-radius` deliberately stays a literal rather than
`--bs-btn-border-radius`, because `.btn-sm` redefines that variable and small buttons would shrink
to the Bootstrap small radius.
**Verification**
- Build: 0 warnings, 0 errors. Suite: 879/879.
- Computed-style probe re-run on 0.4.0 with the local block gone: `.btn` **13.6px**, `.btn-sm`
**12.48px** — identical to the pre-bump numbers, so the kit rule reaches. `btn-group-sm` members
both 12.48px with the seam intact (first member right radius `0px`, last `5px`). Retained
declarations confirmed live on both sizes: radius `5px`, weight `500`, `nowrap`.
- Cascade confirmed by enumerating `document.styleSheets`: `--bs-btn-font-size` is now declared in
exactly two sheets — `bootstrap.min.css` (`1rem` / `0.875rem`) and `layout.css`
(`.85rem` / `.78rem`). `site.css` no longer declares it, so the duplicate is gone.
**Tree-hygiene note.** The first suite run after the bump failed
`GatewayTreeHygieneTests.SourceTree_ContainsNoSqliteDatabaseFiles` — unrelated to the bump. The §4
rig's *first* start used the default **relative** `Secrets:SqlitePath` (`mxgateway-secrets.db`),
which resolved against the server project directory and left a DB in the source tree; only the §5
run redirected it to the scratchpad. The file was untracked, was deleted, and the suite went green.
The §4/§5 cleanup notes were therefore incomplete as originally written — the scratchpad copies were
removed but this one was missed. The hygiene test is what caught it, which is what it exists for.
## 7. Follow-up: `ZB.MOM.WW.Theme` 0.4.0 → 0.4.1 (pin-only, no rendering change here)
0.4.1 was published the same day to fix `.rail-btn-block`, a modifier shipped broken in 0.4.0
(`display: block; width: auto` fills for an `<a>` but shrink-wraps a `<button>`; now
`width: calc(100% - 1.2rem)` with `box-sizing: border-box`, accounting for `.rail-btn`'s side
margins). Bumped for family-pin alignment.
**Why nothing needed re-verifying.** Confirmed rather than assumed:
- `diff` of the two restored packages: `theme.css` byte-identical; `layout.css` differs **only**
inside the `.rail-btn-block` rule and its comment. The `.btn` sizing block is unchanged, so the
§6 measurements (`.btn` 13.6px, `.btn-sm` 12.48px) still hold and the probe was not re-run.
- This app does not use `rail-btn-block`. It does carry the exact element pair the bug turned on —
`MainLayout.razor:32` is a form-submit `<button class="rail-btn">` (Sign Out) and
`MainLayout.razor:36` an `<a class="rail-btn">` (Sign In) — but base `.rail-btn` is
`display: inline-block`, which shrink-wraps both element types identically. The asymmetry only
appears once the block modifier is applied, so this dashboard was never affected.
**Verification.** Build 0 warnings / 0 errors; suite **879/879**; `staticwebassets.build.json`
resolves `zb.mom.ww.theme/0.4.1`. No stale-HTTP-cache clear was needed — restore picked 0.4.1
directly.
@@ -36,6 +36,32 @@ public static class DashboardDisplay
return string.IsNullOrWhiteSpace(value) ? "-" : value;
}
/// <summary>
/// Formats a nullable text value for display, shortened to a maximum length.
/// </summary>
/// <remarks>
/// For table cells bound to text the gateway does not control — fault messages, COM
/// exception text, SQL errors. One multi-line exception otherwise makes a single row
/// several times taller than its neighbours. Call sites keep the full text reachable
/// on the element's <c>title</c> and on the row's detail page.
/// </remarks>
/// <param name="value">The text to format.</param>
/// <param name="maxLength">Maximum characters to render before the ellipsis.</param>
/// <returns>Formatted text, ellipsized when longer than <paramref name="maxLength"/>, or "-" if null or empty.</returns>
public static string Abbreviate(string? value, int maxLength = 80)
{
if (string.IsNullOrWhiteSpace(value))
{
return "-";
}
// Length-checked, never a bare range slice: a value shorter than maxLength
// would throw and take the whole page render down with it.
return value.Length <= maxLength
? value
: string.Concat(value.AsSpan(0, maxLength).TrimEnd(), "…");
}
/// <summary>
/// Formats a long count value for display with thousands separator.
/// </summary>
@@ -133,8 +133,10 @@ else
</div>
<div class="mt-3">
<button type="submit" class="btn btn-success btn-sm me-1" disabled="@IsBusy">Save</button>
<button type="button" class="btn btn-outline-secondary btn-sm" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
<div class="btn-group btn-group-sm" role="group" aria-label="Create API key actions">
<button type="submit" class="btn btn-success" disabled="@IsBusy">Save</button>
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">Cancel</button>
</div>
</div>
</div>
</div>
@@ -44,7 +44,8 @@ else
</div>
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
@* Overview stays compact; the Galaxy page renders the error in full. *@
<div class="empty-state mt-2" title="@Snapshot.Galaxy.LastError">@DashboardDisplay.Abbreviate(Snapshot.Galaxy.LastError, 160)</div>
}
</section>
@@ -131,11 +131,6 @@ else
</tbody>
</table>
</div>
<div class="text-secondary small mt-2">
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
<code>GetLastDeployTime</code> to detect redeployments.
</div>
</section>
}
@@ -83,7 +83,8 @@ else
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
<td>@DashboardDisplay.Text(session.LastFault)</td>
@* Full text stays reachable on the tooltip and on the session detail page. *@
<td title="@session.LastFault">@DashboardDisplay.Abbreviate(session.LastFault)</td>
@if (CanManage)
{
<td>
@@ -67,7 +67,8 @@ else
<td><StatusBadge Text="@worker.State.ToString()" /></td>
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
<td>@DashboardDisplay.Text(worker.LastFault)</td>
@* Full text stays reachable on the tooltip and on the session detail page. *@
<td title="@worker.LastFault">@DashboardDisplay.Abbreviate(worker.LastFault)</td>
@if (CanManage)
{
<td>
@@ -46,9 +46,11 @@
}
else if (Node.LoadState == BrowseLoadState.Error)
{
<div class="tree-load-status text-danger">
@* Abbreviated: sibling tree rows are nowrap inside a fixed-height
scroller, so a full COM/SQL error would stretch the whole pane. *@
<div class="tree-load-status text-danger" title="@Node.LoadError">
<span class="tree-toggle tree-toggle-empty"></span>
<span>Failed to load: @Node.LoadError</span>
<span>Failed to load: @DashboardDisplay.Abbreviate(Node.LoadError, 60)</span>
</div>
}
@@ -14,6 +14,7 @@
<p class="mb-0">@Message</p>
</div>
<div class="modal-footer">
<div class="btn-group" role="group" aria-label="Confirm or cancel">
<button type="button" class="btn btn-outline-secondary"
disabled="@IsBusy"
@onclick="OnCancel">
@@ -28,6 +29,7 @@
</div>
</div>
</div>
</div>
}
@code {
@@ -25,7 +25,7 @@ else
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
<td><StatusBadge Text="@fault.State" /></td>
<td>@fault.Message</td>
<td title="@fault.Message">@DashboardDisplay.Abbreviate(fault.Message)</td>
</tr>
}
</tbody>
@@ -15,7 +15,7 @@
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.1.5" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.5" />
<PackageReference Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.3.1" />
<PackageReference Include="ZB.MOM.WW.Theme" Version="0.4.1" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Health" Version="0.2.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
@@ -183,8 +183,19 @@ code {
}
/* ── Buttons ─────────────────────────────────────────────────────────────────
Flatten Bootstrap buttons onto the single accent + hairline palette. */
.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }
Flatten Bootstrap buttons onto the single accent + hairline palette.
Button *sizing* is not here: ZB.MOM.WW.Theme 0.4.0 upstreamed it into
layout.css as --bs-btn-* variable overrides, and ThemeHead emits that sheet
ahead of this one, so it applies without a local copy. Do not reintroduce a
local font-size on .btn — a literal at equal specificity beats Bootstrap's
`font-size: var(--bs-btn-font-size)` and flattens .btn-sm into a
padding-only difference across the whole app.
What remains is shape, not size, and is app-specific. border-radius stays a
literal rather than --bs-btn-border-radius so .btn-sm cannot shrink it; the
.btn-group seam rules outrank .btn, so groups still render seamed. */
.btn { border-radius: 5px; font-weight: 500; white-space: nowrap; }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
@@ -290,6 +301,14 @@ code {
.browse-panel { margin-top: 0; }
.browse-search { margin-bottom: 0.6rem; }
/* 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.4rem 0.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.12rem 0.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;