docs: admin_ui_cleanup.md — reusable admin-UI sweep playbook (from the ignitionoee router + OtOpcUa AdminUI rounds)

This commit is contained in:
Joseph Doherty
2026-08-10 17:09:00 -04:00
parent 2a38b6ac76
commit f0f607ea46
+187
View File
@@ -0,0 +1,187 @@
# 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:
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.)
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
`ContextMenu.razor.css` built, served 200, and was referenced by **nothing** — every context menu
rendered in-flow (no `position:fixed`, no backdrop) for the component's whole life.
3. **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.)
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.
### 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 `&middot;` 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="#">` or `btn-link` text-links) → real
`btn 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`/`Open` as links, convert `+ Area`/`Edit`/`Delete` to 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-sm` beside 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's `SectionName` said `Security: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-rolled `rowspan` grouping, 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.
1. **App stylesheet** loaded **after** `<ThemeHead />` so it wins the cascade (create one if the app
lacks it; the router created `app.css`, OtOpcUa extended `site.css`).
2. **Button sizing as Bootstrap CSS-variable overrides** — never redefine box properties wholesale
(`btn-group` seam/radius machinery depends on them). The block both apps now carry:
```css
.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.css` and OtOpcUa `site.css`; when a
`ZB.MOM.WW.Theme` release ships a `.btn` rule, delete the local copies (both files carry a header
comment saying exactly this).
3. **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 inline `style="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.
4. **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.
5. **Shared class for repeated inline sizing** (e.g. `.tree-twist { flex: 0 0 18px; width: 18px; }`
replacing four trees' `style="width:18px"` expanders).
6. **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-sm` when members are small); fold
per-button `btn-sm` into the group class; drop `ms-2`/`me-1` spacers between members.
- 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.
- Deliberately separated members stay **outside** the group (the `ms-auto` Delete in a form foot);
inputs interleaved in a run split it into two groups (never put a `form-control` inside).
- Destructive actions → `btn-outline-danger`; the armed/confirm state may go solid `btn-danger`.
- **Preserve behavior byte-for-byte**: every `@onclick`, `disabled`, binding, `AuthorizeView` gate,
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.js` navigation 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: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).
- 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
`&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)
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`.
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
write on shared rigs); verify disarm.
5. Anything the scoped bundle resurrected (context menus float at the cursor, backdrops cover).
6. 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.