Files
scadaproj/docs/plans/2026-06-03-ui-theme-adoption-design.md
T

11 KiB
Raw Blame History

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 Release 0.1.0 nupkg, 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 in scadaproj (cf. memory shared-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); interactive NavSidebar island (@rendermode InteractiveServer) holding _expanded, persisted via JS interop (window.navState.get/.set) to the otopcua_nav cookie (comma-separated section ids, 1-yr, SameSite=Lax); bespoke StatusBadge; static-POST Login.razor; own theme.css + vendored fonts. Lowest risk.
    • ScadaBridge CentralUI.sidebar/.nav-link/<ul><li> (NavMenu + NavSection); Login.razor + LoginLayout; own theme.css; Host owns App.razor. Medium risk.
    • MxAccessGateway Dashboard — combined MainLayout (~210 lines); .sidebar/.nav-link; StatusBadge; no Blazor login page (server-redirect); own theme.css (font path is absolute /fonts/…, not portable). Highest risk.

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/Auditfeat/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.sh to the Gitea feed. Commits in scadaproj.
  • 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 LoginCard page).
  • 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:

  • NavRailSection stays the static-SSR-friendly <details class="rail-section" open="@Expanded"> it already is. It gains a stable Key parameter (default = a slug of Title) emitted as a data-nav-key attribute on the <details>.
  • New vendored asset wwwroot/js/nav-state.js in the RCL: on DOMContentLoaded, for each [data-nav-key], read localStorage and set el.open; attach a toggle listener that writes el.open back to localStorage keyed by data-nav-key. Pure client-side progressive enhancement — no circuit, no server round-trip.
  • New <ThemeScripts/> component (sibling to ThemeHead) 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. StatusBadgeStatusPill. Login.razorLoginCard (keep static POST, <AntiforgeryToken/>, server-validate ReturnUrl). LowMed
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+LoginLayoutLoginCard. 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. StatusBadgeStatusPill. 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 + flaky StaleTagMonitor timer 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:
    1. Rail renders at lg+ and collapses to a hamburger toggle below lg.
    2. Nav expand-state persists across navigations and a full reload (shared enhancer).
    3. StatusPill renders correctly in all five states (Ok/Warn/Bad/Idle/Info).
    4. Login posts, round-trips ReturnUrl safely (server-validated), shows errors.
    5. IBM Plex fonts load from _content/ZB.MOM.WW.Theme/fonts/… (no 404; OtOpcUa's latent font 404 is fixed).
  • 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 /login is auth-facing and net-new → high-risk classification (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).