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

1011 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`](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):
```xml
<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):
```xml
<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:
```xml
<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`:
```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`:
```xml
<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:
```csharp
global using Bunit;
global using Xunit;
global using ZB.MOM.WW.Theme;
```
**Step 7:** `ZB.MOM.WW.Theme.slnx`:
```xml
<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):
```bash
cd ~/Desktop/scadaproj/ZB.MOM.WW.Theme && dotnet build
```
Expected: build succeeds, 0 warnings.
**Step 10:** Commit.
```bash
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/`).
```bash
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:
```css
/* 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.
```csharp
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:**
```csharp
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:**
```csharp
// StatusState.cs
namespace ZB.MOM.WW.Theme;
public enum StatusState { Ok, Warn, Bad, Idle, Info }
```
```razor
@* 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:**
```csharp
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:**
```razor
@* 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):
```csharp
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:**
```razor
@* 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;
}
```
```razor
@* 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:**
```csharp
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:**
```razor
@* 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:**
```csharp
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:**
```razor
@* 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:**
```csharp
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:**
```csharp
// ButtonVariant.cs
namespace ZB.MOM.WW.Theme;
public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
```
```razor
@* 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",
};
}
```
```razor
@* 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; }
}
```
```razor
@* 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:**
```csharp
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:**
```razor
@* 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:
```bash
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:
```bash
./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):
````markdown
# 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`.