Files
scadaproj/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md
T
2026-06-01 04:39:06 -04:00

37 KiB
Raw Blame History

ZB.MOM.WW.Theme Shared Library Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.

Goal: Build ZB.MOM.WW.Theme — a single .NET 10 Razor Class Library that ships the "Technical-Light" design tokens + IBM Plex fonts as static web assets, plus a canonical side-rail UI kit (shell, nav, status pill, login card, common controls) — and create the components/ui-theme/ normalization folder that frames it.

Architecture: One RCL (Microsoft.NET.Sdk.Razor, net10.0) under scadaproj/ZB.MOM.WW.Theme/, mirroring ZB.MOM.WW.Auth/ conventions (central package management, .slnx, Directory.Build.props Version 0.1.0, build/pack.sh + push.sh). Static assets (theme.css, layout.css, three .woff2) are served at _content/ZB.MOM.WW.Theme/...; components carry no inline colors and reuse the token classes. Tests via bUnit. App adoption (referencing the package, deleting copies, the MxGateway top-bar → rail migration) is explicit follow-on, tracked in GAPS.md.

Tech Stack: .NET 10 · Blazor SSR · Razor Class Library · Bootstrap 5 (app-side, not vendored by the kit) · bUnit · xUnit · central package management.

Source design: 2026-06-01-ui-theme-component-design.md


Reference sources (read these once; do not re-discover)

The canonical CSS + reference markup already exist in OtOpcUa's AdminUI. The kit is largely a relocation of these into a versioned package:

  • Tokens + helpers (THE shared file, 379 lines, copy verbatim): ~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/theme.css
  • Side-rail layout CSS (extract the rail/login bits): ~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css
  • IBM Plex fonts (copy verbatim): ~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/fonts/ibm-plex-{sans-400,sans-600,mono-500}.woff2
  • Reference shell + components: Components/Layout/{MainLayout,NavSidebar,NavSection}.razor, Components/Shared/StatusBadge.razor, Components/Pages/Login.razor (same dir tree).
  • Conventions to mirror: ~/Desktop/scadaproj/ZB.MOM.WW.Auth/{Directory.Build.props, Directory.Packages.props,ZB.MOM.WW.Auth.slnx,build/pack.sh,build/push.sh,.gitignore}.

Two corrections baked into this plan (do NOT revert to the design-doc sketch):

  1. The shared shell is a regular component ThemeShell (with ChildContent), not a LayoutComponentBase. Blazor's @layout directive cannot pass parameters, so per-app Product/Accent/Nav must flow through a component the app's thin MainLayout delegates to.
  2. The canonical theme.css font @font-face URLs use url('../fonts/…') (correct from css/theme.css), fixing OtOpcUa's latent url('fonts/…') 404 (it silently falls back to system fonts today).

Task 1: Scaffold the RCL (solution, props, projects, build scripts)

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (everything depends on this)

Files:

  • Create: ZB.MOM.WW.Theme/Directory.Build.props
  • Create: ZB.MOM.WW.Theme/Directory.Packages.props
  • Create: ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.slnx
  • Create: ZB.MOM.WW.Theme/.gitignore
  • Create: ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj
  • Create: ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/_Imports.razor
  • Create: ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj
  • Create: ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/_Imports.cs
  • Create: ZB.MOM.WW.Theme/build/pack.sh, ZB.MOM.WW.Theme/build/push.sh

Step 1: Directory.Build.props (copy from Auth verbatim):

<Project>
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>latest</LangVersion>
    <Version>0.1.0</Version>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
</Project>

Step 2: Directory.Packages.props (test stack + bUnit only — the RCL itself uses just the framework reference):

<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="bunit" Version="1.40.0" />
    <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
    <PackageVersion Include="xunit" Version="2.9.3" />
    <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
    <PackageVersion Include="coverlet.collector" Version="6.0.4" />
  </ItemGroup>
</Project>

(If bunit 1.40.0 fails to restore on the net10 SDK, bump to the latest 1.x and note it.)

