12 KiB
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 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
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.
1. Discovery — four parallel read-only scans
Run these as parallel read-only agents (or by hand) and write the inventories down with file:line
before changing anything. The inventories become the per-batch fix briefs.
1a. Foundation / CSS audit
The theme ships less than pages assume. Check, in order:
- Shipped-vs-used class matrix. Extract every CSS class used in
*.razormarkup and check each against (a) the theme package'sstaticwebassets/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.) - Is the scoped-CSS bundle linked? If any
*.razor.cssexists, the build emits<App>.bundle.scp.css— but nothing links it automatically when the head is hand-authored. Compare the<link>list inApp.razoragainstobj/…/scopedcss/bundle/. OtOpcUa'sContextMenu.razor.cssbuilt, served 200, and was referenced by nothing — every context menu rendered in-flow (noposition:fixed, no backdrop) for the component's whole life. - Phantom CSS custom properties. Grep every
var(--…)in markup/inline styles against the tokens actually defined in the theme + app CSS. An undefined token makes the declaration invalid at computed-value time — silently dropped, no console error. A fallback argument (var(--x, #fff)) hides the defect. (OtOpcUa:var(--alert)×13 — the theme's red family is--bad*— so intended red error tints stayed amber;var(--surface-2)likewise dead.) - Button sizing. Theme 0.3.1 ships no
.btnrule, so Bootstrap's hard-coded--bs-btn-font-size: 1remapplies against the theme's 0.9rem body. Count how many buttons carrybtn-smvs full-size; modal feet and shared form shells are where full-size accumulates. - Dark scheme: don't chase it. Theme 0.3.1 is light-only (no
prefers-color-scheme/data-bs-themeanywhere). Fix any comment claiming otherwise.
1b. Button inventory
Quick tells: grep -rn "btn-group" Components/ returning zero means nothing is grouped
(both apps' idiom was d-flex gap-2); also grep for · separators between actions,
btn-link p-0 on things that do rather than navigate, py-0/inline width: sizing hacks.
Inventory, with file:line:
- Adjacent related buttons that should be one
btn-group: page-head action pairs, modal/panel feet (Save/Cancel, Cancel/Confirm), pagers, browse-toolbar pairs, inline confirm pairs. - Row actions styled as links (
<a href="#">orbtn-linktext-links) → realbtn btn-sm btn-outline-secondary, destructive →btn-outline-danger, grouped when ≥2 in a cell. Distinguish navigation from action: a link that routes somewhere stays a link; a link that mutates state becomes a button. Both directions can coexist in one row (OtOpcUa's UnsTree: keep⚙ settings/Openas links, convert+ Area/Edit/Deleteto buttons). - Arm→confirm flows (click Delete → Confirm/Cancel appears): list them; the fix is restyle-only and must not collapse the two-step.
- Size mismatches within one visual group (
btn-smbeside full-size).
1c. Prose inventory (DELETE / KEEP / RELOCATE rubric — binding)
- DELETE: unconditional documentation blocks — always-rendered banners explaining how a feature
works, config-mode walkthroughs, "how X is decided" sections, column-meaning tables. These belong
in
docs/, not on the page. Verify each fact is already docs-covered (grep for distinctive phrases); if it is, plain delete. - RELOCATE-then-delete: facts stated nowhere else. Land them in the right doc file first, then delete the page copy — same ordering both sweeps used (foundation/doc task before page batches).
- 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,
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 code'sSectionNamesaidSecurity:Ldap— always verify cited key names against the options class). Wrong facts outrank style: fix or delete them even if you skip everything else.
1d. Tree tables — check the data shape before porting the component
The router built a generic TreeTable + TreeVisibility (depth-ordered flat ITreeRow list,
forward-scan visibility — reference implementation in
../ignitionoee/router/src/ZB.MOM.Oee.Ui/Components/Shared/TreeTable.razor + ViewModels/TreeRows.cs)
and adopted it on two pages. OtOpcUa rejected it entirely after the scan. The rubric:
- Candidates: tables flattening real hierarchy through hacks — a repeated
"SITE / AREA / LINE"path-prefix column, hand-rolledrowspangrouping, indent-by-padding rows, faked full-width group-header rows. - Not candidates: rank-ordered lists and fact/audit logs (a tree destroys load-bearing ordering), tables already grouped per parent (one table per group), rows that are all roots.
- Nav/picker trees are a different animal. Lazy-loading navigation trees with context menus or selection (OtOpcUa has four) don't fit the flat-list TreeTable model (no columns, lazy expansion) — leave them alone. Duplicated expander scaffolding across nav trees is not, by itself, reason to unify in a repo with no component test harness.
If no adoption justifies the component, don't port it — an unused component is an orphan.
2. How to fix
Foundation first (one task, own files, everything else references it by name)
CSS class names aren't compile-checked, so batch agents can reference foundation classes before the foundation branch merges — all branches build independently and compose at merge.
-
App stylesheet loaded after
<ThemeHead />so it wins the cascade (create one if the app lacks it; the router createdapp.css, OtOpcUa extendedsite.css). -
Button sizing as Bootstrap CSS-variable overrides — never redefine box properties wholesale (
btn-groupseam/radius machinery depends on them). The block both apps now carry:.btn { --bs-btn-font-size: .85rem; --bs-btn-padding-y: .35rem; --bs-btn-padding-x: .8rem; --bs-btn-line-height: 1.4; } .btn-group-sm > .btn, .btn-sm { --bs-btn-font-size: .78rem; --bs-btn-padding-y: .25rem; --bs-btn-padding-x: .6rem; }This normalizes every unsized button app-wide without touching call sites. Upstreaming candidate: this block is now duplicated in ignitionoee
app.cssand OtOpcUasite.css; when aZB.MOM.WW.Themerelease ships a.btnrule, delete the local copies (both files carry a header comment saying exactly this). -
Phantom tokens → a real class, not a token alias. OtOpcUa's pattern:
.notice-alert { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-border); }then migrate the inlinestyle="border-color:var(--alert)"hacks to the class in the page batches. Mirror how the theme's own variant classes (e.g..chip-alert) use the tokens. -
Link the scoped bundle if 1a-2 found it dead:
<link rel="stylesheet" href="_content/<AppNamespace>/<AppNamespace>.bundle.scp.css"/>— verify the exact asset name from the build output, not by guessing. -
Shared class for repeated inline sizing (e.g.
.tree-twist { flex: 0 0 18px; width: 18px; }replacing four trees'style="width:18px"expanders). -
Relocate the orphaned prose facts into docs (from 1c) in this same task, so page deletions never race the relocation.
Button conventions (page batches)
<div class="btn-group" role="group">(btn-group btn-group-smwhen members are small); fold per-buttonbtn-sminto the group class; dropms-2/me-1spacers between members.- Mixed
<a class="btn">+<button>members are valid Bootstrap — group them (keepdownloadetc. attributes verbatim). - A group whose conditional member doesn't render is fine — keep
@ifinside the group. - Deliberately separated members stay outside the group (the
ms-autoDelete in a form foot); inputs interleaved in a run split it into two groups (never put aform-controlinside). - Destructive actions →
btn-outline-danger; the armed/confirm state may go solidbtn-danger. - Preserve behavior byte-for-byte: every
@onclick,disabled, binding,AuthorizeViewgate, and arm→confirm flow unchanged — wrappers, classes, and prose only. Grep the diff for@onclick|disabled=|href=|type="submit"appearing identically on both sides. - Respect existing "buttons-not-anchors" comments (Blazor's
blazor.web.jsnavigation interceptor is why some tree labels are buttons) — never convert those to links.
Execution pattern (what actually worked, twice)
- 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:lineinventories 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@ifbranches can't be grouped; toolbars the inventory over-counted). - 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).
- Verification greps on the merged tree: zero remaining phantom-token usages, zero remaining
·action separators / inlinewidth:hacks,btn-groupcount roughly matching the inventory, the bundle link present.
Live browser gate (mandatory — these apps have no bUnit)
Rebuild/redeploy the app's dev rig (all instances behind the load balancer — round-robin serves you the stale one otherwise), then walk:
- Computed-style probes (one
javascripteval): a.btn'sfont-sizevs body; the phantom-token replacement class resolving to real colors; the scoped bundle present indocument.styleSheets. - Every page leads with data — no unconditional documentation above the fold.
- Seamed
btn-groups where the inventory says; destructive members red. - One arm→confirm flow per style: arm → confirm UI appears → Cancel (never complete a real write on shared rigs); verify disarm.
- Anything the scoped bundle resurrected (context menus float at the cursor, backdrops cover).
- Legends/hints/empty states from the KEEP list still render.
Record PASS/FAIL per check in the plan or a recipe doc; CSS-only fixes may land in-session, anything structural gets recorded instead.