Files
mxaccessgw/docs/plans/2026-08-11-dashboard-ui-sweeps.md
T
Joseph Doherty 1c30611b1e feat(dashboard): gate the side rail's Secrets link on secrets:manage
Family-wide nav sweep: the Secrets management page should be linked from
each app's UI, visible to Administrator-role users only.

The link already existed in MainLayout's Admin section. The gate did not:
the rail rendered every item for every visitor, including a Viewer and the
anonymous-localhost read-only identity. Not an access hole — the mounted
page carries [Authorize(Policy = "secrets:manage")], so a Viewer clicking
through was denied — but a dead link presented as a live one. There was
also no existing role-gated nav pattern to follow; the rail's only
AuthorizeView was the footer's signed-in/signed-out split.

Gated on the POLICY rather than a role literal, so nav visibility cannot
drift from what the page enforces. 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. The policy form
stays correct if that ever relaxes, where a role literal would then hide
the link from users who can use the page.

API Keys is deliberately left ungated. It looks like the same case and is
not: ApiKeysPage renders for a Viewer with write affordances hidden, so
hiding its link 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), and
/admin/secrets joins the canonical route list — 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. The
principal helper sets an authentication type deliberately: without one
the role assertions would pass vacuously for the wrong reason.

Not a rendering test — the suite has no component-testing harness, and
adding one to assert a single AuthorizeView would be a large dependency
for a small claim.

Build 0 warnings / 0 errors; suite 895/895.
2026-08-13 09:33:04 -04:00

390 lines
27 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.
## 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.
Not a rendering test: the suite has no component-testing harness, and adding one to assert a single
`AuthorizeView` would be a large dependency for a small claim.
**Verification.** Build 0 warnings / 0 errors; suite **895/895** (892 + 3).