Compare commits

...

8 Commits

13 changed files with 1009 additions and 19 deletions
+12 -5
View File
@@ -121,7 +121,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Adopted (lib `0.1.3`; all 3 apps, merged to **local default** main/master + **pushed to origin** (gitea)) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| UI Theme (layout / tokens / components) | Adopted (lib `0.2.0`; all 3 apps, local `feat/adopt-zb-theme` branches) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
| Health (readiness / liveness / active-node) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
| Observability (metrics / traces / logs) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Telemetry` lib + `.Serilog` | [`components/observability/`](components/observability/) | [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/) |
| Config + validation (options / startup validation) | Adopted (lib `0.1.0`; all 3 apps, local) | Shared `ZB.MOM.WW.Configuration` lib | [`components/configuration/`](components/configuration/) | [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/) |
@@ -156,10 +156,17 @@ backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + wi
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
(.NET 10 Razor Class Library; single package; 32 bUnit tests; `dotnet pack` → 1 nupkg @ 0.1.0).
The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
(.NET 10 Razor Class Library; single package; 44 bUnit tests; `dotnet pack` → 1 nupkg @ 0.2.0,
**published to the Gitea feed**). The build plan is at
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md);
the adoption plan at [`docs/plans/2026-06-03-ui-theme-adoption.md`](docs/plans/2026-06-03-ui-theme-adoption.md).
**Adopted across all three apps on 2026-06-03** (full canonical cutover, SPEC §7) on each repo's local-only
`feat/adopt-zb-theme` branch (OtOpcUa `11de14d`, ScadaBridge `58352a6`, MxGateway `73e54e2` — committed,
spec+code reviewed, **not yet merged/pushed**). The `0.1.0 → 0.2.0` bump first promoted nav-expand persistence
into the kit (`NavRailSection.Key`/`data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
`<ThemeScripts/>`), so all three apps share one persistence mechanism (OtOpcUa's bespoke cookie/JS-interop nav
island retired); MxGateway additionally gained a net-new Blazor `<LoginCard>` `/login` page over its existing
hardened endpoint. Per-app result in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.1.0</Version>
<Version>0.2.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
@@ -1,7 +1,7 @@
@* Components/NavRailSection.razor — CSS-only collapsible (no JS, works in static SSR).
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
@namespace ZB.MOM.WW.Theme
<details class="rail-section" open="@Expanded">
<details class="rail-section" open="@Expanded" data-nav-key="@ResolvedKey">
<summary class="rail-eyebrow-toggle">@Title</summary>
<div class="rail-section-body">@ChildContent</div>
</details>
@@ -18,8 +18,24 @@
/// </summary>
[Parameter] public bool Expanded { get; set; } = true;
/// <summary>
/// Stable identifier used to persist this section's open/closed state in
/// localStorage (via the kit's nav-state.js). Defaults to a slug of <see cref="Title"/>.
/// </summary>
[Parameter] public string? Key { get; set; }
/// <summary>
/// Section content — typically <see cref="NavRailItem"/> children.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
private string ResolvedKey => string.IsNullOrWhiteSpace(Key) ? Slug(Title) : Key!;
private static string Slug(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return string.Empty;
var chars = s.Trim().ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray();
return string.Join('-', new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries));
}
}
@@ -0,0 +1,4 @@
@namespace ZB.MOM.WW.Theme
@* Components/ThemeScripts.razor — drop before </body>. Emits the kit's nav-state
enhancer that persists NavRailSection open/closed state in localStorage. *@
<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer></script>
@@ -0,0 +1,26 @@
// ZB.MOM.WW.Theme nav-state.js — persists <details data-nav-key> open/closed
// state in localStorage so NavRailSection expand state survives navigation and
// reloads. Pure client-side; works with static Blazor SSR. Keyed per section.
// localStorage keys are prefixed with "zbnav:" to avoid collisions.
(function () {
var PREFIX = "zbnav:";
var INIT_ATTR = "data-zbnav-initialized";
function apply() {
document.querySelectorAll("details.rail-section[data-nav-key]").forEach(function (el) {
if (el.hasAttribute(INIT_ATTR)) return; // already wired — avoid duplicate listeners
el.setAttribute(INIT_ATTR, "");
var key = PREFIX + el.getAttribute("data-nav-key");
var saved = null;
try { saved = window.localStorage.getItem(key); } catch (e) { return; }
if (saved === "1") el.open = true;
else if (saved === "0") el.open = false;
el.addEventListener("toggle", function () {
try { window.localStorage.setItem(key, el.open ? "1" : "0"); } catch (e) { /* ignore */ }
});
});
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", apply);
else
apply();
})();
@@ -55,4 +55,40 @@ public class NavRailTests : TestContext
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
}
[Fact]
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, "Site Calls")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
}
[Fact]
public void NavRailSection_emits_explicit_key_when_supplied()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
}
[Fact]
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, " ")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
}
[Fact]
public void NavRailSection_slug_preserves_unicode_letters()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, "Café")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
}
}
@@ -35,6 +35,10 @@ public class StaticAssetsTests
public void Fonts_are_vendored(string file) =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
[Fact]
public void NavStateScript_ships() =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
// Theme-002: .chip-idle pairs the idle background with the matching --idle
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact]
@@ -0,0 +1,13 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext
{
[Fact]
public void ThemeScripts_emits_nav_state_script_tag()
{
var cut = RenderComponent<ThemeScripts>();
var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer"));
}
}
+17
View File
@@ -3,6 +3,23 @@
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
> **✅ ADOPTED 2026-06-03 (local-only).** Backlog #2#4 implemented across all three apps on each repo's
> **`feat/adopt-zb-theme`** branch — full canonical cutover (SPEC §7): `<ThemeHead/>`/`<ThemeScripts/>`,
> thin `MainLayout` → `<ThemeShell>` + `NavRailItem`/`NavRailSection`, per-app `theme.css`/IBM-Plex fonts/
> `nav-state.js` deleted, `<LoginCard>` sign-in, and `StatusPill` (OtOpcUa's dead `StatusBadge` deleted;
> MxGateway's `StatusBadge` redirected to a thin `StatusPill` adapter; inline domain `.chip-*` kept as page
> content per §6). **Library first enhanced to `0.2.0`** — nav-expand persistence promoted INTO the kit
> (`NavRailSection.Key` → `data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
> `<ThemeScripts/>`), so all three apps get uniform persistence from one source (OtOpcUa's bespoke
> cookie/JS-interop nav island retired). 0.2.0 published to the Gitea feed; 44 bUnit tests. **MxGateway
> additionally gained a net-new Blazor `<LoginCard>` `/login` page** reusing its existing hardened
> `POST /login` endpoint (antiforgery + `SanitizeReturnUrl` + `SignInAsync` preserved). Every task spec+code
> reviewed (high-risk via serial spec→code; the MxGateway login via an Opus security review). Branch heads:
> OtOpcUa `11de14d`, ScadaBridge `58352a6`, MxGateway `73e54e2` — **committed local-only, not yet merged/pushed**.
> Plan: `docs/plans/2026-06-03-ui-theme-adoption*.md`. The ⛔/🟡 cells below describe the PRE-adoption
> divergence (kept for history). Deferred follow-ups: prune now-dead `.sidebar`/`.nav-link` residual from each
> app's kept `site.css`; a kit-side `layout.css` `calc(100vh - 3.3rem)` review.
---
## Divergence vs spec
@@ -1,9 +1,15 @@
# Shared library: `ZB.MOM.WW.Theme`
**Status: Built (`0.1.0`).** The RCL lives at
[`scadaproj/ZB.MOM.WW.Theme/`](../../../ZB.MOM.WW.Theme/) — built and tested. Adoption
by the three apps is follow-on, tracked in [`../GAPS.md`](../GAPS.md). Realizes
[`../spec/SPEC.md`](../spec/SPEC.md).
**Status: Built + Published + Adopted (`0.2.0`).** The RCL lives at
[`scadaproj/ZB.MOM.WW.Theme/`](../../../ZB.MOM.WW.Theme/) — built, tested (44 bUnit tests), and
**published to the Gitea NuGet feed**. **Adopted across all three apps on 2026-06-03** (local-only
`feat/adopt-zb-theme` branches; see [`../GAPS.md`](../GAPS.md)). Realizes [`../spec/SPEC.md`](../spec/SPEC.md).
`0.2.0` adds **shared nav-expand persistence**: `NavRailSection` gained a `Key` parameter (emitted as
`data-nav-key`, defaulting to a slug of `Title`), a vendored `wwwroot/js/nav-state.js` localStorage enhancer
(keyed by `data-nav-key`, prefix `zbnav:`, idempotent), and a new **`ThemeScripts`** component (sibling to
`ThemeHead`) that emits the enhancer `<script defer>` before `</body>`. This lets every app persist nav
expand-state from one shared, static-SSR-friendly mechanism (no per-app cookie/JS-interop island).
---
@@ -16,12 +22,14 @@ tokens-only or components-only consumers; all three apps consume the full kit.
|---|---|---|
| `ZB.MOM.WW.Theme` | `net10.0` Razor Class Library | Tokens + fonts + layout CSS + all components |
Published to the Gitea NuGet feed; `Version 0.1.0`. SemVer — token changes are
breaking (major bump). Build from `scadaproj/ZB.MOM.WW.Theme/`:
Published to the Gitea NuGet feed; `Version 0.2.0`. SemVer — token changes are
breaking (major bump); the `0.1.0 → 0.2.0` bump added nav persistence (`NavRailSection.Key` +
`ThemeScripts` + `nav-state.js`) additively. Build from `scadaproj/ZB.MOM.WW.Theme/`:
```bash
dotnet build -c Release # 0 warnings (TreatWarningsAsErrors)
dotnet test # 32 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
dotnet test # 44 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.2.0.nupkg
GITEA_NUGET_SOURCE=GITEA_NUGET_KEY=… ./build/push.sh # publish to the Gitea feed
```
---
@@ -72,6 +80,18 @@ Place in `App.razor` `<head>` **after** the app's Bootstrap link.
---
### `ThemeScripts`
Emits the nav-state localStorage enhancer `<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer>`.
No parameters. Place in `App.razor` **before `</body>`**. Persists each `NavRailSection`'s open/closed
state (keyed by its `data-nav-key`) across navigation and reloads; pure client-side, works in static SSR.
```razor
<ThemeScripts />
```
---
### `ThemeShell`
Canonical side-rail chassis. **Not a `LayoutComponentBase`** — delegated to from the app's
@@ -134,14 +154,15 @@ One rail navigation link. Wraps Blazor `<NavLink class="rail-link">`.
### `NavRailSection`
Collapsible nav section group using CSS-only `<details open>` — no JavaScript, works in
static Blazor SSR. Apps that need interactive cookie-persisted expand state may keep a
bespoke interactive `NavSection` alongside this.
Collapsible nav section group using CSS-only `<details open>` — no JavaScript required. Open/closed
state is persisted in localStorage by `<ThemeScripts/>` (keyed by `Key``data-nav-key`); works in
static Blazor SSR.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Title` | `string` | Yes | — | Eyebrow label |
| `Expanded` | `bool` | No | `true` | Initial open state |
| `Key` | `string?` | No | slug of `Title` | Stable persistence key, emitted as `data-nav-key` |
| `Expanded` | `bool` | No | `true` | Initial open state (before localStorage restore) |
| `ChildContent` | `RenderFragment?` | No | `null` | `NavRailItem` children |
---
@@ -0,0 +1,171 @@
# 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`). | 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`+`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).
+643
View File
@@ -0,0 +1,643 @@
# UI-Theme Adoption Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Enhance the shared `ZB.MOM.WW.Theme` RCL with cross-app nav-expand persistence (bump `0.2.0`, publish to the Gitea feed), then adopt it via full canonical cutover across OtOpcUa AdminUI, ScadaBridge CentralUI+Host, and MxAccessGateway Dashboard.
**Architecture:** A library-minor-then-adopt waterfall (same shape as the completed Auth/Audit normalization). Phase 0 enhances + publishes the kit. Phases 13 are **independent per-repo cutovers** (each on its own `feat/adopt-zb-theme` branch, local-only) ordered by risk. Phase 4 updates scadaproj docs + memory. UI-only — no data contracts, no DB migrations; the dominant risk is **visual regression**, mitigated by per-app build+test gates and a manual visual checklist.
**Tech Stack:** .NET 10, Blazor SSR, Razor Class Library, bUnit/xUnit, Bootstrap 5, NuGet central package management (OtOpcUa/ScadaBridge) / per-project versions (MxGateway), Gitea NuGet feed.
**Design:** [`2026-06-03-ui-theme-adoption-design.md`](2026-06-03-ui-theme-adoption-design.md). Decisions D1D8 there are authoritative.
---
## Conventions for the executor
- **Delivery:** scadaproj library + docs changes (Phases 0, 4) commit on the existing `docs/ui-theme-adoption` branch. Each app (Phases 13) gets its own `feat/adopt-zb-theme` branch, **committed local-only, never pushed** until the user explicitly authorizes merge+push (same model as Auth/Audit).
- **Per-repo green gate:** before declaring an app's phase done, run `dotnet build` + that repo's full test suite. **Baseline known pre-existing reds first** and do not chase them: ScadaBridge IntegrationTests ×11 (need live LDAP/SQL/SMTP), `PartitionPurgeTests.EndToEnd`, flaky `StaleTagMonitor` timer tests; MxGateway 3 FakeWorker tests. Only regressions introduced by this work count.
- **Cutover invariant (all apps):** the kit's `theme.css`/`layout.css` define `--*` tokens, the side-rail layout, and the `.chip`/`.chip-ok|warn|bad|idle|info` status classes. Before deleting an app's `wwwroot/css/theme.css`, **diff it against the kit's `theme.css`/`layout.css` and migrate any app-only rules** (e.g. OtOpcUa's `.chip-alert`/`.chip-caution`) into that app's `site.css`. The app's `site.css` page-layout residual and scoped `.razor.css` stay.
- **Status policy (per SPEC §6/§7):** inline `.chip-*` spans and Bootstrap `.badge` in *domain pages* are page content — they keep working under kit CSS and are **not** rewritten. Only a bespoke status *component* gets removed/redirected to `<StatusPill>`.
- **Cross-repo parallelism:** Phases 1, 2, 3 touch disjoint repos and are mutually independent — they MAY run concurrently, but are listed in risk order (OtOpcUa → ScadaBridge → MxGateway). All three are blocked by Task 0.4 (published package).
---
## Phase 0 — Library enhancement + publish (scadaproj, branch `docs/ui-theme-adoption`)
### Task 0.1: NavRailSection persistence key
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 0.2
**Files:**
- Modify: `ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor`
- Test: `ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs`
**Context:** `NavRailSection` renders `<details class="rail-section" open="@Expanded"><summary class="rail-eyebrow-toggle">@Title</summary>…`. Add an optional `Key` parameter (default = a stable slug of `Title`) emitted as `data-nav-key` on the `<details>` so the localStorage enhancer (Task 0.2) can persist per-section open state.
**Step 1 — failing tests** in `NavRailTests.cs`:
```csharp
[Fact]
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, "Site Calls")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
}
[Fact]
public void NavRailSection_emits_explicit_key_when_supplied()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
}
```
**Step 2 — run, expect FAIL** (no `Key`/`data-nav-key`):
`dotnet test ZB.MOM.WW.Theme/ --filter "FullyQualifiedName~NavRailSection_emits"`
**Step 3 — implement.** Edit `NavRailSection.razor`:
```razor
@namespace ZB.MOM.WW.Theme
<details class="rail-section" open="@Expanded" data-nav-key="@ResolvedKey">
<summary class="rail-eyebrow-toggle">@Title</summary>
<div class="rail-section-body">@ChildContent</div>
</details>
@code {
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
[Parameter] public bool Expanded { get; set; } = true;
/// <summary>Stable identifier used to persist this section's open/closed state in
/// localStorage (via the kit's nav-state.js). Defaults to a slug of <see cref="Title"/>.</summary>
[Parameter] public string? Key { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
private string ResolvedKey => string.IsNullOrWhiteSpace(Key) ? Slug(Title) : Key!;
private static string Slug(string s)
{
var chars = s.Trim().ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : '-').ToArray();
return string.Join('-', new string(chars).Split('-', StringSplitOptions.RemoveEmptyEntries));
}
}
```
**Step 4 — run, expect PASS** (plus the existing NavRail tests stay green).
**Step 5 — commit:** `git add -A && git commit -m "feat(theme): NavRailSection data-nav-key for persistence"`
---
### Task 0.2: localStorage nav enhancer + ThemeScripts
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 0.1
**Files:**
- Create: `ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/js/nav-state.js`
- Create: `ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeScripts.razor`
- Test: `ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeScriptsTests.cs` (new)
- Test: `ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs` (extend)
**Step 1 — create `wwwroot/js/nav-state.js`** (progressive enhancement; no framework):
```javascript
// ZB.MOM.WW.Theme nav-state.js — persists <details data-nav-key> open/closed
// state in localStorage so NavRailSection expand state survives navigation and
// reloads. Pure client-side; works with static Blazor SSR. Keyed per section.
(function () {
var PREFIX = "zbnav:";
function apply() {
document.querySelectorAll("details.rail-section[data-nav-key]").forEach(function (el) {
var key = PREFIX + el.getAttribute("data-nav-key");
var saved = null;
try { saved = window.localStorage.getItem(key); } catch (e) { return; }
if (saved === "1") el.open = true;
else if (saved === "0") el.open = false;
el.addEventListener("toggle", function () {
try { window.localStorage.setItem(key, el.open ? "1" : "0"); } catch (e) { /* ignore */ }
});
});
}
if (document.readyState === "loading")
document.addEventListener("DOMContentLoaded", apply);
else
apply();
})();
```
**Step 2 — create `Components/ThemeScripts.razor`:**
```razor
@namespace ZB.MOM.WW.Theme
@* Components/ThemeScripts.razor — drop before </body>. Emits the kit's nav-state
enhancer that persists NavRailSection open/closed state in localStorage. *@
<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer></script>
```
**Step 3 — failing tests.** `ThemeScriptsTests.cs`:
```csharp
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext
{
[Fact]
public void ThemeScripts_emits_nav_state_script_tag()
{
var cut = RenderComponent<ThemeScripts>();
var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer"));
}
}
```
In `StaticAssetsTests.cs`, add an assertion that the JS file ships (mirror its existing CSS/font asset checks — read the file first to match its exact assertion style, e.g. verifying the file exists on disk under `wwwroot/js/nav-state.js`).
**Step 4 — run tests, expect PASS:** `dotnet test ZB.MOM.WW.Theme/`
**Step 5 — commit:** `git commit -am "feat(theme): ThemeScripts + localStorage nav-state enhancer"`
---
### Task 0.3: Version bump 0.2.0 + full suite
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** none (depends on 0.1, 0.2)
**Files:**
- Modify: `ZB.MOM.WW.Theme/Directory.Build.props:7`
**Steps:**
1. Change `<Version>0.1.0</Version>``<Version>0.2.0</Version>`.
2. Run `cd ZB.MOM.WW.Theme && dotnet build -c Release` — expect **0 warnings** (TreatWarningsAsErrors).
3. Run `dotnet test` — expect all green (38 existing + the new persistence/ThemeScripts tests).
4. Commit: `git commit -am "chore(theme): bump 0.1.0 -> 0.2.0 (nav persistence + ThemeScripts)"`
---
### Task 0.4: Publish 0.2.0 to Gitea feed
**Classification:** small
**Estimated implement time:** ~2 min (blocks on user-supplied token)
**Parallelizable with:** none (depends on 0.3)
**⚠ Requires the user's `GITEA_NUGET_KEY`** (Gitea token with `package:write`). It is not persisted — ask the user to export it (or run the push command themselves via `! …`). Do not invent or store it.
**Steps:**
1. Confirm 404 pre-state: `curl -s -o /dev/null -w "%{http_code}\n" https://gitea.dohertylan.com/api/packages/dohertj2/nuget/registration/zb.mom.ww.theme/index.json` (expect `404`).
2. Publish:
```bash
cd ZB.MOM.WW.Theme
export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
export GITEA_NUGET_KEY="<user-supplied>"
./build/push.sh
```
3. Verify published: re-run the curl — expect `200`; confirm version `0.2.0` is listed.
4. No commit needed (artifacts are gitignored). Record the publish in the task log.
---
## Phase 1 — OtOpcUa AdminUI cutover (repo `~/Desktop/OtOpcUa`, branch `feat/adopt-zb-theme`)
> Blocked by Task 0.4. Lowest risk: already side-rail with the kit's exact CSS classes.
> **First:** `cd ~/Desktop/OtOpcUa && git checkout -b feat/adopt-zb-theme`.
### Task 1.1: NuGet wiring + usings
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (gates 1.21.5)
**Files:**
- Modify: `Directory.Packages.props` (repo root) — add `<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />`
- Modify: `NuGet.config` (repo root) — under `<packageSource key="dohertj2-gitea">` add `<package pattern="ZB.MOM.WW.Theme" />`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` — add `<PackageReference Include="ZB.MOM.WW.Theme" />` (versionless; central PM)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/_Imports.razor` — add `@using ZB.MOM.WW.Theme`
**Verify:** `dotnet restore src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` resolves `ZB.MOM.WW.Theme 0.2.0` from the Gitea feed. Commit.
### Task 1.2: App.razor — ThemeHead + ThemeScripts
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 1.3, 1.4, 1.5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor`
**Edits:** Replace line `…/css/theme.css` `<link>` with `<ThemeHead />` (keep the Bootstrap `<link>` *above* it and the `…/css/site.css` `<link>` *below* it). Replace `<script src="…/js/nav-state.js"></script>` with `<ThemeScripts />`. Keep the bootstrap bundle + `blazor.web.js` scripts. Commit.
### Task 1.3: Migrate app-only CSS, delete theme.css + fonts + nav-state.js
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1.2, 1.4, 1.5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css`
- Delete: `wwwroot/css/theme.css`, `wwwroot/fonts/ibm-plex-*.woff2` (×3), `wwwroot/js/nav-state.js`
**Steps:** Diff `wwwroot/css/theme.css` against the kit's `theme.css`+`layout.css`. Any rule present in the app copy but NOT the kit (notably **`.chip-alert`, `.chip-caution`**, and any app-only tweak) → append to `site.css` under a clearly-commented "App-specific status variants (not in ZB.MOM.WW.Theme)" block. Then delete the four asset files. Keep `wwwroot/js/monaco-loader.js`. Commit.
### Task 1.4: MainLayout → ThemeShell + kit nav
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1.2, 1.3, 1.5
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor`
- Delete: `Components/Layout/NavSidebar.razor`, `Components/Layout/NavSection.razor`
**Context:** Replaces the interactive `NavSidebar` island + bespoke `NavSection` with the kit's static `<ThemeShell>` + `NavRailSection`/`NavRailItem` (persistence now comes from `ThemeScripts`). All sections default `Expanded=true`; the URL-based auto-expand behavior is intentionally dropped (D2/D3 — localStorage persistence replaces it). Reproduce the 3 sections / 17 links / footer exactly.
**Target `MainLayout.razor`:**
```razor
@inherits LayoutComponentBase
<ThemeShell Product="OtOpcUa" Accent="#2f5fd0">
<Nav>
<NavRailSection Title="Navigation" Key="nav">
<NavRailItem Href="/" Text="Overview" Match="NavLinkMatch.All" />
<NavRailItem Href="/fleet" Text="Fleet status" />
<NavRailItem Href="/hosts" Text="Host status" />
<NavRailItem Href="/clusters" Text="Clusters" />
<NavRailItem Href="/reservations" Text="Reservations" />
<NavRailItem Href="/certificates" Text="Certificates" />
<NavRailItem Href="/role-grants" Text="Role grants" />
</NavRailSection>
<NavRailSection Title="Scripting" Key="scripting">
<NavRailItem Href="/virtual-tags" Text="Virtual tags" />
<NavRailItem Href="/scripted-alarms" Text="Scripted alarms" />
<NavRailItem Href="/scripts" Text="Scripts" />
<NavRailItem Href="/script-log" Text="Script log" />
</NavRailSection>
<NavRailSection Title="Live" Key="live">
<NavRailItem Href="/deployments" Text="Deployments" />
<NavRailItem Href="/alerts" Text="Alerts" />
<NavRailItem Href="/alarms-historian" Text="Alarms historian" />
</NavRailSection>
</Nav>
<RailFooter>
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-user" href="/account">@context.User.Identity?.Name</a>
<div class="rail-roles">@string.Join(", ", context.User.Claims.Where(c => c.Type.EndsWith("/role")).Select(c => c.Value))</div>
<form method="post" action="/auth/logout"><AntiforgeryToken /><button class="rail-btn" type="submit">Sign out</button></form>
</Authorized>
<NotAuthorized>
<div class="rail-eyebrow">Session</div>
<a class="rail-btn" href="/login">Sign in</a>
</NotAuthorized>
</AuthorizeView>
</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
```
**Note:** confirm `ThemeShell` exposes `Nav`/`RailFooter`/`ChildContent` slots and that the hamburger/collapse behavior comes from the kit's `layout.css` (Bootstrap collapse JS already loaded). If the kit shell wraps the rail in its own collapse, drop the app's old hamburger markup (now in the shell). Build the AdminUI project; verify it compiles. Commit.
### Task 1.5: Delete dead StatusBadge + Login → LoginCard
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 1.2, 1.3, 1.4
**Files:**
- Delete: `Components/Shared/StatusBadge.razor` (verified unused — confirm with a repo grep for `<StatusBadge` returning 0 hits before deleting)
- Modify: `Components/Pages/Login.razor`
**Login target** (preserve static POST to `/auth/login`, the `Error`/`ReturnUrl` query params, and `LoginLayout`):
```razor
@page "/login"
@layout LoginLayout
@attribute [Microsoft.AspNetCore.Authorization.AllowAnonymous]
<div class="login-wrap rise" style="animation-delay:.02s">
<LoginCard Product="OtOpcUa Admin" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
<AntiforgeryToken />
</LoginCard>
</div>
@code {
[SupplyParameterFromQuery] private string? Error { get; set; }
[SupplyParameterFromQuery] private string? ReturnUrl { get; set; }
}
```
**Note:** the `/auth/login` endpoint already round-trips `returnUrl` and signs in (unchanged). Confirm `<LoginCard>` renders the username/password fields the endpoint reads (`name="username"`, `name="password"`, `name="returnUrl"`); if its field names differ, set them via LoginCard params or keep the existing `<form>` inside a `<TechCard>` instead. Build; commit.
### Task 1.6: Build, test, visual checklist (OtOpcUa)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on 1.21.5)
**Steps:** `dotnet build ZB.MOM.WW.OtOpcUa.slnx`; `dotnet test ZB.MOM.WW.OtOpcUa.slnx` (compare against baseline reds). Run the visual checklist (design §5): rail renders ≥lg + hamburger <lg; nav persistence across reload; status chips intact (incl. alert/caution); login posts + returnUrl; IBM Plex fonts load from `_content/ZB.MOM.WW.Theme/fonts/` (the old latent 404 is gone). Report results; do not merge.
---
## Phase 2 — ScadaBridge CentralUI + Host cutover (repo `~/Desktop/ScadaBridge`, branch `feat/adopt-zb-theme`)
> Blocked by Task 0.4. Independent of Phase 1. **First:** `cd ~/Desktop/ScadaBridge && git checkout -b feat/adopt-zb-theme`.
### Task 2.1: NuGet wiring + usings
**Classification:** small
**Estimated implement time:** ~3 min
**Files:**
- Modify: `Directory.Packages.props` — add `<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.2.0" />`
- Modify: `nuget.config` — under `dohertj2-gitea` add `<package pattern="ZB.MOM.WW.Theme" />`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/ZB.MOM.WW.ScadaBridge.CentralUI.csproj` — add `<PackageReference Include="ZB.MOM.WW.Theme" />`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/_Imports.razor` — add `@using ZB.MOM.WW.Theme`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/_Imports.razor` — add `@using ZB.MOM.WW.Theme` (Host's `App.razor` uses `ThemeHead`/`ThemeScripts`; the RCL flows transitively via the CentralUI project reference)
**Verify** restore resolves 0.2.0; commit.
### Task 2.2: Host App.razor — ThemeHead + ThemeScripts
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 2.3, 2.4, 2.5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor`
**Edits:** Replace the `_content/ZB.MOM.WW.ScadaBridge.CentralUI/css/theme.css` `<link>` with `<ThemeHead />` (keep Bootstrap + bootstrap-icons links above; keep `/ZB.MOM.WW.ScadaBridge.Host.styles.css` and the CentralUI `site.css` link). Replace the `…CentralUI/js/nav-state.js` `<script>` with `<ThemeScripts />`; keep `treeview-storage.js`, `monaco-init.js`, `audit-grid.js`, `transport.js`, and the bootstrap bundle. Commit.
### Task 2.3: Migrate app-only CSS, delete theme.css + fonts + nav-state.js
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2.2, 2.4, 2.5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css` (only if the diff surfaces app-only rules)
- Delete: `CentralUI/wwwroot/css/theme.css`, `CentralUI/wwwroot/fonts/ibm-plex-*.woff2` (×3), `CentralUI/wwwroot/js/nav-state.js`
**Steps:** Diff CentralUI `theme.css` vs kit; migrate any app-only rules into `site.css` (ScadaBridge's chips are the standard ok/warn/bad/idle, covered by the kit — expect little/none). Keep the other JS files. Commit.
### Task 2.4: MainLayout → ThemeShell + kit nav (preserve AuthorizeView gating, DialogHost, SessionExpiry)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2.2, 2.3, 2.5
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor`
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor`
- Delete: `Components/Layout/NavSection.razor` (after NavMenu no longer uses it)
**Context:** Two non-obvious must-preserves: `MainLayout` hosts `<DialogHost />` and `<SessionExpiry />` — keep both in the thin layout (outside `<ThemeShell>` or in `ChildContent` alongside `@Body`). `NavMenu` wraps its sections in `<AuthorizeView Policy="…">` (RequireAdmin/RequireDesign/RequireDeployment/OperationalAudit + mixed-role children) — these policy guards must wrap the new `NavRailSection`s unchanged.
**Steps:**
1. `MainLayout.razor` → thin delegation:
```razor
@inherits LayoutComponentBase
<ThemeShell Product="ScadaBridge" Accent="#2f5fd0">
<Nav><NavMenu /></Nav>
<RailFooter>
<AuthorizeView><Authorized>
<div class="rail-eyebrow">Session</div>
<span class="rail-user">@context.User.GetDisplayName()</span>
<form method="post" action="/auth/logout" data-enhance="false"><AntiforgeryToken /><button class="rail-btn" type="submit">Sign Out</button></form>
</Authorized></AuthorizeView>
</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
<DialogHost />
<SessionExpiry />
```
(Move the session/sign-out block out of `NavMenu` into `RailFooter`; keep `GetDisplayName()`.)
2. Rewrite `NavMenu.razor` body: replace `<nav class="sidebar"><ul class="nav flex-column">…` + `<li><NavLink class="nav-link">` + `<NavSection>` with kit `NavRailSection`/`NavRailItem`, **preserving each `<AuthorizeView Policy="…">` wrapper** around its section. The always-visible Dashboard link becomes a bare `<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />` (outside any section, or in a default section). Reproduce all sections/links from the inventory (Admin, Design, Deployment, Notifications, Site Calls, Monitoring, Audit) and their child links exactly.
3. Build CentralUI; **verify `<AuthorizeView>`-wrapped `NavRailSection` renders for an authorized principal and hides for an unauthorized one** under static SSR (GAPS open question) — assert via an existing CentralUI bUnit test or add a focused one. Commit.
### Task 2.5: Login → LoginCard
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2.2, 2.3, 2.4
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Login.razor`
**Target** (preserve static POST to `/auth/login`; the endpoint uses `.DisableAntiforgery()` and always redirects `/` — no `returnUrl`, no antiforgery token needed):
```razor
@page "/login"
@layout LoginLayout
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
<LoginCard Product="ScadaBridge" Action="/auth/login" Error="@ErrorMessage" />
@code {
[SupplyParameterFromQuery(Name = "error")] public string? ErrorMessage { get; set; }
}
```
Confirm `<LoginCard>`'s field names match what `/auth/login` reads (`username`/`password`). Build; commit.
### Task 2.6: Build, test, visual checklist (ScadaBridge)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on 2.22.5)
**Steps:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx`; run the FULL suite (Host/CentralUI/ManagementService/Transport/ConfigurationDatabase) and compare to baseline reds. Visual checklist incl. policy-gated nav sections show/hide by role, DialogHost + SessionExpiry still function. Report; do not merge.
---
## Phase 3 — MxAccessGateway Dashboard cutover (repo `~/Desktop/MxAccessGateway`, branch `feat/adopt-zb-theme`)
> Blocked by Task 0.4. Independent of Phases 12. Highest risk: combined-layout split + login conversion. **First:** `cd ~/Desktop/MxAccessGateway && git checkout -b feat/adopt-zb-theme`.
### Task 3.1: NuGet wiring + usings (no central PM)
**Classification:** small
**Estimated implement time:** ~3 min
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj` — add `<PackageReference Include="ZB.MOM.WW.Theme" Version="0.2.0" />` (explicit version; this repo has no `Directory.Packages.props`)
- Modify: `NuGet.config` — under `dohertj2-gitea` add `<package pattern="ZB.MOM.WW.Theme" />`
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/_Imports.razor` — add `@using ZB.MOM.WW.Theme`
**Verify** restore resolves 0.2.0; commit.
### Task 3.2: App.razor — ThemeHead + ThemeScripts
**Classification:** small
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 3.3, 3.5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/App.razor`
**Edits:** Replace `<link rel="stylesheet" href="/css/theme.css" />` with `<ThemeHead />` (keep Bootstrap link above + `/css/site.css` below). Replace `<script src="/js/nav-state.js"></script>` with `<ThemeScripts />`. Keep bootstrap bundle + `blazor.web.js`. Commit. *(Note: `<HeadOutlet>`/`<Routes>` keep their `@rendermode="InteractiveServer"`; ThemeHead/ThemeScripts are static markup and unaffected.)*
### Task 3.3: Migrate app-only CSS, delete theme.css + fonts
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3.2, 3.5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css` (only if diff surfaces app-only rules)
- Delete: `wwwroot/css/theme.css`, `wwwroot/fonts/ibm-plex-*.woff2` (×3)
**Steps:** Diff `theme.css` vs kit; migrate app-only rules to `site.css`. The kit's `@font-face` uses the correct relative path (the app's absolute `/fonts/` path is retired). **Keep** `wwwroot/js/nav-state.js`? No — it is replaced by `ThemeScripts` (Task 3.2 removed its `<script>`); delete `wwwroot/js/nav-state.js` here too. Commit.
### Task 3.4: Split combined MainLayout → thin MainLayout + ThemeShell
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3.5
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor`
- Delete: `Dashboard/Components/Layout/NavSection.razor`
**Context:** The ~211-line combined layout (hamburger + `<nav class="sidebar">` + brand + 3 `NavSection`s + AuthorizeView footer + `<main>`) collapses to a thin `<ThemeShell>` delegation. Reproduce the Runtime / Galaxy / Admin sections + links and the footer (Authorized: user + Sign Out POST `/logout`; NotAuthorized: Sign In `/login`).
```razor
@inherits LayoutComponentBase
<ThemeShell Product="MXAccess Gateway" Accent="#2f5fd0">
<Nav>
<NavRailItem Href="/" Text="Dashboard" Match="NavLinkMatch.All" />
<NavRailSection Title="Runtime" Key="runtime"> … sessions/workers/events/alarms … </NavRailSection>
<NavRailSection Title="Galaxy" Key="galaxy"> … repository/browse … </NavRailSection>
<NavRailSection Title="Admin" Key="admin"> … API Keys/settings … </NavRailSection>
</Nav>
<RailFooter>
<AuthorizeView>
<Authorized>
<div class="rail-eyebrow">Session</div>
<span class="rail-user">@context.User.Identity?.Name</span>
<form method="post" action="/logout" data-enhance="false"><AntiforgeryToken /><button class="rail-btn" type="submit">Sign Out</button></form>
</Authorized>
<NotAuthorized><a class="rail-btn" href="/login">Sign In</a></NotAuthorized>
</AuthorizeView>
</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
```
Fill the section children from the inventory's exact hrefs/labels. Build the Server project; commit.
### Task 3.5: StatusBadge → StatusPill adapter
**Classification:** standard
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 3.2, 3.3, 3.4
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor`
**Decision (documented deviation from SPEC §7.5):** `StatusBadge` is used at 12 call sites with a domain `Text`→class switch. Rather than scatter the text→state mapping across 12 pages, **redirect `StatusBadge` to render `<StatusPill>`** — the bespoke `.chip` rendering moves to the kit; only the app's domain text→state mapping (per-project vocabulary, SPEC §6) remains. Call sites stay unchanged.
```razor
@* Thin adapter: maps MxGateway runtime state text → kit StatusPill state. *@
<StatusPill State="MapState(Text)">@Text</StatusPill>
@code {
[Parameter] public string? Text { get; set; }
private static StatusState MapState(string? t) => t switch
{
"Ready" or "Healthy" or "Active" => StatusState.Ok,
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" or "Stale" or "Degraded" => StatusState.Warn,
"Faulted" or "Unavailable" => StatusState.Bad,
_ => StatusState.Idle,
};
}
```
Confirm `StatusPill` renders its `ChildContent` as the label and emits `chip chip-ok|warn|bad|idle`. Build; commit. *(If the reviewer insists on literal deletion, the fallback is replacing all 12 call sites with `<StatusPill>` + a shared static `MapState` helper — note it but prefer the adapter.)*
### Task 3.6: Net-new Blazor LoginCard page (reuse existing hardened endpoint)
**Classification:** high-risk
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on 3.4 for layout/usings context)
**Files:**
- Create: `Dashboard/Components/Layout/LoginLayout.razor`
- Create: `Dashboard/Components/Pages/Login.razor`
- Modify: `Dashboard/DashboardEndpointRouteBuilderExtensions.cs`
**Context (discovered reality):** MxGateway is NOT login-less — it has a working, hardened login: `POST /login` (`PostLoginAsync`) validates antiforgery, calls `IDashboardAuthenticator.AuthenticateAsync` (LDAP via shared Auth → roles → `ZbClaimTypes` principal), `SignInAsync`, then `LocalRedirect(SanitizeReturnUrl(returnUrl))`. The login *UI* is a raw HTML string from `GetLoginAsync`/`RenderLoginPage`. We swap **only the UI** to a Blazor `<LoginCard>` page; the `POST /login` endpoint and authenticator are reused unchanged. A `<form method="post">` posts natively, so the page's render mode is irrelevant to the POST.
**Steps:**
1. `LoginLayout.razor`: `@inherits LayoutComponentBase` + `@Body` (no rail).
2. `Login.razor`:
```razor
@page "/login"
@layout LoginLayout
@using Microsoft.AspNetCore.Authorization
@attribute [AllowAnonymous]
<div class="dashboard-login">
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
<AntiforgeryToken />
</LoginCard>
</div>
@code {
[SupplyParameterFromQuery(Name = "returnUrl")] private string? ReturnUrl { get; set; }
[SupplyParameterFromQuery(Name = "error")] private string? Error { get; set; }
}
```
3. In `DashboardEndpointRouteBuilderExtensions.cs`:
- **Remove** the `MapGet("/login", … GetLoginAsync)` registration and the `GetLoginAsync` + `RenderLoginPage` helpers (the Blazor route now serves `GET /login`; the component carries `[AllowAnonymous]` to override the `RequireAuthorization(ViewerPolicy)` on `MapRazorComponents<App>()`).
- **Change** `PostLoginAsync`'s failure branch from re-rendering HTML to a redirect: `return Results.Redirect($"/login?error={Uri.EscapeDataString(result.FailureMessage ?? "…")}&returnUrl={Uri.EscapeDataString(returnUrl)}");`. Keep antiforgery validation, `SignInAsync`, and the success `LocalRedirect(returnUrl)`.
- Keep `MapPost("/login")`, `/logout` (GET+POST), and `/denied` (still uses `RenderPage`).
4. Build the Server project. **Verify the full flow:** unauthenticated request to `/` → cookie challenge → `/login` renders the Blazor `<LoginCard>` anonymously → POST authenticates → cookie set → redirect. Commit.
### Task 3.7: Build, test, login smoke (MxGateway)
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (depends on 3.23.6)
**Steps:** `dotnet build src/MxGateway.sln` (+ worker x86 if the suite needs it); `dotnet test src/MxGateway.Tests/MxGateway.Tests.csproj` (compare to the 3 baseline FakeWorker reds). Visual + auth smoke: layout renders, nav persists, status pills, **login page renders and a valid/invalid credential round-trips correctly** (the highest-risk surface). Report; do not merge.
---
## Phase 4 — scadaproj docs + memory (branch `docs/ui-theme-adoption`)
### Task 4.1: Update component docs
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (depends on 1.6, 2.6, 3.7)
**Files:**
- Modify: `components/ui-theme/GAPS.md` — add an "✅ ADOPTED 2026-06-03 (local-only)" banner mirroring the Auth/Audit GAPS banners; note persistence promoted to the kit + MxGateway's new LoginCard page.
- Modify: `components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md` — status → "Built + Published `0.2.0`"; document `ThemeScripts` + `NavRailSection.Key` + the nav-state.js asset.
- Modify: `CLAUDE.md` — UI-Theme component row status → "Adopted (lib `0.2.0`; all 3 apps, local feature branches)"; bump the version/test-count prose (38→ new total).
Commit.
### Task 4.2: Update memory
**Classification:** trivial
**Estimated implement time:** ~2 min
**Parallelizable with:** Task 4.1
**Files:**
- Create: `/Users/dohertj2/.claude/projects/-Users-dohertj2-Desktop-scadaproj/memory/ui-theme-adoption.md` (project memory: scope, 0.2.0, per-app branches local-only, MxGateway login conversion, persistence-in-kit decision; link `[[component-status-claims-are-optimistic]]`, `[[shared-libs-are-plain-files-not-nested-repos]]`).
- Modify: `…/memory/MEMORY.md` — add the index line.
Commit.
### Task 4.3: Final integration review
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none
Dispatch a final reviewer across all three `feat/adopt-zb-theme` diffs + the scadaproj Phase 0/4 diff: confirm SPEC §7 acceptance per app, no app-only CSS lost, no regressions vs baseline, and the cross-app consistency of the shell/nav/login. Produce a go/no-go for the merge+push decision (which remains the user's call).
---
## Dependency summary
- `0.1, 0.2` → `0.3` → `0.4`.
- `0.4` blocks `1.1`, `2.1`, `3.1`.
- Within Phase 1: `1.1` → {`1.2`, `1.3`, `1.4`, `1.5`} (parallel) → `1.6`.
- Within Phase 2: `2.1` → {`2.2`, `2.3`, `2.4`, `2.5`} (parallel) → `2.6`.
- Within Phase 3: `3.1` → {`3.2`, `3.3`, `3.5`} (parallel) and `3.1`→`3.4`→`3.6`; all → `3.7`.
- `{1.6, 2.6, 3.7}` → `4.1`, `4.2` → `4.3`.
- Phases 1/2/3 are independent repos (may run concurrently; listed in risk order).
@@ -0,0 +1,32 @@
{
"planPath": "docs/plans/2026-06-03-ui-theme-adoption.md",
"tasks": [
{"id": 44, "subject": "Task 0.1: NavRailSection persistence key", "status": "pending"},
{"id": 45, "subject": "Task 0.2: nav-state.js enhancer + ThemeScripts", "status": "pending"},
{"id": 46, "subject": "Task 0.3: Bump 0.2.0 + full suite", "status": "pending", "blockedBy": [44, 45]},
{"id": 47, "subject": "Task 0.4: Publish 0.2.0 to Gitea feed", "status": "pending", "blockedBy": [46]},
{"id": 48, "subject": "Task 1.1: OtOpcUa NuGet wiring + usings", "status": "pending", "blockedBy": [47]},
{"id": 49, "subject": "Task 1.2: OtOpcUa App.razor ThemeHead/ThemeScripts", "status": "pending", "blockedBy": [48]},
{"id": 50, "subject": "Task 1.3: OtOpcUa migrate CSS, delete theme.css/fonts/nav-state.js", "status": "pending", "blockedBy": [48]},
{"id": 51, "subject": "Task 1.4: OtOpcUa MainLayout to ThemeShell + kit nav", "status": "pending", "blockedBy": [48]},
{"id": 52, "subject": "Task 1.5: OtOpcUa delete dead StatusBadge + Login to LoginCard", "status": "pending", "blockedBy": [48]},
{"id": 53, "subject": "Task 1.6: OtOpcUa build/test/visual checklist", "status": "pending", "blockedBy": [49, 50, 51, 52]},
{"id": 54, "subject": "Task 2.1: ScadaBridge NuGet wiring + usings", "status": "pending", "blockedBy": [47]},
{"id": 55, "subject": "Task 2.2: ScadaBridge Host App.razor ThemeHead/ThemeScripts", "status": "pending", "blockedBy": [54]},
{"id": 56, "subject": "Task 2.3: ScadaBridge migrate CSS, delete theme.css/fonts/nav-state.js", "status": "pending", "blockedBy": [54]},
{"id": 57, "subject": "Task 2.4: ScadaBridge MainLayout/NavMenu to ThemeShell (preserve AuthorizeView/DialogHost/SessionExpiry)", "status": "pending", "blockedBy": [54]},
{"id": 58, "subject": "Task 2.5: ScadaBridge Login to LoginCard", "status": "pending", "blockedBy": [54]},
{"id": 59, "subject": "Task 2.6: ScadaBridge build/test/visual checklist", "status": "pending", "blockedBy": [55, 56, 57, 58]},
{"id": 60, "subject": "Task 3.1: MxGateway NuGet wiring + usings (no central PM)", "status": "pending", "blockedBy": [47]},
{"id": 61, "subject": "Task 3.2: MxGateway App.razor ThemeHead/ThemeScripts", "status": "pending", "blockedBy": [60]},
{"id": 62, "subject": "Task 3.3: MxGateway migrate CSS, delete theme.css/fonts/nav-state.js", "status": "pending", "blockedBy": [60]},
{"id": 63, "subject": "Task 3.4: MxGateway split combined MainLayout to ThemeShell", "status": "pending", "blockedBy": [60]},
{"id": 64, "subject": "Task 3.5: MxGateway StatusBadge to StatusPill adapter", "status": "pending", "blockedBy": [60]},
{"id": 65, "subject": "Task 3.6: MxGateway net-new Blazor LoginCard page", "status": "pending", "blockedBy": [63]},
{"id": 66, "subject": "Task 3.7: MxGateway build/test/login smoke", "status": "pending", "blockedBy": [61, 62, 63, 64, 65]},
{"id": 67, "subject": "Task 4.1: Update component docs", "status": "pending", "blockedBy": [53, 59, 66]},
{"id": 68, "subject": "Task 4.2: Update memory", "status": "pending", "blockedBy": [53, 59, 66]},
{"id": 69, "subject": "Task 4.3: Final integration review", "status": "pending", "blockedBy": [67, 68]}
],
"lastUpdated": "2026-06-03"
}