diff --git a/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md b/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md new file mode 100644 index 0000000..a685004 --- /dev/null +++ b/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md @@ -0,0 +1,1010 @@ +# 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 + + + net10.0 + enable + enable + latest + 0.1.0 + true + + +``` + +**Step 2:** `Directory.Packages.props` (test stack + bUnit only — the RCL itself uses just +the framework reference): +```xml + + + true + + + + + + + + + +``` +(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 + + + ZB.MOM.WW.Theme + true + true + ZB.MOM.WW.Theme + Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family. + + + + + + + + +``` + +**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 + + + false + + + + + + + + + + + + +``` + +**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 + + + + + + + + +``` + +**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(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 *@ +@ChildContent + +@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(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(p => p + .Add(x => x.Product, "ScadaBridge") + .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "")))); + Assert.NotNull(cut.Find(".brand .logo")); + Assert.Empty(cut.FindAll(".brand .mark")); + } +} +``` + +**Step 2:** Run → FAIL. **Step 3: implement:** +```razor +@* Components/BrandBar.razor *@ +
+ @if (Logo is not null) { @Logo } + else { } + @Product +
+ +@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 +`` 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(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(p => p + .Add(x => x.Title, "Navigation") + .AddChildContent("X")); + 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 *@ + + @if (Icon is not null) { @Icon } + @Text + + +@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. *@ +
+ @Title +
@ChildContent
+
+ +@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 `@Body` +(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(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "N"))) + .AddChildContent("
BODY
")); + 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(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(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(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "S")))); + 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. *@ +
+ +
+ +
+
@ChildContent
+
+ +@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 `
` 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 ``. + +**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(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(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(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(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. *@ + + +@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(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(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(p => p + .Add(x => x.Title, "Drivers") + .AddChildContent("
x
")); + 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(p => p + .Add(x => x.Label, "Name") + .Add(x => x.Hint, "required") + .Add(x => x.Error, "missing") + .AddChildContent("")); + 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 *@ + + +@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? 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 *@ +
+ @if (Header is not null) {
@Header
} + else if (!string.IsNullOrEmpty(Title)) {
@Title
} +
@ChildContent
+ @if (Footer is not null) {
@Footer
} +
+ +@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 *@ +
+ + @ChildContent + @if (!string.IsNullOrEmpty(Hint)) {
@Hint
} + @if (!string.IsNullOrEmpty(Error)) {
@Error
} +
+ +@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(); + 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 , AFTER your Bootstrap . *@ + + +``` + +**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 1–9) + +**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 ``. +2. In `App.razor` ``, after Bootstrap: ``. +3. Make your `MainLayout` a thin delegate to `ThemeShell`: + +```razor +@inherits LayoutComponentBase + + + @* session / sign-out *@ + @Body + +``` + +## 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 1–10 (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 3–9 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 1–10 (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 11–12 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 1–9. +- **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`. diff --git a/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md.tasks.json b/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md.tasks.json new file mode 100644 index 0000000..b2e9a39 --- /dev/null +++ b/docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md.tasks.json @@ -0,0 +1,25 @@ +{ + "planPath": "docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md", + "repo": "~/Desktop/scadaproj", + "tasks": [ + {"id": 1, "nativeId": 28, "subject": "Task 1: Scaffold ZB.MOM.WW.Theme RCL", "classification": "standard", "status": "pending", "blockedBy": []}, + {"id": 2, "nativeId": 29, "subject": "Task 2: Static assets — tokens, fonts, layout CSS", "classification": "standard", "status": "pending", "blockedBy": [1]}, + {"id": 3, "nativeId": 30, "subject": "Task 3: StatusState enum + StatusPill", "classification": "small", "status": "pending", "blockedBy": [1]}, + {"id": 4, "nativeId": 31, "subject": "Task 4: BrandBar", "classification": "small", "status": "pending", "blockedBy": [1]}, + {"id": 5, "nativeId": 32, "subject": "Task 5: NavRailItem + NavRailSection", "classification": "standard", "status": "pending", "blockedBy": [1]}, + {"id": 6, "nativeId": 33, "subject": "Task 6: ThemeShell canonical side-rail", "classification": "standard", "status": "pending", "blockedBy": [4]}, + {"id": 7, "nativeId": 34, "subject": "Task 7: LoginCard", "classification": "standard", "status": "pending", "blockedBy": [1]}, + {"id": 8, "nativeId": 35, "subject": "Task 8: TechButton + TechCard + TechField", "classification": "standard", "status": "pending", "blockedBy": [1]}, + {"id": 9, "nativeId": 36, "subject": "Task 9: ThemeHead stylesheet entry point", "classification": "small", "status": "pending", "blockedBy": [1]}, + {"id": 10, "nativeId": 37, "subject": "Task 10: Full build, pack, RCL README", "classification": "standard", "status": "pending", "blockedBy": [2, 3, 4, 5, 6, 7, 8, 9]}, + {"id": 11, "nativeId": 38, "subject": "Task 11: ui-theme docs — spec, tokens, contract, README", "classification": "standard", "status": "pending", "blockedBy": []}, + {"id": 12, "nativeId": 39, "subject": "Task 12: ui-theme docs — current-state ×3 + GAPS", "classification": "standard", "status": "pending", "blockedBy": [11]}, + {"id": 13, "nativeId": 40, "subject": "Task 13: Register ui-theme component in indexes", "classification": "small", "status": "pending", "blockedBy": [11, 12]} + ], + "parallelism": { + "after_task_1": [2, 3, 4, 5, 7, 8, 9], + "task_6_waits_on": 4, + "docs_parallel_with_build": [11, 12, 13] + }, + "lastUpdated": "2026-06-01" +}