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

644 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).