docs: correct mxgw Bootstrap claim + fold five-app sweep findings into admin_ui_cleanup

CLAUDE.md (Secrets row):
- mxgw is NOT Bootstrap-free (libman.json pins bootstrap@5.3.3, App.razor:7
  links it ahead of ThemeHead). The scadaproj#2 modal gate had skipped mxgw on
  that false premise; gate since run and PASS 2026-08-11.
- scoped-CSS bundle claim scoped to its 2026-07-19 date; OtOpcUa links one now
  too, so it is two of four rather than one.

admin_ui_cleanup.md — five rounds now (adds mxgw, HistorianGateway, ScadaBridge):
- 1a-1: test sources are a fifth definition site, checked BEFORE the ghost list.
  A class matched only by a test is a selector, never a ghost. Extends to
  substring and visible-text assertions, so button labels are load-bearing.
- 1a-4: two inverted button-sizing instances (literal font-size neutering
  btn-sm app-wide; a chassis class standing in for the button system).
- 1a-5: determine dark scheme per app; where one exists the step inverts.
- 1a-6 (new): a token override emitted as an inline style on the shell root
  defeats every scheme-level :root override. OtOpcUa carries the inert-but-
  latent instance.
- 1c: reword nothing before checking test sources; the docs may be the wrong
  document, not the page.
- 2: btn-sm fold breaks button.btn-*.btn-sm locators; cell-clip is wrong for
  resizable columns; briefs carry the rubric, not just line numbers.
- verification: baseline pre-existing failures by measuring; a suspiciously
  fast green is a vacuous pass, not a pass.
- live gate: mandatory bUnit or not; hit-testability over visibility; measure
  behaviour where computed values mislead (-webkit-box serializes flow-root);
  record what could not be exercised.
