# 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 1–3 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 D1–D8 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 1–3) 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.2–1.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.2–1.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.2–2.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 1–2. 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.2–3.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).
