# 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/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.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. `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 + 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).
