1011 lines
37 KiB
Markdown
1011 lines
37 KiB
Markdown
# 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">▮</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">
|
||
☰
|
||
</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 — 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 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 `<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 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`.
|