- new: exemptions from a family-wide sweep must re-derive their premise.
This commit is contained in:
Joseph Doherty
2026-08-11 02:07:01 -04:00
parent d131416673
commit 3b7dbfa082
2 changed files with 156 additions and 19 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+155 -18
View File
@@ -1,17 +1,40 @@
# Admin-UI cleanup playbook (`ZB.MOM.WW.Theme` + Bootstrap Blazor apps)
A reusable sweep recipe for the family's Blazor Server admin UIs, distilled from two completed
A reusable sweep recipe for the family's Blazor Server admin UIs, distilled from five completed
rounds:
- **ignitionoee router** — `../ignitionoee/docs/plans/2026-08-05-router-ui-sweeps.md` (+ its
render-validation companion plan)
- **OtOpcUa AdminUI** — `../OtOpcUa/docs/plans/2026-08-10-adminui-ui-sweeps.md`
- **MxAccessGateway dashboard** — `../MxAccessGateway/docs/plans/2026-08-11-dashboard-ui-sweeps.md`
- **HistorianGateway dashboard** — `../HistorianGateway/docs/plans/2026-08-11-dashboard-ui-sweep.md`
- **ScadaBridge CentralUI** — `../ScadaBridge/docs/plans/2026-08-11-central-ui-cleanup-sweep.md`
Both apps sit on **ZB.MOM.WW.Theme 0.3.1 + self-hosted Bootstrap 5.3**, and both sweeps found the
same *classes* of defect with different instances. Run the discovery phase before assuming which
instances your app has — the router had ghost layout classes and the OtOpcUa AdminUI had none, but
OtOpcUa had a dead scoped-CSS bundle the router didn't. **Neither app has bUnit; the final gate is
always a live browser pass.**
All five sit on **ZB.MOM.WW.Theme 0.3.1 + self-hosted Bootstrap 5.3** (mxgw included — an earlier
claim that it was Bootstrap-free was false; see the exemption rule below), and all five found the
same *classes* of defect with different instances. **Run the discovery phase before assuming which
instances your app has.** The spread so far: the router had ghost layout classes and OtOpcUa had
none; OtOpcUa had a dead scoped-CSS bundle and mxgw/HistorianGateway have no `*.razor.css` at all
while ScadaBridge's bundle was already correctly wired; HistorianGateway's phantom tokens were all
fallback-masked and ScadaBridge had zero; ScadaBridge's *pages* were right and its *docs* were
wrong, inverting §1c.
**Determine the test harness per app — do not assume.** The router and OtOpcUa have no bUnit, so a
live browser pass is their only gate. HistorianGateway (`bunit 1.40.0`, ten Dashboard test files)
and ScadaBridge (`CentralUI.Tests`, 990 tests) both have one, which changes discovery materially
(see §1a-1). **A live pass is still mandatory everywhere**, because computed styles and cascade
order are exactly what bUnit cannot see.
### Exemptions need their premise re-derived
If a family-wide sweep or gate excuses one app on a property claim about that app — "it's
Bootstrap-free", "it has no dark mode", "it has no scoped CSS" — **re-derive that property from
that repo at sweep time.** It happened once already: the 2026-07-19 `/admin/secrets` modal gate
skipped mxgw as "Bootstrap-free so never affected", which was false; mxgw self-hosts Bootstrap
5.3.3 and *was* affected, and only escaped because it had taken the fix independently. The
exemption reached the right conclusion on false reasoning and nobody re-checked for three weeks.
This is the same discipline §1c applies to cited config keys, aimed at scope decisions instead of
prose.
---
@@ -27,9 +50,32 @@ The theme ships less than pages assume. Check, in order:
1. **Shipped-vs-used class matrix.** Extract every CSS class used in `*.razor` markup and check each
against (a) the theme package's `staticwebassets/css/*.css`
(`~/.nuget/packages/zb.mom.ww.theme/<ver>/`), (b) Bootstrap, (c) the app's own stylesheet, (d)
page `<style>` blocks. Anything defined nowhere is a **silent no-op** — the browser ignores it
without any error. (Router: 13 ghost classes from the mockups' theme.css that 0.3.1 never
shipped. OtOpcUa: zero — it was authored against what ships.)
page `<style>` blocks, **(e) the test sources**. Anything defined in none of (a)–(d) is a
**silent no-op** — the browser ignores it without any error. (Router: 13 ghost classes from the
mockups' theme.css that 0.3.1 never shipped. OtOpcUa: zero — it was authored against what ships.
mxgw: 3, one load-bearing. HistorianGateway: 4. ScadaBridge: 26, but only one lost intent.)
**Check (e) BEFORE writing the ghost list, not as a filter afterwards.** A class matched only by
a test is a **selector, not a ghost** — rename it deliberately or leave it, never delete it. The
failure mode is nasty: deleting it reds the suite, and the tempting "fix" is to amend the
assertions, which quietly destroys the coverage. HistorianGateway's 16 `console-*` classes look
exactly like ghosts and are in fact driven by
`cut.Find("input.console-tag")` / `button.console-run`, with the Viewer role gate resting on
`Assert.DoesNotContain("console-write", cut.Markup)` — five tests. That sweep caught them only
because it happened to read the test files while checking something else; ordering luck, not
method. Hence: check first.
**The constraint is not limited to class names.** A bUnit suite can pin markup by *substring*
and by *visible text*: `ApiKeysPageRenderTests` asserts `DoesNotContain("secret", …,
OrdinalIgnoreCase)` over the list markup, and elsewhere selects with
`Find("button:contains('Create API Key')")`. So **button label text is load-bearing too** — a
sweep that "improves" wording breaks a suite exactly as a deleted class does. This binds §1c as
much as it binds this step; see the note there.
Corollary, learned the hard way twice: **a dead-code claim derived from grepping `src/` alone is
not a dead-code claim.** ScadaBridge nearly deleted `Shared/DataTable.razor` as an orphan —
`DataTablePagerTests` renders it. Same mistake as the `console-*` near-deletion, different
clothes.
2. **Is the scoped-CSS bundle linked?** If any `*.razor.css` exists, the build emits
`<App>.bundle.scp.css` — but nothing links it automatically when the head is hand-authored.
Compare the `<link>` list in `App.razor` against `obj/…/scopedcss/bundle/`. OtOpcUa's
@@ -43,8 +89,45 @@ The theme ships less than pages assume. Check, in order:
4. **Button sizing.** Theme 0.3.1 ships **no `.btn` rule**, so Bootstrap's hard-coded
`--bs-btn-font-size: 1rem` applies against the theme's 0.9rem body. Count how many buttons carry
`btn-sm` vs full-size; modal feet and shared form shells are where full-size accumulates.
5. **Dark scheme: don't chase it.** Theme 0.3.1 is light-only (no `prefers-color-scheme` /
`data-bs-theme` anywhere). Fix any comment claiming otherwise.
(ScadaBridge: 50 full-size + 13 `py-0 px-*` hacks.)
Two **inverted** instances, where the standard §2 `.btn` block would not have helped:
- **A literal `font-size` in the app stylesheet** (mxgw `site.css:187`, `font-size: 0.82rem`).
Bootstrap sizes `.btn-sm`/`.btn-group-sm` *purely* by redefining `--bs-btn-font-size`, so a
literal at equal specificity loaded later flattens every button to one size — `btn-sm` becomes
a font-size no-op app-wide and differs only in padding. Tell: `btn-sm` present in quantity but
visually indistinguishable.
- **A chassis class standing in for the button system** (HistorianGateway: all 13 buttons were
`.rail-btn`, the *side-rail* class, and 11 carried inline `style="width:auto"` to undo the
`width:100%` a local override imposed). The `.btn` block would have applied to nothing. Fix is
the block **plus** moving page buttons onto real `.btn`; the width hacks then delete
themselves. Tell: inline sizing hacks clustered on one non-`.btn` class.
5. **Dark scheme: determine whether the app has one.** Theme 0.3.1 itself is light-only (no
`prefers-color-scheme` / `data-bs-theme`), and the router, OtOpcUa, mxgw and HistorianGateway
are all light-only — for those, don't chase it, and fix any comment claiming otherwise.
**ScadaBridge is the exception**: it implements its own `[data-bs-theme="dark"]` token block
plus a pre-paint localStorage script. Where a second scheme exists this step **inverts** —
hard-coded colours and inline token overrides are actively worse, not merely moot, because they
are invisible in the scheme you're looking at and broken in the other.
6. **Per-app token overrides delivered as inline styles.** Check how the app passes brand tokens to
`ThemeShell`. The kit emits `Accent="…"` as an inline `style="--accent: …"` **on the shell
root** — a descendant of `<html>` — so it **beats every scheme-level `:root` override below it**.
ScadaBridge's `Accent="#2f5fd0"` killed the dark accent app-wide (brand mark, active rail-link
border, `.rail-btn`) while being a no-op in light, because that hex already *is* theme.css's
light default: invisible in both directions.
The general rule: **a per-app token override delivered as an inline style on a shell element
silently defeats every scheme-level `:root` override.** Declare the token in the app
stylesheet's `:root` instead — it then loses to the dark block by source order and wins over
theme.css by sheet order.
Worked example of why to check even when nothing looks wrong: **OtOpcUa passes the identical
`Accent="#2f5fd0"`** (`MainLayout.razor:11`) and has no dark scheme at all, so the override is
**inert today and latent forever** — it does nothing now and silently kills the dark accent the
day someone adds a dark block. That future session will see "dark mode doesn't work" with no
reason to suspect a layout parameter set years earlier. ignitionoee passes no `Accent` and is
unaffected.
### 1b. Button inventory
@@ -77,7 +160,18 @@ Inventory, with file:line:
- **KEEP**: state banners (empty/error/truncation/live status), form hints (`form-text`, field
hints — they render only in form context), visual-encoding **legends** that are the only decoder
of a chip/graphic, workflow cross-links, expander-gated notes.
- **Hunt stale claims while you're in there** — both sweeps found prose that was actively *wrong*,
- **Before rewording anything, re-read §1a-1(e).** In a repo with bUnit, **visible text is a
selector**: `Find("button:contains('Create API Key')")` makes a button's label load-bearing, and
`DoesNotContain("secret", …, OrdinalIgnoreCase)` makes an *absent* substring load-bearing. Prose
work is the activity most likely to trip this, because improving wording is the whole point of
it. Grep the test sources for any string you are about to change.
- **The wrong document may be the docs, not the page.** ScadaBridge inverted this section entirely:
zero DELETEs, zero RELOCATEs — its pages carried no unconditional documentation at all — but its
*docs* were wrong. `Component-Transport.md` claimed notification lists pull their SMTP config
(they don't) and listed API keys as exportable (deliberately not; SMS configs were missing
instead), plus stale ClusterClient claims in three files. When the page and the doc disagree,
establish which one the code supports before assuming the page is the stale one.
- **Hunt stale claims while you're in there** — every sweep found prose that was actively *wrong*,
not just verbose: milestone labels for shipped features ("F8/F9 pending", "(F3)", "arriving in
Batch 2/3"), architecture claims superseded by later phases, dead hyperlinks to repo paths the
app doesn't serve, and **config keys that don't exist** (prose said `Authentication:Ldap`, the
@@ -162,6 +256,10 @@ foundation branch merges — all branches build independently and compose at mer
- `<div class="btn-group" role="group">` (`btn-group btn-group-sm` when members are small); fold
per-button `btn-sm` into the group class; drop `ms-2`/`me-1` spacers between members.
**Budget for the fold breaking test locators**: any selector pinning `button.btn-*.btn-sm` stops
matching the moment `btn-sm` moves onto the group. ScadaBridge updated 16 selectors for exactly
this and it is a mechanical, expected cost — not a signal that the fold was wrong. Three sweeps
hit the fold and only the one with a dense suite noticed, which is why it is written down here.
- Mixed `<a class="btn">` + `<button>` members are valid Bootstrap — group them (keep `download`
etc. attributes verbatim).
- A group whose conditional member doesn't render is fine — keep `@if` inside the group.
@@ -184,7 +282,14 @@ foundation branch merges — all branches build independently and compose at mer
- **Truncate unbounded text with a safe helper, never a raw slice.** OtOpcUa's
`DisplayText.Abbreviate(value, 60)` + the full text on `title` — a raw `@x[..60]` 500s the whole
page when the value is shorter (OtOpcUa #504). Keep the full text reachable (tooltip + the
row's detail dialog).
row's detail dialog). Every app that hit this needed its own helper (HistorianGateway added
`Dashboard/DisplayText.cs` with 7 tests pinning the short-value case); ScadaBridge also found
three unguarded `[..8]` slices in `Topology.razor` that were a latent
`ArgumentOutOfRangeException` on the deploy path, so grep for raw range operators generally, not
only in the cells you're truncating.
- **CSS clipping is the wrong tool for user-resizable grid columns.** `nowrap` + `max-width` raises
the column's min-content width and breaks drag-to-resize. Cap the *string* there instead, and
keep CSS clamping for fixed-width columns.
- **Inline expander rows → a dialog.** Host the same detail component in a Bootstrap modal
(`modal-backdrop fade show` + `modal fade show d-block`, `modal-lg`), one at a time. Lifecycle
cautions that made the OtOpcUa version correct:
@@ -201,23 +306,55 @@ foundation branch merges — all branches build independently and compose at mer
- Discovery scans → plan doc with the full inventories → **one foundation agent + N page-batch
agents on disjoint file sets**, all parallel; shared components (form shells, collection editors)
are the biggest lever — one file can fix a dozen forms.
- Briefs must carry exact `file:line` inventories **and** the instruction *"verify by reading
before editing; refuse items the markup contradicts."* Both sweeps had agents correctly refuse
brief items (buttons in mutually exclusive `@if` branches can't be grouped; toolbars the
inventory over-counted).
- Briefs must carry exact `file:line` inventories **and the rubric that produced them**, not just
the line numbers — plus the instruction *"verify by reading before editing; refuse items the
markup contradicts."* An agent holding only a line list can't tell a mis-derived item from a
correct one. Every sweep so far has had agents correctly refuse brief items (buttons in mutually
exclusive `@if` branches can't be grouped; toolbars the inventory over-counted), four of
ScadaBridge's seven batches did, and one there caught a brief instruction that would have failed
the test suite. Treat refusals as the mechanism working, not as friction.
- Merge foundation first, then batches; run the **full build + test suite on the merged tree** even
if every branch was green in isolation (parallel branches compose only at the full build).
- **Baseline any failure you intend to call pre-existing — measure it, don't assert it.** Stash to a
clean tree and run it more than once; a test that fails 2-in-3 on the untouched base is not your
regression, and you can only know that by running it. Related trap: **a suspiciously fast green
is not a pass.** HistorianGateway's `RedundantWritePathLiveTests` "passed in isolation" in 504 ms
while the real code path requires a live historian round trip — it was taking an early-return
guard and passing *vacuously*, and the full-suite run that failed was the honest one. This
inverts the usual reading of "passes in isolation." An unguarded environment-dependent test that
fails rather than skips on a dev box is a hygiene defect worth filing on its own, because it
trains people to ignore a red suite.
- Verification greps on the merged tree: zero remaining phantom-token usages, zero remaining
`&middot;` action separators / inline `width:` hacks, `btn-group` count roughly matching the
inventory, the bundle link present.
### Live browser gate (mandatory — these apps have no bUnit)
### Live browser gate (mandatory in every app, bUnit or not)
bUnit cannot see computed styles, cascade order, or whether an element is actually reachable by a
pointer — which is precisely what this sweep changes. Two probes worth stealing:
**hit-testability**, not just visibility (`document.elementFromPoint` at a confirm button's centre
must return the button, not the backdrop — mxgw's modal gate), and **realistic-length injected
data** (ScadaBridge injected a 150-char OPC UA node id; HistorianGateway created a key with an
88-char display name, which rendered as 40 chars + ellipsis with Revoke still in the viewport).
**Record what you could not exercise rather than rounding it up.** HistorianGateway proved 2 of its
4 truncation sites live and said so: `sf.Error` needs a genuine drain failure, which needs a
historian that accepts a connection and *then* rejects the write, and a box with nothing listening
dies at setup and never enqueues. That is a rig capability ceiling — don't manufacture it, and
don't claim the sites you couldn't reach.
Rebuild/redeploy the app's dev rig (**all** instances behind the load balancer — round-robin serves
you the stale one otherwise), then walk:
1. Computed-style probes (one `javascript` eval): a `.btn`'s `font-size` vs body; the phantom-token
replacement class resolving to real colors; the scoped bundle present in `document.styleSheets`.
**Measure behaviour rather than reading a computed value when the two can disagree.** Chrome
serializes `-webkit-box` as `display: flow-root`, so a working line-clamp *reads* like a silent
failure; ScadaBridge measured instead (40px clamped vs 500px unclamped) and confirmed it works.
Reading the value alone would have produced a "fix" for something that was never broken.
If the app has a second colour scheme, probe **both** — flip it and confirm the accent tokens
move together (ScadaBridge: `rgb(47,95,208)` → `rgb(77,127,232)`, with `--accent-deep` following
instead of desyncing).
2. Every page leads with data — no unconditional documentation above the fold.
3. Seamed `btn-group`s where the inventory says; destructive members red.
4. One arm→confirm flow per style: arm → confirm UI appears → **Cancel** (never complete a real