11 KiB
UI-Theme Adoption — Design
Date: 2026-06-03
Status: Approved (brainstorming complete) — ready for writing-plans.
Component: UI Theme (ZB.MOM.WW.Theme shared RCL).
Goal: Adopt the shared ZB.MOM.WW.Theme Razor Class Library across all three sister
apps (OtOpcUa AdminUI, MxAccessGateway Dashboard, ScadaBridge CentralUI + Host) via a
full canonical cutover (SPEC §7), after first promoting nav-expand persistence into
the kit so every app gets it from one shared mechanism.
This is the UI-theme analogue of the completed Auth+Audit normalization (
docs/plans/2026-06-02-auth-audit-normalization*.md). It is UI-only: no data contracts, no DB migrations, no wire protocols. The dominant risk is visual regression, not data corruption.
0. Verified starting state (2026-06-03)
Independently verified (the component docs were optimistic — cf. memory
component-status-claims-are-optimistic):
- Library is real but unpublished and unadopted.
ZB.MOM.WW.Theme/holds all 10 components + a Release0.1.0nupkg, but the Gitea feed returns HTTP 404 for the package and no app references it. The shared-contract's "Published to the Gitea NuGet feed" is aspirational. → This is a clean publish + adopt. - Library is plain files tracked by
scadaproj(not a nested git repo) — library changes commit inscadaproj(cf. memoryshared-libs-are-plain-files-not-nested-repos). - Per-app surface matches
components/ui-theme/GAPS.md:- OtOpcUa AdminUI — already side-rail (
.app-shell/.side-rail/.rail-link); interactiveNavSidebarisland (@rendermode InteractiveServer) holding_expanded, persisted via JS interop (window.navState.get/.set) to theotopcua_navcookie (comma-separated section ids, 1-yr,SameSite=Lax); bespokeStatusBadge; static-POSTLogin.razor; owntheme.css+ vendored fonts. Lowest risk. - ScadaBridge CentralUI —
.sidebar/.nav-link/<ul><li>(NavMenu+NavSection);Login.razor+LoginLayout; owntheme.css; Host ownsApp.razor. Medium risk. - MxAccessGateway Dashboard — combined
MainLayout(~210 lines);.sidebar/.nav-link;StatusBadge; no Blazor login page (server-redirect); owntheme.css(font path is absolute/fonts/…, not portable). Highest risk.
- OtOpcUa AdminUI — already side-rail (
1. Decisions (locked during brainstorming)
| # | Decision | Choice |
|---|---|---|
| D1 | Adoption depth | A — Full canonical cutover (SPEC §7 acceptance, all three apps) |
| D2 | Nav persistence | On all apps, via one shared kit mechanism (not bespoke per app) |
| D3 | Persistence implementation | CSS <details> + localStorage enhancer (recommended over promoting OtOpcUa's interactive-island+cookie) |
| D4 | MxGateway login | Add a new <LoginCard> Blazor login page (the higher-risk consistency option) |
| D5 | Delivery model | Same as Auth/Audit — feat/adopt-zb-theme per app, local-only, then fast-forward merge to each repo's default + push to gitea on explicit go; scadaproj docs on docs/ui-theme-adoption |
| D6 | Publish | Publish the (enhanced) RCL to the Gitea feed first, then adopt (needs GITEA_NUGET_KEY, user-supplied, not persisted) |
| D7 | Library version | Bump 0.1.0 → 0.2.0 (new feature: persistent nav + ThemeScripts); publish 0.2.0 directly (0.1.0 was never released) |
| D8 | Accent colors | Preserve each app's current --accent value (move the source to the RCL, don't shift palettes) |
2. Program shape & sequencing
A library-minor-then-adopt waterfall (same shape as Auth/Audit):
- Phase 0 — Library enhancement + publish. Add shared nav persistence (§3), bump to
0.2.0, run the bUnit suite,build/push.shto the Gitea feed. Commits inscadaproj. - Phase 1 — OtOpcUa AdminUI (lowest risk; already side-rail; validates the pattern).
- Phase 2 — ScadaBridge CentralUI + Host (medium; class migration + AuthorizeView nav).
- Phase 3 — MxAccessGateway Dashboard (highest; split combined layout and add the
net-new
LoginCardpage). - Phase 4 — scadaproj docs + memory (GAPS adoption banner; CLAUDE.md ui-theme row → Adopted; shared-contract → Published 0.2.0; memory note).
Execution: subagent-driven, classification-driven reviews (trivial→none; small→code; standard→spec∥code parallel; high-risk→serial spec→code + final integration review).
Delivery: feat/adopt-zb-theme branch per app, local-only; full build+test green per
repo; fast-forward merge to each default + push to gitea on the user's explicit go.
3. Library enhancement: shared nav persistence (Phase 0)
Promote one shared mechanism into the kit — a simpler generalization of OtOpcUa's proven cookie+interop approach.
Mechanism — CSS <details> + localStorage enhancer:
NavRailSectionstays the static-SSR-friendly<details class="rail-section" open="@Expanded">it already is. It gains a stableKeyparameter (default = a slug ofTitle) emitted as adata-nav-keyattribute on the<details>.- New vendored asset
wwwroot/js/nav-state.jsin the RCL: onDOMContentLoaded, for each[data-nav-key], readlocalStorageand setel.open; attach atogglelistener that writesel.openback tolocalStoragekeyed bydata-nav-key. Pure client-side progressive enhancement — no circuit, no server round-trip. - New
<ThemeScripts/>component (sibling toThemeHead) emits<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer></script>, placed before</body>.
Why localStorage over promoting OtOpcUa's island+cookie: keeps the kit
static-SSR-friendly (no forced InteractiveServer island per app), one shared file,
uniform across all three. It simplifies OtOpcUa — retiring its interactive NavSidebar
island + nav-state.js + otopcua_nav cookie in favor of the shared enhancer. localStorage
is per-browser/origin (same effective scope as the old cookie) and is never read
server-side today, so nothing is lost.
Trade-off: a brief flash-of-default-state on first paint (localStorage isn't readable server-side, so sections render at their server default and JS corrects after load). Negligible for a nav rail. (If zero-flash were required, the alternative is a server-read cookie — rejected as more kit coupling.)
Version: 0.1.0 → 0.2.0 (additive feature). Tests: extend the bUnit suite —
NavRailSection emits data-nav-key (derived slug + explicit Key); ThemeScripts emits
the script tag. JS runtime behavior is covered by the per-app manual checklist (§5), since
bUnit has no JS engine.
4. Per-app adoption scope (full canonical cutover)
Each app, per SPEC §7: add PackageReference ZB.MOM.WW.Theme 0.2.0 + @using ZB.MOM.WW.Theme
in _Imports.razor; <ThemeHead/> in App.razor <head> after Bootstrap + <ThemeScripts/>
before </body>; delete the app's theme.css + vendored IBM Plex .woff2 fonts; replace
MainLayout with the thin delegation to <ThemeShell Product=… Accent=…>; rebuild nav with
NavRailItem/NavRailSection; StatusBadge→<StatusPill>; login → <LoginCard>; keep
each app's site.css page-layout residual + scoped .razor.css unchanged. --accent
preserves each app's current value (D8).
| App | Notable specifics | Risk |
|---|---|---|
| OtOpcUa AdminUI | Already-correct rail classes (RCL layout.css matches). Retire NavSidebar island + nav-state.js + otopcua_nav cookie → kit NavRailSection/NavRailItem + shared enhancer. RailFooter = the existing AuthorizeView session block. StatusBadge→StatusPill. Login.razor→LoginCard (keep static POST, <AntiforgeryToken/>, server-validate ReturnUrl). |
Low–Med |
| ScadaBridge CentralUI + Host | .sidebar/.nav-link/<ul><li> (NavMenu+NavSection) → kit nav (class migration throughout). Verify <AuthorizeView> policy-gated sections render/hide under static SSR (GAPS open Q). <ThemeHead/>/<ThemeScripts/> go in Host's App.razor. StatusBadge/inline .chip-*→StatusPill. Login.razor+LoginLayout→LoginCard. |
Med |
| MxGateway Dashboard | Split combined ~210-line MainLayout → thin MainLayout + <ThemeShell> (nav extracted into the Nav slot). .sidebar/.nav-link→rail classes; portable font path fixed by RCL. StatusBadge→StatusPill. Add a new /login Blazor page using <LoginCard> posting to a /auth/login endpoint wired to the app's existing ZB.MOM.WW.Auth LDAP service + dashboard cookie SignInAsync (mirror OtOpcUa/ScadaBridge static-POST login). Verify the server auth-redirect now lands on this page. |
High |
5. Delivery, risk & verification
- Build/test gate per repo:
dotnet build+ the full suite green before merge. Baseline the known pre-existing reds first and do not chase them (ScadaBridge IntegrationTests ×11 needing live LDAP/SQL/SMTP + flakyStaleTagMonitortimer tests; MxGateway 3 FakeWorker tests) — only regressions introduced by this work count. - Visual regression is the real risk — a green build does not prove the chrome looks
right. Verification per app = a structured manual checklist:
- Rail renders at
lg+ and collapses to a hamburger toggle belowlg. - Nav expand-state persists across navigations and a full reload (shared enhancer).
StatusPillrenders correctly in all five states (Ok/Warn/Bad/Idle/Info).- Login posts, round-trips
ReturnUrlsafely (server-validated), shows errors. - IBM Plex fonts load from
_content/ZB.MOM.WW.Theme/fonts/…(no 404; OtOpcUa's latent font 404 is fixed).
- Rail renders at
- Optional browser smoke pass: run each app locally and drive a Claude-in-Chrome smoke pass (screenshots of shell + login) before merge — included only if the user opts in; otherwise the checklist above is run manually.
- MxGateway
/loginis auth-facing and net-new →high-riskclassification (serial spec→code review + final integration review).
6. Acceptance (per app)
Mirrors SPEC §7: (1) ZB.MOM.WW.Theme 0.2.0 referenced + in _Imports.razor; (2)
<ThemeHead/> after Bootstrap and per-app theme.css/fonts deleted; (3) MainLayout is the
thin ThemeShell delegation; (4) nav rebuilt with NavRailItem/NavRailSection (+ shared
persistence via <ThemeScripts/>); (5) local StatusBadge/.chip-* removed → <StatusPill>;
(6) login is <LoginCard> (static POST, <AntiforgeryToken/>, server-validated ReturnUrl)
— including MxGateway's net-new page; (7) site.css residual + scoped .razor.css kept.
7. Out of scope
Per SPEC §0/§6: each app's site.css page-layout residual, route/page content, scoped
.razor.css, authorization logic. The kit owns chrome and tokens, not domain screens.
No new data grids/modals/toasts (YAGNI). Bootstrap stays per-app (not vendored by the kit).