Files
mxaccessgw/docs/plans/2026-08-11-dashboard-ui-sweeps.md
T
Joseph Doherty 01033d7aaf
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 2m18s
ci / java (push) Successful in 2m24s
ci / portable (push) Successful in 8m51s
fix(dashboard): admin-UI cleanup sweep
Family-wide admin-UI cleanup pass (scadaproj admin_ui_cleanup.md) applied to the
Blazor dashboard. Behaviour is unchanged throughout — no @onclick, disabled,
binding, auth gate, or arm->confirm flow was touched.

Uncontrolled error text is now truncated at the render site. Fault messages,
Galaxy load errors, and browse-tree load failures were rendered in full into
fixed-width table cells, where a long exception string blows out the column.
Each site gets DashboardDisplay.Abbreviate plus a title attribute carrying the
untruncated text, so nothing becomes unreachable. Abbreviate is length-checked
rather than a bare range slice: `value[..n]` on a shorter string throws and
takes the whole page render down with it. The two detail views whose entire
purpose is to show one fault in full — SessionDetailsPage and GalaxyPage's
Last Error — are deliberately left untruncated.

Two classes referenced from markup had no definition anywhere in the sheet.
.browse-stale-banner was inert; .tree-load-status was a real visual defect —
loading and failed-to-load rows sit among .tree-row siblings and carry the same
leading .tree-toggle-empty spacer, but that spacer only takes its width as a
flex item, so without a flex container those rows lost their indent.

Confirm/cancel pairs in ConfirmDialog and the API-key create form are now
btn-groups with role="group" and an aria-label, replacing margin-spaced loose
buttons.

Removes a paragraph on GalaxyPage naming internal RPCs (DiscoverHierarchy,
GetLastDeployTime) — implementation detail with no meaning to a dashboard
operator.

Verified in a real browser, not bUnit: full build clean, 879/879 tests, and a
live gate against a running dashboard with a genuine ~250-char SqlClient
exception as the erroring row. Results per check, including the checks that
could NOT be exercised without an x86 worker, are recorded in
docs/plans/2026-08-11-dashboard-ui-sweeps.md.

That plan doc also records a correction: this app is NOT Bootstrap-free. The
sweep brief said it was, citing the scadaproj index; libman.json pins
bootstrap 5.3.3 and App.razor:7 links it ahead of the theme. The stale claim had
already cost this app one skipped family sweep (scadaproj#2, the /admin/secrets
modal), so that modal was live-gated here too and passes.
2026-08-11 05:48:22 -04:00

353 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.