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

34 KiB
Raw Blame History

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. 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:

[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:

@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):

// 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:

@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:

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:
    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:

@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):

@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 NavRailSections unchanged.

Steps:

  1. MainLayout.razor → thin delegation:
    @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):

@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 NavSections + 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).

@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.

@* 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:
    @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.20.30.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.13.43.6; all → 3.7.
  • {1.6, 2.6, 3.7}4.1, 4.24.3.
  • Phases 1/2/3 are independent repos (may run concurrently; listed in risk order).