# 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.

## 8. Follow-up: role-gate the side rail's Secrets link (family-wide nav task)

Requested as a family-wide sweep: every app's UI should link to the Secrets management page, visible
to Administrator-role users only.

**Found state.** The link already existed — `MainLayout.razor`, Admin section, `/admin/secrets`. What
did not exist was any gate: the rail rendered every item for every visitor, including a Viewer and
the anonymous-localhost read-only identity. The premise that there was an "existing role-gated nav
pattern" to follow was false; the rail's only `AuthorizeView` was the footer's signed-in/signed-out
split, so this introduces the pattern rather than extending it.

Not an access hole — the mounted page carries `[Authorize(Policy = "secrets:manage")]`, so a Viewer
clicking through was denied. It was a dead link presented as a live one.

**Gate chosen: the policy, not the role.** `<AuthorizeView Policy="@SecretsAuthorization.ManagePolicy">`,
i.e. the same policy the page itself enforces, so nav visibility cannot drift from page access. The
sweep asked for a role literal (`DashboardRoles.Admin` = `"Administrator"`), and in this host the two
are equivalent: `GatewayOptionsValidator` constrains `Dashboard:GroupToRole` values to
`Administrator` or `Viewer`, so the shared library's other manage-granting roles (`secrets-manager`,
`secrets-reveal`) are unreachable here. The policy form was preferred because it stays correct if
that constraint ever relaxes — a role literal would then hide the link from users who can use the
page.

**Deliberate asymmetry — API Keys stays ungated.** Its sibling item looks like the same case and is
not. `ApiKeysPage` renders for a Viewer with write affordances hidden (`@if (CanManageApiKeys)`), so
hiding its nav item would remove legitimate read access. The secrets page has no read-only mode. The
rule is "gate the link when the page denies the role outright", not "gate everything under Admin".

**Coverage.** Three tests pin the policy's verdict per principal (Administrator admitted, Viewer
refused, unauthenticated refused) in `SecretsNavGateTests`, and `/admin/secrets` joins the canonical
route list in `GatewayApplicationTests` — it is the one nav destination mounted from an RCL rather
than declared here, so a routing regression could remove it without touching this repo's pages.

### 8a. Correction: the policy tests could not detect a deleted gate

The coverage above shipped with a stated rationale — that rendering was disproportionate because the
policy verdict "is the part that can actually be wrong". That rationale was wrong, and a review point
from the OtOpcUa session identified why: the policy is library code this repo did not author, while
the *wiring* is the only thing this change introduced. Worse, the check applies specifically to repos
where the link already existed before gating — "an Administrator still sees it" is identical to the
pre-change behaviour, so it cannot distinguish a working gate from an inert one. **Only the negative
observation proves a gate exists at all.**

`SecretsNavRenderTests` now renders `MainLayout` through the framework's static `HtmlRenderer` — no
component-testing package needed, since the assertion is about emitted markup, not interactivity —
and asserts the Secrets item is absent for a Viewer and for an anonymous caller, present for an
Administrator, and that the ungated API Keys sibling stays present for a Viewer (so a later
"consistency fix" that hides it fails loudly).

**Confirmed non-vacuous by mutation**, which is the only thing that makes the absence assertions
worth anything: with the `AuthorizeView` removed from the layout, `Rail_OmitsSecretsLink_ForViewer`
and `Rail_OmitsSecretsLink_ForAnonymous` both go red — **and all three original policy tests stay
green**, demonstrating the gap concretely rather than by argument. The Administrator case is retained
as the control: without it, a rail that rendered no nav at all would satisfy both absence assertions
and the suite would report a working gate over a blank page.

**Verification.** Build 0 warnings / 0 errors; suite **899/899** (895 + 4).