Step 3: src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj — RCL, packable:

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <RootNamespace>ZB.MOM.WW.Theme</RootNamespace>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <IsPackable>true</IsPackable>
    <PackageId>ZB.MOM.WW.Theme</PackageId>
    <Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
  </PropertyGroup>
  <ItemGroup>
    <FrameworkReference Include="Microsoft.AspNetCore.App" />
  </ItemGroup>
  <ItemGroup>
    <InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
  </ItemGroup>
</Project>

Step 4: src/ZB.MOM.WW.Theme/_Imports.razor:

@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.Theme

Step 5: tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="bunit" />
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" />
    <PackageReference Include="coverlet.collector" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\ZB.MOM.WW.Theme\ZB.MOM.WW.Theme.csproj" />
  </ItemGroup>
</Project>

Step 6: tests/.../_Imports.cs — global usings so test files stay terse:

global using Bunit;
global using Xunit;
global using ZB.MOM.WW.Theme;

Step 7: ZB.MOM.WW.Theme.slnx:

<Solution>
  <Folder Name="/src/">
    <Project Path="src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj" />
  </Folder>
  <Folder Name="/tests/">
    <Project Path="tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj" />
  </Folder>
</Solution>

Step 8: .gitignore (copy Auth's), and build/pack.sh + build/push.sh (copy Auth's verbatim — they are project-agnostic; chmod +x both).

Step 9: Verify scaffold builds (no components yet):

cd ~/Desktop/scadaproj/ZB.MOM.WW.Theme && dotnet build

Expected: build succeeds, 0 warnings.

Step 10: Commit.

git add ZB.MOM.WW.Theme
git commit -m "feat(theme): scaffold ZB.MOM.WW.Theme RCL + test project"

Task 2: Static assets — tokens, fonts, layout CSS

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3, 4, 5, 7, 8, 9 (disjoint files; components reference these classes only at runtime)

Files:

  • Create: ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css
  • Create: ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css
  • Create: ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2
  • Create: .../wwwroot/fonts/ibm-plex-sans-600.woff2, .../ibm-plex-mono-500.woff2
  • Test: ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs

Step 1: theme.css — copy the 379-line file from the OtOpcUa reference path verbatim, then make ONE edit: change the three @font-face src: url('fonts/ibm-plex-*.woff2') to src: url('../fonts/ibm-plex-*.woff2') (correct relative path from css/fonts/).

cp ~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/theme.css \
   src/ZB.MOM.WW.Theme/wwwroot/css/theme.css
# then edit the 3 url('fonts/...') → url('../fonts/...')
cp ~/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/fonts/*.woff2 \
   src/ZB.MOM.WW.Theme/wwwroot/fonts/

Step 2: layout.css — port the side-rail + login layout from the OtOpcUa site.css reference (the .app-shell, .side-rail, .rail-eyebrow*, .rail-section-body, .rail-link, .rail-foot, .rail-user, .rail-roles, .rail-btn, .login-wrap, .login-title rules — copy them). Then ADD these kit-specific rules:

/* details-based collapsible nav section (no JS / no rendermode coupling) */
.rail-section { }
.rail-section > summary { list-style: none; cursor: pointer; }
.rail-section > summary::-webkit-details-marker { display: none; }
.rail-section > summary::before { content: '\25B6'; font-size: 0.55rem; color: var(--ink-faint); margin-right: 0.4rem; }
.rail-section[open] > summary::before { content: '\25BC'; }

/* StatusPill: info variant (on-palette, reuses dir-read colours) */
.chip-info { color: var(--accent-deep); background: #e7ecfb; border-color: #cdd9f7; }

/* TechCard body/footer padding; TechField error; LoginCard body */
.panel-body { padding: 0.85rem 0.9rem; }
.panel-foot { padding: 0.6rem 0.9rem; border-top: 1px solid var(--rule); }
.login-body { padding: 1.4rem 1.1rem 1.25rem; }
.login-error { margin-bottom: 0.85rem; }
.field-error { font-size: 0.78rem; margin-top: 0.2rem; }

Step 3: StaticAssetsTests.cs — guard the anti-drift guarantee: the files exist in ONE place with the expected content and the corrected font path.

using System.IO;

namespace ZB.MOM.WW.Theme.Tests;

public class StaticAssetsTests
{
    // wwwroot is copied next to the test assembly via the RCL static-web-asset pipeline,
    // but the simplest stable check is against the source tree relative to the test binary.
    private static string Wwwroot =>
        Path.GetFullPath(Path.Combine(AppContext.BaseDirectory,
            "..", "..", "..", "..", "..", "src", "ZB.MOM.WW.Theme", "wwwroot"));

    [Fact]
    public void ThemeCss_exists_and_defines_accent_token()
    {
        var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css"));
        Assert.Contains("--accent:", css);
        Assert.Contains("--ok:", css);
    }

    [Fact]
    public void ThemeCss_uses_corrected_relative_font_path()
    {
        var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css"));
        Assert.Contains("url('../fonts/ibm-plex-sans-400.woff2')", css);
        Assert.DoesNotContain("url('fonts/ibm-plex", css); // the latent 404 path is gone
    }

    [Theory]
    [InlineData("ibm-plex-sans-400.woff2")]
    [InlineData("ibm-plex-sans-600.woff2")]
    [InlineData("ibm-plex-mono-500.woff2")]
    public void Fonts_are_vendored(string file) =>
        Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
}

Step 4: Run: dotnet test --filter "FullyQualifiedName~StaticAssetsTests" → PASS.

Step 5: Commit: feat(theme): vendor tokens, fonts, and side-rail layout CSS.


Task 3: StatusState enum + StatusPill

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2, 4, 5, 7, 8, 9

Files:

  • Create: src/ZB.MOM.WW.Theme/StatusState.cs
  • Create: src/ZB.MOM.WW.Theme/Components/StatusPill.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs

Step 1: failing test:

namespace ZB.MOM.WW.Theme.Tests;

public class StatusPillTests : TestContext
{
    [Theory]
    [InlineData(StatusState.Ok, "chip-ok")]
    [InlineData(StatusState.Warn, "chip-warn")]
    [InlineData(StatusState.Bad, "chip-bad")]
    [InlineData(StatusState.Idle, "chip-idle")]
    [InlineData(StatusState.Info, "chip-info")]
    public void Maps_state_to_chip_class(StatusState state, string expected)
    {
        var cut = RenderComponent<StatusPill>(p => p
            .Add(x => x.State, state)
            .AddChildContent("Connected"));
        var span = cut.Find("span");
        Assert.Contains("chip", span.ClassList);
        Assert.Contains(expected, span.ClassList);
        Assert.Equal("Connected", span.TextContent.Trim());
    }
}

Step 2: Run → FAIL (StatusState / StatusPill not defined).

Step 3: implement:

// StatusState.cs
namespace ZB.MOM.WW.Theme;

public enum StatusState { Ok, Warn, Bad, Idle, Info }
@* Components/StatusPill.razor *@
<span class="chip @ChipClass">@ChildContent</span>

@code {
    [Parameter, EditorRequired] public StatusState State { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }

    private string ChipClass => State switch
    {
        StatusState.Ok   => "chip-ok",
        StatusState.Warn => "chip-warn",
        StatusState.Bad  => "chip-bad",
        StatusState.Info => "chip-info",
        _                => "chip-idle",
    };
}

Step 4: Run → PASS. Step 5: Commit feat(theme): StatusPill widget.


Task 4: BrandBar

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 2, 3, 5, 7, 8, 9

Files:

  • Create: src/ZB.MOM.WW.Theme/Components/BrandBar.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs

Step 1: failing test:

namespace ZB.MOM.WW.Theme.Tests;

public class BrandBarTests : TestContext
{
    [Fact]
    public void Renders_product_with_default_mark()
    {
        var cut = RenderComponent<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
        Assert.Contains("OtOpcUa", cut.Markup);
        Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
    }

    [Fact]
    public void Custom_logo_replaces_default_mark()
    {
        var cut = RenderComponent<BrandBar>(p => p
            .Add(x => x.Product, "ScadaBridge")
            .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
        Assert.NotNull(cut.Find(".brand .logo"));
        Assert.Empty(cut.FindAll(".brand .mark"));
    }
}

Step 2: Run → FAIL. Step 3: implement:

@* Components/BrandBar.razor *@
<div class="brand">
    @if (Logo is not null) { @Logo }
    else { <span class="mark">&#9646;</span> }
    @Product
</div>

@code {
    [Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
    [Parameter] public RenderFragment? Logo { get; set; }
}

Step 4: Run → PASS. Step 5: Commit feat(theme): BrandBar.


Task 5: NavRailItem + NavRailSection

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 2, 3, 4, 7, 8, 9

Files:

  • Create: src/ZB.MOM.WW.Theme/Components/NavRailItem.razor
  • Create: src/ZB.MOM.WW.Theme/Components/NavRailSection.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs

Step 1: failing test (bUnit's TestContext provides a fake NavigationManager, so <NavLink> renders):

namespace ZB.MOM.WW.Theme.Tests;

public class NavRailTests : TestContext
{
    [Fact]
    public void NavRailItem_renders_rail_link_with_href_and_text()
    {
        var cut = RenderComponent<NavRailItem>(p => p
            .Add(x => x.Href, "/clusters")
            .Add(x => x.Text, "Clusters"));
        var a = cut.Find("a.rail-link");
        Assert.Equal("/clusters", a.GetAttribute("href"));
        Assert.Contains("Clusters", a.TextContent);
    }

    [Fact]
    public void NavRailSection_renders_title_and_children_open_by_default()
    {
        var cut = RenderComponent<NavRailSection>(p => p
            .Add(x => x.Title, "Navigation")
            .AddChildContent("<a class='rail-link'>X</a>"));
        var details = cut.Find("details.rail-section");
        Assert.True(details.HasAttribute("open"));
        Assert.Contains("Navigation", cut.Find("summary").TextContent);
        Assert.NotNull(cut.Find(".rail-section-body .rail-link"));
    }
}

Step 2: Run → FAIL. Step 3: implement:

@* Components/NavRailItem.razor *@
<NavLink class="rail-link" href="@Href" Match="@Match" ActiveClass="active">
    @if (Icon is not null) { <span class="rail-ico">@Icon</span> }
    @Text
</NavLink>

@code {
    [Parameter, EditorRequired] public string Href { get; set; } = string.Empty;
    [Parameter, EditorRequired] public string Text { get; set; } = string.Empty;
    [Parameter] public RenderFragment? Icon { get; set; }
    [Parameter] public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix;
}
@* Components/NavRailSection.razor — CSS-only collapsible (no JS, works in static SSR).
   Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
<details class="rail-section" open="@Expanded">
    <summary class="rail-eyebrow-toggle"><span class="rail-eyebrow-label">@Title</span></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;
    [Parameter] public RenderFragment? ChildContent { get; set; }
}

Step 4: Run → PASS. Step 5: Commit feat(theme): NavRailItem + NavRailSection.


Task 6: ThemeShell (canonical side-rail shell)

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (uses BrandBar — depends on Task 4)

Files:

  • Create: src/ZB.MOM.WW.Theme/Components/ThemeShell.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs

Why a component, not a layout: @layout can't pass parameters. Each app keeps a 3-line MainLayout : LayoutComponentBase that renders <ThemeShell …>@Body</ThemeShell> (documented in the RCL README, Task 11).

Step 1: failing test:

namespace ZB.MOM.WW.Theme.Tests;

public class ThemeShellTests : TestContext
{
    [Fact]
    public void Renders_product_nav_and_body()
    {
        var cut = RenderComponent<ThemeShell>(p => p
            .Add(x => x.Product, "OtOpcUa")
            .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
            .AddChildContent("<div class='pagebody'>BODY</div>"));
        Assert.NotNull(cut.Find(".side-rail .brand"));
        Assert.Contains("OtOpcUa", cut.Markup);
        Assert.NotNull(cut.Find(".side-rail .rail-link"));
        Assert.NotNull(cut.Find("main.page .pagebody"));
    }

    [Fact]
    public void Accent_sets_css_variable_on_shell_root()
    {
        var cut = RenderComponent<ThemeShell>(p => p
            .Add(x => x.Product, "ScadaBridge")
            .Add(x => x.Accent, "#2f855a"));
        var shell = cut.Find(".app-shell");
        Assert.Contains("--accent: #2f855a", shell.GetAttribute("style"));
    }

    [Fact]
    public void No_accent_emits_no_style()
    {
        var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
        Assert.False(cut.Find(".app-shell").HasAttribute("style"));
    }

    [Fact]
    public void RailFooter_renders_when_supplied()
    {
        var cut = RenderComponent<ThemeShell>(p => p
            .Add(x => x.Product, "OtOpcUa")
            .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
        Assert.NotNull(cut.Find(".rail-foot .sess"));
    }
}

Step 2: Run → FAIL. Step 3: implement:

@* Components/ThemeShell.razor — the one canonical side-rail chassis.
   Not a LayoutComponentBase: the app's thin MainLayout delegates to this. *@
<div class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
    <button class="btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
            type="button" data-bs-toggle="collapse" data-bs-target="#theme-rail"
            aria-controls="theme-rail" aria-expanded="false" aria-label="Toggle navigation">
        &#9776;
    </button>
    <div class="collapse d-lg-block" id="theme-rail">
        <nav class="side-rail">
            <BrandBar Product="@Product" Logo="@Logo" />
            @Nav
            @if (RailFooter is not null)
            {
                <div class="rail-foot">@RailFooter</div>
            }
        </nav>
    </div>
    <main class="page">@ChildContent</main>
</div>

@code {
    [Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
    [Parameter] public string? Accent { get; set; }
    [Parameter] public RenderFragment? Logo { get; set; }
    [Parameter] public RenderFragment? Nav { get; set; }
    [Parameter] public RenderFragment? RailFooter { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }

    private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}";
}

Note: style="@AccentStyle" — Blazor omits the attribute entirely when AccentStyle is null, satisfying No_accent_emits_no_style.

Step 4: Run → PASS. Step 5: Commit feat(theme): ThemeShell canonical side-rail.


Task 7: LoginCard (static form-POST card)

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 2, 3, 4, 5, 8, 9

Files:

  • Create: src/ZB.MOM.WW.Theme/Components/LoginCard.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs

Design note: Login MUST be a static <form method="post"> posting to a server endpoint (/auth/login) — SignInAsync has to run before the response starts, so an interactive EventCallback is too late (verified in OtOpcUa's Login.razor). ChildContent lets the app inject <AntiforgeryToken/>.

Step 1: failing test:

namespace ZB.MOM.WW.Theme.Tests;

public class LoginCardTests : TestContext
{
    [Fact]
    public void Posts_to_action_with_username_password_fields()
    {
        var cut = RenderComponent<LoginCard>(p => p
            .Add(x => x.Product, "OtOpcUa")
            .Add(x => x.Action, "/auth/login"));
        var form = cut.Find("form");
        Assert.Equal("post", form.GetAttribute("method"));
        Assert.Equal("/auth/login", form.GetAttribute("action"));
        Assert.NotNull(cut.Find("input#username"));
        Assert.NotNull(cut.Find("input#password"));
        Assert.Contains("OtOpcUa", cut.Find(".login-title").TextContent);
    }

    [Fact]
    public void ReturnUrl_renders_hidden_input()
    {
        var cut = RenderComponent<LoginCard>(p => p
            .Add(x => x.Product, "OtOpcUa")
            .Add(x => x.ReturnUrl, "/clusters"));
        var hidden = cut.Find("input[name=returnUrl]");
        Assert.Equal("/clusters", hidden.GetAttribute("value"));
    }

    [Fact]
    public void Error_renders_notice()
    {
        var cut = RenderComponent<LoginCard>(p => p
            .Add(x => x.Product, "OtOpcUa")
            .Add(x => x.Error, "Bad credentials"));
        Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
    }

    [Fact]
    public void No_returnUrl_no_hidden_input()
    {
        var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
        Assert.Empty(cut.FindAll("input[name=returnUrl]"));
    }
}

Step 2: Run → FAIL. Step 3: implement:

@* Components/LoginCard.razor — static form-POST sign-in card. *@
<div class="login-wrap rise">
    <section class="panel">
        <div class="login-body">
            <h1 class="login-title">@Product &mdash; sign in</h1>
            <form method="post" action="@Action" data-enhance="false">
                @if (!string.IsNullOrEmpty(ReturnUrl))
                {
                    <input type="hidden" name="returnUrl" value="@ReturnUrl" />
                }
                @ChildContent  @* e.g. <AntiforgeryToken/> supplied by the app *@
                <div class="mb-3">
                    <label class="form-label" for="username">Username</label>
                    <input id="username" name="username" type="text"
                           class="form-control form-control-sm" autocomplete="username" />
                </div>
                <div class="mb-3">
                    <label class="form-label" for="password">Password</label>
                    <input id="password" name="password" type="password"
                           class="form-control form-control-sm" autocomplete="current-password" />
                </div>
                @if (!string.IsNullOrWhiteSpace(Error))
                {
                    <div class="panel notice login-error">@Error</div>
                }
                <button class="btn btn-primary w-100" type="submit">Sign in</button>
            </form>
        </div>
    </section>
</div>

@code {
    [Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
    [Parameter] public string Action { get; set; } = "/auth/login";
    [Parameter] public string? ReturnUrl { get; set; }
    [Parameter] public string? Error { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
}

Step 4: Run → PASS. Step 5: Commit feat(theme): LoginCard.


Task 8: Common controls — TechButton, TechCard, TechField

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, 3, 4, 5, 7, 9

Files:

  • Create: src/ZB.MOM.WW.Theme/ButtonVariant.cs
  • Create: src/ZB.MOM.WW.Theme/Components/TechButton.razor
  • Create: src/ZB.MOM.WW.Theme/Components/TechCard.razor
  • Create: src/ZB.MOM.WW.Theme/Components/TechField.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs

Step 1: failing test:

namespace ZB.MOM.WW.Theme.Tests;

public class CommonControlsTests : TestContext
{
    [Theory]
    [InlineData(ButtonVariant.Primary, "btn-primary")]
    [InlineData(ButtonVariant.Secondary, "btn-outline-secondary")]
    [InlineData(ButtonVariant.Danger, "btn-danger")]
    [InlineData(ButtonVariant.Ghost, "btn-link")]
    public void TechButton_maps_variant(ButtonVariant v, string cls)
    {
        var cut = RenderComponent<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
        var btn = cut.Find("button");
        Assert.Contains("btn", btn.ClassList);
        Assert.Contains(cls, btn.ClassList);
    }

    [Fact]
    public void TechButton_busy_disables_and_passes_through_attributes()
    {
        var cut = RenderComponent<TechButton>(p => p
            .Add(x => x.Busy, true)
            .AddUnmatched("id", "save")
            .AddChildContent("Save"));
        var btn = cut.Find("button");
        Assert.True(btn.HasAttribute("disabled"));
        Assert.Equal("save", btn.GetAttribute("id"));
    }

    [Fact]
    public void TechCard_renders_title_and_body()
    {
        var cut = RenderComponent<TechCard>(p => p
            .Add(x => x.Title, "Drivers")
            .AddChildContent("<div class='b'>x</div>"));
        Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
        Assert.NotNull(cut.Find(".panel-body .b"));
    }

    [Fact]
    public void TechField_renders_label_hint_error()
    {
        var cut = RenderComponent<TechField>(p => p
            .Add(x => x.Label, "Name")
            .Add(x => x.Hint, "required")
            .Add(x => x.Error, "missing")
            .AddChildContent("<input/>"));
        Assert.Contains("Name", cut.Find("label").TextContent);
        Assert.Contains("required", cut.Find(".form-text").TextContent);
        Assert.Contains("missing", cut.Find(".field-error").TextContent);
    }
}

(AddUnmatched is bUnit's helper for CaptureUnmatchedValues.)

Step 2: Run → FAIL. Step 3: implement:

// ButtonVariant.cs
namespace ZB.MOM.WW.Theme;

public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
@* Components/TechButton.razor *@
<button type="@Type" class="btn @VariantClass" disabled="@Busy" @attributes="Extra">
    @if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
    @ChildContent
</button>

@code {
    [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
    [Parameter] public string Type { get; set; } = "button";
    [Parameter] public bool Busy { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? Extra { get; set; }

    private string VariantClass => Variant switch
    {
        ButtonVariant.Secondary => "btn-outline-secondary",
        ButtonVariant.Danger    => "btn-danger",
        ButtonVariant.Ghost     => "btn-link",
        _                       => "btn-primary",
    };
}
@* Components/TechCard.razor *@
<section class="panel @Class">
    @if (Header is not null) { <div class="panel-head">@Header</div> }
    else if (!string.IsNullOrEmpty(Title)) { <div class="panel-head">@Title</div> }
    <div class="panel-body">@ChildContent</div>
    @if (Footer is not null) { <div class="panel-foot">@Footer</div> }
</section>

@code {
    [Parameter] public string? Title { get; set; }
    [Parameter] public RenderFragment? Header { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
    [Parameter] public RenderFragment? Footer { get; set; }
    [Parameter] public string? Class { get; set; }
}
@* Components/TechField.razor *@
<div class="tech-field mb-3">
    <label class="form-label">@Label</label>
    @ChildContent
    @if (!string.IsNullOrEmpty(Hint)) { <div class="form-text">@Hint</div> }
    @if (!string.IsNullOrEmpty(Error)) { <div class="field-error s-bad">@Error</div> }
</div>

@code {
    [Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
    [Parameter] public string? Hint { get; set; }
    [Parameter] public string? Error { get; set; }
    [Parameter] public RenderFragment? ChildContent { get; set; }
}

Step 4: Run → PASS. Step 5: Commit feat(theme): TechButton/TechCard/TechField.


Task 9: ThemeHead (stylesheet entry point)

Classification: small Estimated implement time: ~2 min Parallelizable with: Task 2, 3, 4, 5, 7, 8

Files:

  • Create: src/ZB.MOM.WW.Theme/Components/ThemeHead.razor
  • Test: tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs

Step 1: failing test:

namespace ZB.MOM.WW.Theme.Tests;

public class ThemeHeadTests : TestContext
{
    [Fact]
    public void Emits_theme_and_layout_links_to_content_path()
    {
        var cut = RenderComponent<ThemeHead>();
        var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList();
        Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs);
        Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
    }
}

Step 2: Run → FAIL. Step 3: implement:

@* Components/ThemeHead.razor — drop in <head>, AFTER your Bootstrap <link>. *@
<link rel="stylesheet" href="_content/ZB.MOM.WW.Theme/css/theme.css" />
<link rel="stylesheet" href="_content/ZB.MOM.WW.Theme/css/layout.css" />

Step 4: Run → PASS. Step 5: Commit feat(theme): ThemeHead stylesheet entry point.


Task 10: Full build, pack, and RCL README

Classification: standard Estimated implement time: ~4 min Parallelizable with: none (depends on Tasks 19)

Files:

  • Create: ZB.MOM.WW.Theme/README.md

Step 1: Full green build + tests:

cd ~/Desktop/scadaproj/ZB.MOM.WW.Theme
dotnet build -c Release        # expect 0 warnings (TreatWarningsAsErrors)
dotnet test                    # expect all bUnit tests green

Step 2: Pack and confirm one nupkg with the static assets:

./build/pack.sh                # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
unzip -l artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg | grep -E 'theme.css|woff2'

Expected: staticwebassets/css/theme.css + the three woff2 appear in the package.

Step 3: Write README.md — consumer guide. MUST include the thin-MainLayout delegation example (the key adoption pattern):

# ZB.MOM.WW.Theme

Shared Technical-Light UI kit for the ZB.MOM.WW SCADA family: design tokens + IBM Plex
fonts (static web assets) and a canonical side-rail shell + widgets.

## Adopt
1. Reference the package; keep your own Bootstrap 5 `<link>`.
2. In `App.razor` `<head>`, after Bootstrap: `<ThemeHead />`.
3. Make your `MainLayout` a thin delegate to `ThemeShell`:

```razor
@inherits LayoutComponentBase
<ThemeShell Product="OtOpcUa" Accent="#2f5fd0">
    <Nav>
        <NavRailSection Title="Navigation">
            <NavRailItem Href="/" Text="Overview" Match="NavLinkMatch.All" />
            <NavRailItem Href="/clusters" Text="Clusters" />
        </NavRailSection>
    </Nav>
    <RailFooter>@* session / sign-out *@</RailFooter>
    <ChildContent>@Body</ChildContent>
</ThemeShell>
```

## Components
`ThemeHead` · `ThemeShell` · `BrandBar` · `NavRailItem` · `NavRailSection` ·
`StatusPill` (`StatusState`) · `LoginCard` · `TechButton` (`ButtonVariant`) · `TechCard` ·
`TechField`.

## Build
`dotnet build -c Release` · `dotnet test` · `./build/pack.sh`

Step 4: Commit docs(theme): RCL README + verified pack.


Task 11: Normalization docs — spec, tokens, shared-contract, README

Classification: standard Estimated implement time: ~5 min Parallelizable with: Tasks 110 (pure docs in a different tree)

Files:

  • Create: components/ui-theme/README.md
  • Create: components/ui-theme/spec/SPEC.md
  • Create: components/ui-theme/spec/DESIGN-TOKENS.md
  • Create: components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md

Write per the design doc §5. SPEC.md = §1 tokens, §2 typography, §3 canonical side-rail layout, §4 component contract (the Task 39 surface, with ThemeShell as a component + the thin-MainLayout pattern), §5 delivery (_content, ThemeHead), §6 shared-vs-per-project (design doc §6), §7 acceptance. DESIGN-TOKENS.md = enumerate every :root token from theme.css (name · value · role). ZB.MOM.WW.Theme.md = the component API + consumer matrix (all 3 apps consume the single RCL; surfaces: OtOpcUa AdminUI, MxGateway Server Dashboard, ScadaBridge Host + CentralUI).

Commit: docs(ui-theme): spec, design tokens, shared contract.


Task 12: Normalization docs — current-state ×3 + GAPS

Classification: standard Estimated implement time: ~5 min Parallelizable with: Tasks 110 (depends on Task 11 for spec cross-refs)

Files:

  • Create: components/ui-theme/current-state/otopcua/CURRENT-STATE.md
  • Create: components/ui-theme/current-state/mxaccessgw/CURRENT-STATE.md
  • Create: components/ui-theme/current-state/scadabridge/CURRENT-STATE.md
  • Create: components/ui-theme/GAPS.md

Each CURRENT-STATE.md is code-verified with file:line refs (use the Reference sources above + the per-app theme.css/site.css/layout paths) and ends in an adoption plan (delete theme.css+fonts copy, render in ThemeShell via thin MainLayout, swap login card for LoginCard; keep site.css page residue + scoped .razor.css). Capture the verified facts: all three theme.css are byte-identical except font-path; OtOpcUa's url('fonts/…') is the latent 404 the kit fixes; OtOpcUa already a rail (low effort), MxGateway top-bar → rail (high risk), ScadaBridge has scoped .razor.css that stays. GAPS.md = divergence tables + prioritized adoption backlog (design doc §7).

Commit: docs(ui-theme): current-state ×3 + GAPS adoption backlog.


Task 13: Register the component

Classification: small Estimated implement time: ~3 min Parallelizable with: none (depends on Tasks 1112 existing; touches shared index files)

Files:

  • Modify: components/README.md (registry table — add UI-theme row)
  • Modify: CLAUDE.md (Component normalization table + prose)
  • Modify: README.md (component table + roadmap)

Add the UI-theme row to the components/README.md registry (Status Draft, Applies to all three, Goal "Path to shared code (ZB.MOM.WW.Theme)", Folder ui-theme/). Add a row to CLAUDE.md's component table (mirroring the auth row: Status, Goal, Design link, Implementation link ZB.MOM.WW.Theme/) and note in prose that scadaproj now hosts a second shared library. Add a row to the root README.md component table + a roadmap bullet. Keep the existing auth registry-status inconsistency out of scope (don't touch the auth row's wording).

Commit: docs: register ui-theme component in indexes.


Execution order & parallelism summary

  • Task 1 first (everything depends on scaffold).
  • Tasks 2,3,4,5,7,8,9 are mutually parallelizable (disjoint files) once Task 1 lands. Task 6 waits on Task 4 (BrandBar).
  • Task 10 waits on Tasks 19.
  • Tasks 11,12,13 are docs in components/ — parallelizable with the entire RCL build; 12 follows 11, 13 follows 11+12.

Git note: scadaproj is a single repo on main (no nested repo for the kit, unlike how Auth started). One committing implementer in flight at a time. App adoption is NOT part of this plan — it is per-repo follow-on tracked in components/ui-theme/GAPS.md.