Compare commits

...

18 Commits

Author SHA1 Message Date
Joseph Doherty 6185009554 Merge feat/zb-mom-ww-theme: ZB.MOM.WW.Theme shared UI kit (0.1.0) + ui-theme normalization component 2026-06-01 05:18:46 -04:00
Joseph Doherty 2485d86205 docs: register ui-theme component in indexes 2026-06-01 05:16:58 -04:00
Joseph Doherty 029ac0719b docs(ui-theme): current-state ×3 + GAPS adoption backlog 2026-06-01 05:15:38 -04:00
Joseph Doherty 95975d0754 docs(ui-theme): spec, design tokens, shared contract 2026-06-01 05:11:43 -04:00
Joseph Doherty 46ce627ea5 docs(theme): RCL README + verified pack
Full Release build (0 warnings, TreatWarningsAsErrors), 32/32 bUnit tests green.
Pack confirmed: staticwebassets/css/theme.css, staticwebassets/css/layout.css, and
the three IBM Plex woff2 fonts ship in ZB.MOM.WW.Theme.0.1.0.nupkg. README covers
the one-paragraph intro, 3-step Adopt guide, thin-MainLayout→ThemeShell delegation
example, component/enum reference table, static-asset paths, and build commands.
2026-06-01 05:05:26 -04:00
Joseph Doherty fe774f8ee4 fix(theme): correct sticky rail selector, harden bool attrs/tests, doc LoginCard security contract
- layout.css: fix @media sticky selector from #sidebar-collapse → #theme-rail (Fix 1)
- NavRailTests/CommonControlsTests: add TDD tests verifying Blazor omits false bool attrs (Fix 2)
- TechButton: rename Extra → AdditionalAttributes, move @attributes splat first (Fix 3)
- LoginCard: add security contract XML/comment docs on ReturnUrl and ChildContent (Fix 4)
- build/pack.sh, push.sh: fix comment from ZB.MOM.WW.Auth → ZB.MOM.WW.Theme (Fix 5)
2026-06-01 05:03:17 -04:00
Joseph Doherty cac2f659e4 feat(theme): ThemeHead stylesheet entry point 2026-06-01 04:56:26 -04:00
Joseph Doherty 40f6962d05 feat(theme): TechButton/TechCard/TechField 2026-06-01 04:56:06 -04:00
Joseph Doherty f7ec3fd732 feat(theme): LoginCard 2026-06-01 04:55:24 -04:00
Joseph Doherty b09de9b777 feat(theme): ThemeShell canonical side-rail
Add ThemeShell.razor (regular component, not LayoutComponentBase) with
Product, Accent, Logo, Nav, RailFooter, and ChildContent parameters.
Accent uses nullable AccentStyle so the style attribute is entirely
absent when null. Composes BrandBar inside .side-rail, wraps page in
<main class="page">. Add ThemeShellTests.cs (4 tests: product/nav/body,
accent sets css var, no-accent emits no style, RailFooter). All 18 tests
green, 0 build warnings.
2026-06-01 04:53:52 -04:00
Joseph Doherty 75e58085d1 refactor(theme): unify components into ZB.MOM.WW.Theme namespace
Add @namespace ZB.MOM.WW.Theme to each component .razor file so the
Razor compiler places all four components in the flat ZB.MOM.WW.Theme
namespace rather than ZB.MOM.WW.Theme.Components. Remove the now-
redundant global using ZB.MOM.WW.Theme.Components from both _Imports
files. Also add @namespace ZB.MOM.WW.Theme to the root _Imports.razor.
Consumers need only @using ZB.MOM.WW.Theme. All 14 tests green.
2026-06-01 04:53:12 -04:00
Joseph Doherty a74ad7008d feat(theme): NavRailItem + NavRailSection 2026-06-01 04:47:36 -04:00
Joseph Doherty 8e70718ca4 feat(theme): BrandBar 2026-06-01 04:46:58 -04:00
Joseph Doherty af8682c0f2 feat(theme): StatusPill widget 2026-06-01 04:46:24 -04:00
Joseph Doherty 6736415a32 feat(theme): vendor tokens, fonts, and side-rail layout CSS 2026-06-01 04:44:36 -04:00
Joseph Doherty 24fce87c96 feat(theme): scaffold ZB.MOM.WW.Theme RCL + test project 2026-06-01 04:41:48 -04:00
Joseph Doherty 5d1cae3fc6 docs: add ZB.MOM.WW.Theme implementation plan (13 tasks) 2026-06-01 04:39:06 -04:00
Joseph Doherty f9d570c323 docs: add UI-theme component design
Brainstormed design for normalizing UI theming across the 3 sister apps
into a single .NET 10 RCL (ZB.MOM.WW.Theme): canonical side-rail shell +
Technical-Light tokens/fonts as static assets + StatusPill/LoginCard/
TechButton-Card-Field, with per-app name/accent/logo. Mirrors the auth
component's path-to-shared-code treatment; app adoption tracked as
follow-on.
2026-06-01 04:29:58 -04:00
50 changed files with 4234 additions and 4 deletions
+20 -3
View File
@@ -6,9 +6,10 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
`scadaproj` is primarily an umbrella/index workspace that aggregates a family of
related SCADA / OT / Wonderware / OPC UA "sister projects" that live as **sibling
directories under `~/Desktop/`**. It now also **hosts one piece of source itself**
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library (its own nested git repo), the
realized output of the auth component normalization (see [Component normalization](#component-normalization)).
directories under `~/Desktop/`**. It now also **hosts two pieces of source itself**
the shared [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) library and the shared
[`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) UI kit — both the realized output of their
respective component normalizations (see [Component normalization](#component-normalization)).
The point of this file is to give a high-level scan of each sister project — its purpose,
location, stack, and primary commands — so a fresh Claude Code session can orient across
the whole family without opening each repo first.
@@ -117,6 +118,7 @@ each project's **code-verified current state**, and the **gaps** between. See
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
| UI Theme (layout / tokens / components) | Built (lib `0.1.0`) | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
@@ -132,6 +134,21 @@ The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test`. Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & ScadaBridge → all four (ApiKeys not used by OtOpcUa).
The UI-theme component is fully populated: a normalized [`spec`](components/ui-theme/spec/SPEC.md),
a [`design-tokens`](components/ui-theme/spec/DESIGN-TOKENS.md) reference, a
[`shared-contract`](components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md), three
[`current-state`](components/ui-theme/current-state/) docs, and an adoption [`GAPS`](components/ui-theme/GAPS.md)
backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + widgets; left
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
(.NET 10 Razor Class Library; single package; 32 bUnit tests; `dotnet pack` → 1 nupkg @ 0.1.0).
The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md).
**Not yet adopted** by the three apps — that's the follow-on tracked in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
## Per-project primary commands
Run these from inside each project directory (not from `scadaproj`).
+42 -1
View File
@@ -17,8 +17,10 @@ it produces.
| [`CLAUDE.md`](CLAUDE.md) | High-level index of the sister projects + working guidance |
| [`components/`](components/) | Component-normalization framework (per concern: target spec, current state, gaps) |
| [`components/auth/`](components/auth/) | First normalized component — login / identity / authorization |
| [`docs/plans/`](docs/plans/) | Implementation plans (e.g. the ZB.MOM.WW.Auth build) |
| [`components/ui-theme/`](components/ui-theme/) | Second normalized component — UI theme / design tokens / layout |
| [`docs/plans/`](docs/plans/) | Implementation plans (e.g. the ZB.MOM.WW.Auth and ZB.MOM.WW.Theme builds) |
| [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) | **Built shared library** (.NET 10) — the realized output of the auth normalization |
| [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) | **Built shared RCL** (.NET 10) — the realized output of the UI-theme normalization |
## The sister projects
@@ -52,6 +54,42 @@ The sister repos kept re-implementing the same cross-cutting concerns and drifti
| Component | Status | Folder |
|---|---|---|
| Auth (login / identity / authz) | Built (library 0.1.0); apps not yet adopted | [`components/auth/`](components/auth/) |
| UI Theme (layout / tokens / components) | Built (RCL 0.1.0); apps not yet adopted | [`components/ui-theme/`](components/ui-theme/) |
## `ZB.MOM.WW.Theme` — the shared UI kit
The UI-theme component, realized as a single-package .NET 10 Razor Class Library the three
apps can adopt to stop copy-pasting the Technical-Light design system. **Built and tested at
0.1.0; adoption by the apps is the follow-on** (tracked in
[`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md)).
| Asset / Component | Purpose | Used by |
|---|---|---|
| `_content/ZB.MOM.WW.Theme/css/theme.css` | Design tokens, IBM Plex typography, Bootstrap overrides | all |
| `_content/ZB.MOM.WW.Theme/css/layout.css` | Side-rail shell layout, nav CSS, chip/card helpers | all |
| `_content/ZB.MOM.WW.Theme/fonts/*.woff2` | IBM Plex Sans 400/600 + Mono 500, vendored | all |
| `ThemeHead`, `ThemeShell`, `BrandBar` | Shell entry point and chassis components | all |
| `NavRailItem`, `NavRailSection` | Rail nav components | all |
| `StatusPill` (`StatusState`) | Inline status chip — replaces per-app `StatusBadge` | all |
| `LoginCard` | Static form-POST sign-in card | OtOpcUa, ScadaBridge (MxGateway when login page added) |
| `TechButton`, `TechCard`, `TechField` | Common controls (Bootstrap 5 wrappers) | all |
**Consumer matrix:** all three apps consume the single `ZB.MOM.WW.Theme` package —
OtOpcUa `AdminUI`, MxAccessGateway `Server`, ScadaBridge `Host` + `CentralUI`.
### Build & test
```bash
cd ZB.MOM.WW.Theme
dotnet build -c Release # 0 warnings (TreatWarningsAsErrors)
dotnet test # 32 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
```
Stack: .NET 10 · Razor Class Library · bUnit · xUnit · central package management.
More detail: [`ZB.MOM.WW.Theme/README.md`](ZB.MOM.WW.Theme/README.md).
---
## `ZB.MOM.WW.Auth` — the shared library
@@ -98,4 +136,7 @@ ZB_LDAP_IT=1 dotnet test # requires a reachable GLAuth (e.g. a sister repo's i
- ✅ Auth component normalized (spec + canonical roles + current-state + gaps).
-`ZB.MOM.WW.Auth` shared library built and tested (0.1.0).
- ⬜ Adopt `ZB.MOM.WW.Auth` in OtOpcUa, MxAccessGateway, ScadaBridge — [`components/auth/GAPS.md`](components/auth/GAPS.md) (#8).
- ✅ UI-theme component normalized (spec + design tokens + current-state + gaps).
-`ZB.MOM.WW.Theme` shared UI kit built and tested (0.1.0); apps not yet adopted.
- ⬜ Adopt `ZB.MOM.WW.Theme` in OtOpcUa [low risk], ScadaBridge [med], MxAccessGateway [high risk] — [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
- ⬜ Normalize the next cross-cutting component.
+482
View File
@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp
+10
View File
@@ -0,0 +1,10 @@
<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>
+12
View File
@@ -0,0 +1,12 @@
<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>
+93
View File
@@ -0,0 +1,93 @@
# 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. The kit ships one
.NET 10 Razor Class Library (RCL) with CSS custom-property tokens, the three IBM Plex
woff2 fonts, and side-rail layout CSS — all served from `_content/ZB.MOM.WW.Theme/…`
plus a set of Blazor SSR components that carry no inline colours and reuse the token
classes. Bootstrap 5 is **not** vendored; each app keeps its own Bootstrap link.
## Adopt
1. Reference the NuGet package in your app; keep your own Bootstrap 5 `<link>` in `App.razor`.
2. In `App.razor` `<head>`, **after** your Bootstrap link, add `<ThemeHead />`:
```razor
<link rel="stylesheet" href="..." /> @* Bootstrap — yours, not vendored by the kit *@
<ThemeHead />
```
3. Make your `MainLayout` a thin delegate to `ThemeShell` — `@layout` cannot pass
parameters, so `ThemeShell` is a regular component and `MainLayout` is a 3-line wrapper:
```razor
@* Layouts/MainLayout.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 info / sign-out link *@</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
```
Add `@using ZB.MOM.WW.Theme` to your `_Imports.razor` so all components are available
without per-file usings.
### Login page
Use `<LoginCard>` for the sign-in form. The card posts to a server endpoint
(`/auth/login` by default) so `SignInAsync` can run before the response starts.
You **must** inject `<AntiforgeryToken/>` inside the card and **validate `ReturnUrl`
server-side** before redirecting (open-redirect risk):
```razor
<LoginCard Product="OtOpcUa" Action="/auth/login" ReturnUrl="@safeReturnUrl" Error="@errorMsg">
<AntiforgeryToken />
</LoginCard>
```
## Components
| Component | Parameters (key) | Notes |
|---|---|---|
| `ThemeHead` | — | Emits `<link>` tags for `theme.css` and `layout.css`. Place in `<head>` after Bootstrap. |
| `ThemeShell` | `Product`*, `Accent`, `Logo`, `Nav`, `RailFooter`, `ChildContent` | Side-rail chassis. Not a layout — delegated to from `MainLayout`. `Accent` overrides `--accent` token. |
| `BrandBar` | `Product`*, `Logo` | Brand glyph + product name; rendered inside `ThemeShell`'s rail header. |
| `NavRailItem` | `Href`*, `Text`*, `Icon`, `Match` | Wraps `<NavLink class="rail-link">`. |
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
| `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. |
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. |
| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. |
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. |
| `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. |
\* `EditorRequired` parameter.
Flat namespace: all components and enums live in `ZB.MOM.WW.Theme`. One `@using` covers everything.
## Static assets
Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipeline:
| Path | Contents |
|---|---|
| `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers |
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers |
| `fonts/ibm-plex-sans-400.woff2` | IBM Plex Sans Regular |
| `fonts/ibm-plex-sans-600.woff2` | IBM Plex Sans SemiBold |
| `fonts/ibm-plex-mono-500.woff2` | IBM Plex Mono Medium |
`theme.css` declares `@font-face` with `url('../fonts/…')` — correct relative path from
`css/` to `fonts/`. (OtOpcUa's original `url('fonts/…')` was a latent 404; the kit fixes it.)
## Build
```bash
# from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 32 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
```
+8
View File
@@ -0,0 +1,8 @@
<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>
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# pack.sh — produce the ZB.MOM.WW.Theme NuGet packages into ./artifacts.
#
# Usage:
# ./build/pack.sh
set -euo pipefail
dotnet pack -c Release -o ./artifacts
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# push.sh — pack and push all ZB.MOM.WW.Theme NuGet packages to the Gitea feed.
#
# Required environment variables:
# GITEA_NUGET_SOURCE — full URL of the Gitea NuGet feed
# e.g. https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json
# GITEA_NUGET_KEY — Gitea access token with package:write permission
#
# Usage:
# export GITEA_NUGET_SOURCE="https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json"
# export GITEA_NUGET_KEY="your-gitea-token"
# ./build/push.sh
set -euo pipefail
: "${GITEA_NUGET_SOURCE:?set GITEA_NUGET_SOURCE to your Gitea NuGet feed URL}"
: "${GITEA_NUGET_KEY:?set GITEA_NUGET_KEY to your Gitea access token}"
dotnet pack -c Release -o ./artifacts
dotnet nuget push "./artifacts/*.nupkg" \
--source "$GITEA_NUGET_SOURCE" \
--api-key "$GITEA_NUGET_KEY" \
--skip-duplicate
@@ -0,0 +1,3 @@
namespace ZB.MOM.WW.Theme;
public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
@@ -0,0 +1,12 @@
@* Components/BrandBar.razor *@
@namespace ZB.MOM.WW.Theme
<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; }
}
@@ -0,0 +1,58 @@
@namespace ZB.MOM.WW.Theme
@* Components/LoginCard.razor — static form-POST sign-in card.
SECURITY NOTES:
- ReturnUrl is echoed into a hidden field verbatim; the consuming app's POST handler
MUST validate it is a local/relative URL before redirecting to prevent open-redirect.
- This form is NOT auto-protected by Blazor antiforgery; the caller MUST pass an
antiforgery token via ChildContent (e.g. <AntiforgeryToken />). *@
<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";
/// <summary>
/// Optional URL to redirect to after a successful login. Echoed into a hidden
/// <c>returnUrl</c> field. The consuming app's POST handler MUST validate this is
/// a local/relative URL before redirecting — do not redirect to arbitrary values
/// to prevent open-redirect vulnerabilities.
/// </summary>
[Parameter] public string? ReturnUrl { get; set; }
[Parameter] public string? Error { get; set; }
/// <summary>
/// Content rendered inside the form, before the username/password fields.
/// The caller MUST supply an antiforgery token here (e.g. <c>&lt;AntiforgeryToken /&gt;</c>)
/// because this static POST form is not auto-protected by Blazor's antiforgery middleware.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -0,0 +1,13 @@
@* Components/NavRailItem.razor *@
@namespace ZB.MOM.WW.Theme
<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;
}
@@ -0,0 +1,13 @@
@* Components/NavRailSection.razor — CSS-only collapsible (no JS, works in static SSR).
Apps that want cookie-persisted expand state keep their own interactive NavSection. *@
@namespace ZB.MOM.WW.Theme
<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; }
}
@@ -0,0 +1,17 @@
@* Components/StatusPill.razor *@
@namespace ZB.MOM.WW.Theme
<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",
};
}
@@ -0,0 +1,22 @@
@namespace ZB.MOM.WW.Theme
@* Components/TechButton.razor *@
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy">
@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>? AdditionalAttributes { get; set; }
private string VariantClass => Variant switch
{
ButtonVariant.Secondary => "btn-outline-secondary",
ButtonVariant.Danger => "btn-danger",
ButtonVariant.Ghost => "btn-link",
_ => "btn-primary",
};
}
@@ -0,0 +1,16 @@
@namespace ZB.MOM.WW.Theme
@* 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; }
}
@@ -0,0 +1,15 @@
@namespace ZB.MOM.WW.Theme
@* 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; }
}
@@ -0,0 +1,4 @@
@namespace ZB.MOM.WW.Theme
@* 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" />
@@ -0,0 +1,32 @@
@* Components/ThemeShell.razor — the one canonical side-rail chassis.
Not a LayoutComponentBase: the app's thin MainLayout delegates to this. *@
@namespace ZB.MOM.WW.Theme
<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}";
}
@@ -0,0 +1,3 @@
namespace ZB.MOM.WW.Theme;
public enum StatusState { Ok, Warn, Bad, Idle, Info }
@@ -0,0 +1,15 @@
<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>
@@ -0,0 +1,5 @@
@namespace ZB.MOM.WW.Theme
@using Microsoft.AspNetCore.Components
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using ZB.MOM.WW.Theme
@@ -0,0 +1,191 @@
/* ZB.MOM.WW.Theme — side-rail + login layout
Tokens live in theme.css; this sheet carries only layout + the side rail. */
/* ── App shell: side rail + page ─────────────────────────────────────────── */
/* The outer flex direction is supplied by Bootstrap utilities on the wrapper
(`d-flex flex-column flex-lg-row`) so the mobile hamburger row stacks above
the rail on <lg viewports and the rail sits beside the page on lg+. */
.app-shell {
align-items: stretch;
min-height: calc(100vh - 3.3rem);
}
.app-shell .page {
flex: 1;
min-width: 0;
}
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
width: 220px;
flex: 0 0 220px;
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 0.7rem;
background: var(--card);
border-right: 1px solid var(--rule-strong);
}
/* On lg+ keep the side rail pinned so it stays visible when content scrolls. */
@media (min-width: 992px) {
#theme-rail {
position: sticky;
top: 0;
height: 100vh;
align-self: flex-start;
z-index: 1020;
}
}
/* When the side rail is collapsed under <lg viewports the Bootstrap collapse
container removes the fixed width; restore full width on mobile. */
@media (max-width: 991.98px) {
.side-rail {
width: 100%;
min-width: 100%;
max-width: 100%;
height: auto;
}
}
/* Login card title. Replaces the panel-head top strip on the login page so the
card reads as a self-contained sign-in form, not a tabbed panel. */
.login-title {
margin: 0 0 1.1rem 0;
font-size: 1.05rem;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--ink);
}
/* Brand block pinned at the top of the side rail. Mirrors ScadaLink's
.sidebar .brand styling — used now that the top app-bar was dropped. */
.side-rail .brand {
color: var(--ink);
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: 1rem;
border-bottom: 1px solid var(--rule);
margin-bottom: 0.4rem;
}
.side-rail .brand .mark { color: var(--accent); }
.rail-eyebrow {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.3rem 0.6rem;
}
/* Collapsible variant — rendered by NavRailSection. Looks like .rail-eyebrow
plus a leading chevron; clicking flips chevron + expanded state. */
.rail-eyebrow-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
background: transparent;
border: 0;
text-align: left;
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.45rem 0.6rem 0.3rem;
cursor: pointer;
}
.rail-eyebrow-toggle:hover { color: var(--ink); }
.rail-eyebrow-chevron {
display: inline-block;
width: 0.7rem;
font-size: 0.55rem;
color: var(--ink-faint);
}
.rail-section-body {
display: flex;
flex-direction: column;
}
.rail-link {
display: block;
padding: 0.4rem 0.6rem;
border-radius: 4px;
border-left: 2px solid transparent;
font-size: 0.86rem;
color: var(--ink-soft);
}
.rail-link:hover {
background: #f3f6fd;
color: var(--ink);
text-decoration: none;
}
.rail-link.active {
background: #eef2fc;
border-left-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
/* ── Session block, pinned to the rail foot ──────────────────────────────── */
.rail-foot {
margin-top: auto;
padding-top: 0.6rem;
border-top: 1px solid var(--rule);
}
.rail-user {
display: block;
padding: 0 0.6rem;
font-weight: 600;
font-size: 0.88rem;
}
.rail-roles {
padding: 0.1rem 0.6rem 0.5rem;
font-family: var(--mono);
font-size: 0.72rem;
color: var(--ink-faint);
}
.rail-btn {
display: inline-block;
margin: 0 0.6rem;
padding: 0.3rem 0.7rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--ink-soft);
background: var(--card);
border: 1px solid var(--rule-strong);
border-radius: 4px;
cursor: pointer;
}
.rail-btn:hover {
border-color: var(--accent);
color: var(--accent);
text-decoration: none;
}
/* ── Login card centring ─────────────────────────────────────────────────── */
.login-wrap {
max-width: 380px;
margin: 3.5rem auto 0;
}
/* 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; }
@@ -0,0 +1,379 @@
/* ============================================================================
Technical-Light design system — portable theme layer
----------------------------------------------------------------------------
A refined technical-light aesthetic: warm-neutral paper, hairline rules,
IBM Plex type, monospace tabular numerics, status carried by colour. Built
to layer over Bootstrap 5 via --bs-* overrides, but every rule below works
standalone — Bootstrap is optional.
HOW TO ADOPT
1. Serve the three IBM Plex woff2 files (shipped in fonts/) and fix the
@font-face url() paths below to wherever you serve them.
2. Include this file once, globally. Add view-specific rules in a separate
stylesheet — never edit the token block per-view.
3. Status is colour, not iconography. Use the .s-* / .chip-* / .kv .v.*
helpers; do not hand-pick hex values in feature CSS.
========================================================================= */
/* ── Vendored fonts (embedded woff2, no network/CDN fetch) ───────────────────
Adjust these url()s to your asset route. If you cannot vendor the fonts the
--sans / --mono fallback stacks below degrade gracefully to system fonts. */
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal; font-weight: 400; font-display: swap;
src: url('../fonts/ibm-plex-sans-400.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Sans';
font-style: normal; font-weight: 600; font-display: swap;
src: url('../fonts/ibm-plex-sans-600.woff2') format('woff2');
}
@font-face {
font-family: 'IBM Plex Mono';
font-style: normal; font-weight: 500; font-display: swap;
src: url('../fonts/ibm-plex-mono-500.woff2') format('woff2');
}
/* ── Design tokens ───────────────────────────────────────────────────────────
The single source of truth. Re-theme by editing only this block. */
:root {
/* Surfaces & ink */
--paper: #f4f4f1; /* page background — warm off-white, never pure */
--card: #ffffff; /* raised surfaces: cards, bars, table heads */
--ink: #1b1d21; /* primary text */
--ink-soft: #5a6066; /* secondary text, labels */
--ink-faint: #8b9097; /* tertiary text, captions, units */
--rule: #e4e4df; /* hairline borders / row dividers */
--rule-strong: #d2d2cb; /* emphasised hairlines: bar underline, pills */
/* Accent */
--accent: #2f5fd0; /* links, sort arrows, primary actions */
--accent-deep: #1e3f99; /* hover / pressed accent, raw-value emphasis */
/* Status — foreground */
--ok: #2f9e44;
--warn: #e8920c;
--bad: #e03131;
--idle: #868e96;
/* Status — tinted backgrounds (pair with the matching foreground) */
--ok-bg: #e9f6ec;
--warn-bg: #fdf1dd;
--bad-bg: #fceaea;
--idle-bg: #eef0f2;
/* Type stacks — Plex first, graceful system fallback */
--mono: 'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace;
--sans: 'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
/* Bootstrap 5 overrides — harmless if Bootstrap is absent */
--bs-body-bg: var(--paper);
--bs-body-color: var(--ink);
--bs-body-font-family: var(--sans);
--bs-body-font-size: 0.9rem;
--bs-primary: var(--accent);
--bs-border-color: var(--rule);
--bs-emphasis-color: var(--ink);
}
/* ── Base ────────────────────────────────────────────────────────────────────
The faint top-right radial is the one deliberate flourish — a soft sheen,
not a gradient wash. Keep it subtle. */
body {
background:
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
var(--paper);
color: var(--ink);
font-family: var(--sans);
font-size: 0.9rem;
-webkit-font-smoothing: antialiased;
}
/* Any numeric / fixed-width text. Tabular figures so columns of digits align. */
.numeric,
.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-deep); text-decoration: underline; }
/* ── App chrome: top bar ─────────────────────────────────────────────────────
One bar across the top: brand, breadcrumb crumbs, a flex spacer, then meta
text and any status pill pushed hard right. */
.app-bar {
display: flex;
align-items: baseline;
gap: 1rem;
padding: 0.85rem 1.25rem;
background: var(--card);
border-bottom: 1px solid var(--rule-strong);
}
.app-bar .brand {
font-weight: 600;
font-size: 1.05rem;
letter-spacing: 0.02em;
}
.app-bar .brand .mark { color: var(--accent); } /* the one accent glyph */
.app-bar .crumb { color: var(--ink-faint); font-size: 0.85rem; }
.app-bar .spacer { flex: 1; } /* pushes meta/pill right */
.app-bar .meta {
font-family: var(--mono);
font-size: 0.78rem;
color: var(--ink-soft);
}
/* ── Connection / liveness pill ──────────────────────────────────────────────
A rounded pill with a dot, driven entirely by data-state. Use for any
live-link health indicator (websocket, SSE, polling). */
.conn-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--rule-strong);
color: var(--ink-soft);
background: var(--card);
}
.conn-pill .dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--idle);
}
.conn-pill[data-state="connected"] { color: var(--ok); border-color: #bfe3c6; background: var(--ok-bg); }
.conn-pill[data-state="connected"] .dot { background: var(--ok); }
.conn-pill[data-state="connecting"] { color: var(--warn); border-color: #f0d9ab; background: var(--warn-bg); }
.conn-pill[data-state="connecting"] .dot { background: var(--warn); animation: pulse 1.1s ease-in-out infinite; }
.conn-pill[data-state="disconnected"] { color: var(--bad); border-color: #f0c0c0; background: var(--bad-bg); }
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
/* ── Status text helpers ─────────────────────────────────────────────────────
Recolour a value in place — counts, ratios, error totals. */
.s-ok { color: var(--ok); }
.s-warn { color: var(--warn); }
.s-bad { color: var(--bad); }
.s-idle { color: var(--idle); }
/* ── State chip ──────────────────────────────────────────────────────────────
Compact rectangular badge for an enumerated state (bound/recovering/…).
Squarer than the pill; use the pill for liveness, the chip for state. */
.chip {
display: inline-block;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.15rem 0.5rem;
border-radius: 4px;
border: 1px solid transparent;
}
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: #c6e6cd; }
.chip-warn { color: #b56a00; background: var(--warn-bg); border-color: #efd6a6; }
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: #eec3c3; }
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
/* ── Panel — the base raised surface ─────────────────────────────────────────
A white card with a hairline border and 8px radius. .panel-head is the
uppercase eyebrow label that sits on top. */
.panel {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
}
.panel-head {
font-size: 0.74rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
/* ── Page wrapper ────────────────────────────────────────────────────────────
Centred, capped width, even gutter. */
.page { padding: 1.25rem; max-width: 1680px; margin: 0 auto; }
/* ── Reveal-on-paint ─────────────────────────────────────────────────────────
Add .rise to top-level sections; stagger with inline animation-delay
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; }
/* ════════════════════════════════════════════════════════════════════════════
COMPONENT LIBRARY
Generic, reusable pieces. View-specific layout belongs in a separate sheet.
════════════════════════════════════════════════════════════════════════════ */
/* ── KPI / aggregate cards ───────────────────────────────────────────────────
A responsive strip of headline numbers. .agg-card.alert / .caution tint the
whole card when a watched metric goes non-zero. */
.agg-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.75rem;
margin-bottom: 1rem;
}
@media (max-width: 1100px) { .agg-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 620px) { .agg-grid { grid-template-columns: repeat(2, 1fr); } }
.agg-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 0.7rem 0.9rem;
}
.agg-label {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--ink-faint);
}
.agg-value {
margin-top: 0.25rem;
font-size: 1.5rem;
font-weight: 600;
line-height: 1.1;
display: flex;
align-items: baseline;
gap: 0.35rem;
}
.agg-sub { /* trailing "/ 54", "ms" etc. — quieter */
font-size: 0.85rem;
font-weight: 400;
color: var(--ink-faint);
}
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
.agg-card.alert .agg-value { color: var(--bad); }
.agg-card.caution { border-color: #efd6a6; background: var(--warn-bg); }
.agg-card.caution .agg-value { color: #b56a00; }
/* ── Metric card + key/value rows ────────────────────────────────────────────
A .panel-head over a stack of .kv rows: label left, monospace value right.
Zebra striping on even rows. .v.warn / .v.bad / .v.ok recolour a value. */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
gap: 0.85rem;
margin-bottom: 1rem;
}
.metric-card {
background: var(--card);
border: 1px solid var(--rule);
border-radius: 8px;
overflow: hidden;
}
.metric-card .panel-head { margin: 0; }
.kv {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 1rem;
padding: 0.32rem 0.9rem;
font-size: 0.85rem;
}
.kv:nth-child(even) { background: #fbfbf9; }
.kv .k { color: var(--ink-soft); }
.kv .v {
font-family: var(--mono);
font-variant-numeric: tabular-nums;
text-align: right;
}
.kv .v.warn { color: var(--warn); }
.kv .v.bad { color: var(--bad); }
.kv .v.ok { color: var(--ok); }
/* ── Toolbar ─────────────────────────────────────────────────────────────────
Filter/search row that sits inside a .panel above a table. */
.toolbar {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid var(--rule);
}
.toolbar .spacer { flex: 1; }
.tb-search { max-width: 280px; }
.tb-state { max-width: 150px; }
.tb-check {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.82rem; color: var(--ink-soft); white-space: nowrap;
user-select: none;
}
.tb-count { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-faint); }
/* ── Data table ──────────────────────────────────────────────────────────────
Dense, hairline-ruled table. Uppercase sticky head on a faint fill; numeric
columns get .num (right-aligned, monospace). Rows are clickable by default —
drop the cursor/hover rules if yours are not. */
.table-wrap { overflow-x: auto; }
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.data-table th,
.data-table td {
padding: 0.45rem 0.8rem;
text-align: left;
white-space: nowrap;
border-bottom: 1px solid var(--rule);
}
.data-table th {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
background: #fbfbf9;
position: sticky;
top: 0;
}
.data-table th.num,
.data-table td.num { text-align: right; font-family: var(--mono); }
.data-table th.sortable { cursor: pointer; user-select: none; }
.data-table th.sortable:hover { color: var(--ink); }
.data-table th.sorted-asc::after { content: ' \2191'; color: var(--accent); }
.data-table th.sorted-desc::after { content: ' \2193'; color: var(--accent); }
.data-table tbody tr { cursor: pointer; transition: background 0.08s; }
.data-table tbody tr:hover { background: #f3f6fd; }
.data-table tbody tr:last-child td { border-bottom: none; }
.empty-row {
text-align: center !important;
color: var(--ink-faint);
padding: 1.6rem !important;
font-style: italic;
}
/* ── Direction / category tag ────────────────────────────────────────────────
Tiny inline tag for a per-row category (e.g. read vs write). */
.dir-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
.dir-write { color: #8a5a00; background: var(--warn-bg); }
/* ── Inline notice ───────────────────────────────────────────────────────────
A .panel with a warning tint — for "this thing is gone / degraded" banners. */
.notice {
padding: 0.85rem 1.1rem;
margin-bottom: 1rem;
color: #b56a00;
background: var(--warn-bg);
border-color: #efd6a6;
}
@@ -0,0 +1,22 @@
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"));
}
}
@@ -0,0 +1,59 @@
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 TechButton_not_busy_is_not_disabled()
{
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
Assert.False(cut.Find("button").HasAttribute("disabled"));
}
[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);
}
}
@@ -0,0 +1,44 @@
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]"));
}
}
@@ -0,0 +1,36 @@
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"));
}
[Fact]
public void NavRailSection_collapsed_when_not_expanded()
{
var cut = RenderComponent<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
}
}
@@ -0,0 +1,35 @@
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)));
}
@@ -0,0 +1,21 @@
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());
}
}
@@ -0,0 +1,13 @@
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);
}
}
@@ -0,0 +1,43 @@
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"));
}
}
@@ -0,0 +1,15 @@
<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>
@@ -0,0 +1,4 @@
global using Bunit;
global using Xunit;
global using Microsoft.AspNetCore.Components;
global using ZB.MOM.WW.Theme;
+1
View File
@@ -18,6 +18,7 @@ specs and analyses that *drive* changes made in the individual repos.
| Component | Status | Applies to | Goal | Folder |
|---|---|---|---|---|
| Auth (login / identity / authz) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Auth`) | [`auth/`](auth/) |
| UI Theme (layout / tokens / components) | Draft | OtOpcUa, MxAccessGateway, ScadaBridge | Path to shared code (`ZB.MOM.WW.Theme`) | [`ui-theme/`](ui-theme/) |
> Add a row when you start normalizing a new component. Status: `Draft` → `Reviewed` → `Adopting` → `Converged`.
+90
View File
@@ -0,0 +1,90 @@
# UI Theme — gaps & adoption backlog
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
---
## Divergence vs spec
### §1 Design tokens — `theme.css`
| Item | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Tokens identical to canonical | ✅ identical | ✅ identical | ✅ identical |
| File maintained in one place (RCL) | ⛔ own copy | ⛔ own copy | ⛔ own copy |
| Font path `url('../fonts/…')` | ⛔ `url('fonts/…')`**latent 404** | 🟡 `url('/fonts/…')` — absolute, not portable | ✅ `url('../fonts/…')` — correct |
| IBM Plex fonts in one place | ⛔ own `wwwroot/fonts/` | ⛔ own `wwwroot/fonts/` | ⛔ own `wwwroot/fonts/` |
**Gap T1:** All three apps maintain a copy of `theme.css` — the single-source guarantee
is broken today. Any token change must be applied in four places (three apps + the RCL)
once the RCL exists.
**Gap T2:** OtOpcUa `url('fonts/…')` is a latent 404 masked by system-font fallback.
Adoption fixes it automatically.
**Gap T3:** Each app vendors fonts — 3× duplication. The RCL eliminates it.
### §2 Typography
All three apps reference IBM Plex via the token stacks. No typography divergence — the
token values are identical. Gap is delivery (T3 above).
### §3 Canonical side-rail layout
| Item | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| `.app-shell` root element | ✅ `div.app-shell` | ⛔ `div.d-flex …` (no `.app-shell`) | ⛔ `div.d-flex …` (no `.app-shell`) |
| Rail CSS class | ✅ `.side-rail` | ⛔ `.sidebar` | ⛔ `.sidebar` |
| Nav item CSS class | ✅ `.rail-link` | ⛔ `.nav-link` | ⛔ `.nav-link` |
| Nav item element | ✅ `<a>` (NavLink) | ⛔ `<li><NavLink>` inside `<ul>` | ⛔ `<li><NavLink>` inside `<ul>` |
| Shell component | ⛔ bespoke `MainLayout` + `NavSidebar` | ⛔ combined `MainLayout` (210 lines) | ⛔ `MainLayout` + `NavMenu` |
| Thin-MainLayout pattern | ⛔ not yet | ⛔ not yet | ⛔ not yet |
**Gap L1:** OtOpcUa already uses the right CSS classes but the component structure
doesn't use `ThemeShell`. Low-risk migration.
**Gap L2:** MxAccessGateway and ScadaBridge use `.sidebar` / `.nav-link` / `<ul><li>`.
Migration requires class name changes throughout their nav markup and `site.css` sidebar
blocks. Medium (ScadaBridge) to high (MxGateway combined layout) risk.
### §4 Component contract
| Component | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| `StatusPill` (vs bespoke `StatusBadge`) | ⛔ `StatusBadge` (string CSS class) | ⛔ `StatusBadge` (string text → class) | ⛔ raw `.chip-*` classes inline |
| `LoginCard` | ⛔ inline markup in `Login.razor` | ⛔ no Blazor login page | ⛔ Bootstrap `.card` markup in `Login.razor` |
| `NavRailItem` / `NavRailSection` | ⛔ `NavLink` + `NavSection` (interactive) | ⛔ `NavLink`+`<li>` + `NavSection` | ⛔ `NavLink`+`<li>` + `NavSection` |
| `ThemeShell` / thin `MainLayout` | ⛔ not yet | ⛔ not yet | ⛔ not yet |
| `ThemeHead` | ⛔ manual `<link>` tags | ⛔ manual `<link>` tags | ⛔ manual `<link>` tags |
### §5 Delivery
| Item | OtOpcUa | MxAccessGateway | ScadaBridge |
|---|---|---|---|
| Asset via `_content/ZB.MOM.WW.Theme/…` | ⛔ `_content/…AdminUI/css/…` | ⛔ root-relative `/css/…` | ⛔ `_content/…CentralUI/css/…` |
| `<ThemeHead />` in `<head>` | ⛔ manual `<link>` tags | ⛔ manual `<link>` tags | ⛔ manual `<link>` tags |
---
## Adoption backlog (ordered)
| # | Item | Projects | Priority | Effort | Risk | Notes |
|---|---|---|---|---|---|---|
| 1 | Build `ZB.MOM.WW.Theme` RCL | scadaproj | High | M | Low | **DONE**`0.1.0` built + tested in this repo |
| 2 | Adopt in OtOpcUa AdminUI | OtOpcUa | High | S | Low | Already rail; fix latent font 404; cookie nav-state optional retain |
| 3 | Adopt in ScadaBridge CentralUI + Host | ScadaBridge | Med | M | Med | Sidebar class migration + `MainLayout` replace; scoped `.razor.css` unchanged |
| 4 | Adopt in MxAccessGateway Dashboard | MxAccessGateway | Low | L | High | Combined `MainLayout` migration; sidebar idiom change; largest UX-visible change — verify visually |
**Sequencing:** #2 first (lowest risk, validates the adoption pattern); #3 next (medium
effort, no design change); #4 last (highest risk — verify dashboard UX thoroughly before
merging). Each adoption is a per-repo PR, independent.
---
## Open questions
- **MxGateway login:** No Blazor login page today. If one is added during adoption (#4),
use `<LoginCard>`. If the server-redirect pattern is kept, `<LoginCard>` is not needed.
- **OtOpcUa cookie nav state:** Decide whether to retain `otopcua_nav` cookie persistence
(keep bespoke interactive `NavSection` alongside `ThemeShell`'s `Nav` slot) or drop it
(CSS-only `NavRailSection` replaces it, losing expand-state persistence across page loads).
- **ScadaBridge `AuthorizeView` policy gating in nav:** Verify `<NavRailSection>` inside
`<AuthorizeView>` renders + hides correctly with the canonical SSR rendering model.
+45
View File
@@ -0,0 +1,45 @@
# UI Theme (layout / tokens / components)
Second normalized component. **Goal: path to shared code** — converge the three sister
projects onto a common "Technical-Light" design system, realized as the `ZB.MOM.WW.Theme`
Razor Class Library.
- The one target: [`spec/SPEC.md`](spec/SPEC.md)
- Design tokens reference: [`spec/DESIGN-TOKENS.md`](spec/DESIGN-TOKENS.md)
- The shared library: [`shared-contract/ZB.MOM.WW.Theme.md`](shared-contract/ZB.MOM.WW.Theme.md)
- Divergences + backlog: [`GAPS.md`](GAPS.md)
- Current state, per project: [`current-state/`](current-state/)
## Why UI theme is a strong candidate
All three sister apps share a Blazor SSR + Bootstrap 5 UI stack and each ships a
hand-copied **379-line `theme.css`** (the "Technical-Light" design system: IBM Plex
`@font-face`, `:root` design tokens, status palette, typography helpers). **The three
copies are byte-for-byte identical except for three lines** — the `@font-face` `src:`
URL prefix differs per app deployment convention. IBM Plex `.woff2` fonts are likewise
vendored three times into each app's `wwwroot/fonts/`. This is the textbook drift
situation: a shared design system already beginning to diverge, with a latent font-path
bug in one app (OtOpcUa) that goes unnoticed because browsers fall back to system fonts.
## Status by project
| Project | Surface | Layout today | Adoption status |
|---|---|---|---|
| **OtOpcUa** | `ZB.MOM.WW.OtOpcUa.AdminUI` | Side rail (`NavSidebar.razor`) + `theme.css` + IBM Plex | Not started |
| **MxAccessGateway** | `ZB.MOM.WW.MxGateway.Server` Dashboard | Sidebar (`nav.sidebar`) + `theme.css` + IBM Plex | Not started |
| **ScadaBridge** | `ZB.MOM.WW.ScadaBridge.Host` + `.CentralUI` (RCL) | Own `MainLayout` + `NavMenu` (`nav.sidebar`) + `theme.css` + IBM Plex | Not started |
See each project's [`current-state/<project>/CURRENT-STATE.md`](current-state/) for the
code-verified detail and its adoption plan.
## Normalized vs. left per-project
**Normalized (extracted into the RCL `ZB.MOM.WW.Theme`):** design tokens + IBM Plex
fonts, the canonical side-rail shell (`ThemeShell` + `BrandBar` + `NavRailItem` +
`NavRailSection`), and the four widgets (`StatusPill`, `LoginCard`, `TechButton`,
`TechCard`, `TechField`). One RCL, one package, one version.
**Left per-project (NOT extracted):** each app's `site.css` residual page layout, its
page/route content, and app-specific scoped `.razor.css` (e.g. ScadaBridge's
`MultiSelectDropdown`, `TreeView`, `Audit/*`). The kit owns the *chrome and tokens*, not
the app's domain screens.
@@ -0,0 +1,161 @@
# UI Theme — current state: MxAccessGateway
Repo: `~/Desktop/MxAccessGateway` (Gitea `mxaccessgw`). Stack: .NET 10, Blazor SSR
(gateway x64) — UI in `src/ZB.MOM.WW.MxGateway.Server/`.
All paths below are relative to the repo root. Verified against source on 2026-06-01.
**Summary:** MxAccessGateway uses a sidebar nav layout and the Technical-Light tokens, but
the sidebar uses Bootstrap `.sidebar` / `.nav-link` classes rather than the canonical
`.side-rail` / `.rail-link` classes, and the overall structure diverges from the spec
target. Adoption has the **highest effort and risk** of the three apps — the shell
requires migration from its current sidebar idiom to the canonical `ThemeShell` pattern.
There is no dedicated login page (authentication gate is integrated into the Dashboard).
---
## 1. CSS / design tokens
**`theme.css`** — 379-line hand copy of the Technical-Light design system. Identical in
content to OtOpcUa's and ScadaBridge's copies except for the font-path prefix.
- Path: `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/theme.css`
- Font path: `url('/fonts/ibm-plex-sans-400.woff2')` (lines 24, 29, 34)
- Absolute path (`/fonts/…`) is technically correct (resolves from root of the app), but
differs from the canonical `url('../fonts/…')` in the RCL — a deployment path difference,
not a loading bug.
- Wired in `App.razor` line 6: `<link rel="stylesheet" href="/css/theme.css" />`.
**`site.css`** — 592 lines of per-app page layout and Dashboard component styling.
- Path: `src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/site.css`
- Wired in `App.razor` line 7: `<link rel="stylesheet" href="/css/site.css" />`.
- Contains: `.sidebar` layout (lines ~2495), `.dashboard-body`, `.agg-card`, table
styles, metric cards, event/alarm grids, and other domain-specific rules.
- After adoption: the `.sidebar` layout section is superseded by RCL `layout.css`.
The domain-specific table/card/grid rules stay in `site.css`.
Note: MxGateway's `App.razor` loads assets from `/css/…` and `/fonts/…` (root-relative
paths to `wwwroot/`), not via `_content/…` static-web-asset paths — contrast with
OtOpcUa and ScadaBridge which use the RCL `_content/` mechanism.
---
## 2. IBM Plex fonts
Three `.woff2` files vendored into:
`src/ZB.MOM.WW.MxGateway.Server/wwwroot/fonts/`
- `ibm-plex-sans-400.woff2`
- `ibm-plex-sans-600.woff2`
- `ibm-plex-mono-500.woff2`
After adoption: delete all three; the RCL serves them from
`_content/ZB.MOM.WW.Theme/fonts/`.
---
## 3. Layout shell
**`MainLayout.razor`** — 210-line combined layout + nav component. `@implements
IDisposable`; `@inject NavigationManager`, `@inject IJSRuntime`. Interactive
(`@rendermode InteractiveServer` inherited from `Routes`).
- Path: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/MainLayout.razor`
- Root element: `<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">`.
Note: **no `.app-shell` class** (unlike OtOpcUa and the spec target).
- Brand: `<a class="brand" href="/"><span class="mark">&#9646;</span> MXAccess Gateway</a>`
(line 24).
- Nav structure: `<nav class="sidebar d-flex flex-column">` with `<ul class="nav flex-column">`
and `<NavSection>` groups ("Runtime", "Galaxy", "Admin", "Configuration") with
`<NavLink class="nav-link">` children (not `.rail-link`).
- No dedicated `RailFooter` / session block (auth state shown elsewhere or via API keys).
- Nav state persisted via JS (`nav-state.js`), same pattern as OtOpcUa.
**`NavSection.razor`** — 40-line component using `EventCallback OnToggle` + JS collapse.
- Path: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Layout/NavSection.razor`
- Structure: `<li class="nav-item"><button class="nav-section-toggle" @onclick="OnToggle">`.
Uses Bootstrap-style `<ul>/<li>` nav items, not `.rail-link` anchor style.
---
## 4. Login / auth surface
MxAccessGateway has **no dedicated Blazor login page**. There is no `Login.razor`. The
dashboard is protected by ASP.NET Core cookie authentication; login is handled via an
ASP.NET Minimal API auth endpoint (outside the Blazor component tree). The `MainLayout`
includes a "Sign In" link (`<a href="/login" class="btn …">Sign In</a>` line 87) that
redirects to the server endpoint. The `<LoginCard>` component is not applicable until
a Blazor login page is added.
---
## 5. StatusBadge component
**`StatusBadge.razor`** — string-matchbased chip component.
- Path: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Shared/StatusBadge.razor`
- Parameters: `string? Text`. Maps known strings ("Ready", "Healthy", "Active", "Faulted",
etc.) to `chip-ok` / `chip-warn` / `chip-bad` / `chip-idle` CSS classes.
- After adoption: the string-matching logic is app-specific (based on gateway session
state strings). Migration to `StatusPill` requires the caller to map gateway state
strings to `StatusState` values, then pass `State` instead of `Text`.
---
## 6. Divergences from spec
| Item | Current state | Spec |
|---|---|---|
| `theme.css` | Hand copy, 379 lines | Single canonical copy in RCL |
| Font-path `url()` | `url('/fonts/…')` (absolute, not a bug but non-canonical) | `url('../fonts/…')` |
| IBM Plex fonts | Vendored 3× in `wwwroot/fonts/` | Single copy in RCL `wwwroot/fonts/` |
| Asset wiring | Root-relative `/css/…`, `/fonts/…` | `_content/ZB.MOM.WW.Theme/…` |
| Shell class | `d-flex …` (no `.app-shell`) | `ThemeShell` + `.app-shell` |
| Nav class idiom | `.sidebar` + `.nav-link` + `<ul>/<li>` | `.side-rail` + `.rail-link` + `<a>` |
| Nav items | `<NavLink class="nav-link">` inside `<li>` | `<NavRailItem>` |
| Nav sections | `NavSection` (button `OnToggle` + JS) | `NavRailSection` (`<details>`, CSS-only) |
| Status chip | `StatusBadge` (string text → CSS class) | `StatusPill` (`StatusState` enum) |
| Login page | None — server endpoint redirect only | `<LoginCard>` (if a Blazor login page is added) |
---
## 7. Adoption plan
**Effort: High. Risk: High.** The sidebar idiom (`nav.sidebar` + `.nav-link` + `<ul><li>`)
differs from the canonical rail idiom (`.side-rail` + `.rail-link` + `<a>`), requiring a
CSS and markup migration. No layout redesign (it already uses a side-panel pattern), but
class names, element structure, and the `site.css` sidebar block all change.
**Steps:**
1. **Delete copies.** Remove `wwwroot/css/theme.css` and `wwwroot/fonts/ibm-plex-*.woff2`
from `src/ZB.MOM.WW.MxGateway.Server/`.
2. **Reference RCL.** Add `<PackageReference Include="ZB.MOM.WW.Theme" />` to
`ZB.MOM.WW.MxGateway.Server.csproj`. Add `@using ZB.MOM.WW.Theme` to `_Imports.razor`.
3. **Wire `ThemeHead`.** In `App.razor` replace `/css/theme.css` link with `<ThemeHead />`.
Keep `/css/site.css` for domain-specific rules. Also change static asset paths from
root-relative to `_content/ZB.MOM.WW.Theme/…` for fonts if any remain in `site.css`.
4. **Replace `MainLayout`.** Replace the 210-line `MainLayout.razor` with a thin wrapper
around `<ThemeShell Product="MXAccess Gateway">`. Carry the nav sections and the sign-in
link into the `Nav` and `RailFooter` slots respectively.
5. **Port nav items.** Migrate from `<NavLink class="nav-link">` inside `<li class="nav-item">`
to `<NavRailItem Href="…" Text="…">`. The four section groups ("Runtime", "Galaxy",
"Admin", "Configuration") map to `<NavRailSection Title="…">` children.
6. **Clean `site.css`.** Remove the `.sidebar` layout block (lines ~2495) — superseded by
`layout.css`. Keep all dashboard/domain-specific rules (`.agg-card`, tables, metric
cards, etc.).
7. **Replace `StatusBadge`.** Add a helper that maps gateway session-state strings to
`StatusState` values; replace `<StatusBadge Text="…">` call sites with
`<StatusPill State="…">`. Delete `StatusBadge.razor`.
8. **Login card (optional).** No Blazor login page exists today. If one is added,
`<LoginCard>` is the canonical implementation.
9. **Keep:** domain-specific `site.css` rules, scoped `.razor.css` files (none currently),
API-key authentication, all page components.
**Flagged risk:** This is the largest UX-visible change across the three apps. The sidebar
class migration (`.sidebar``.side-rail`, `.nav-link``.rail-link`) will visually
change the nav styling. Verify visually in the dashboard before merging. The nav expand
state persistence (JS-based) must be verified or replaced with CSS-only `<details>`.
@@ -0,0 +1,163 @@
# UI Theme — current state: OtOpcUa
Repo: `~/Desktop/OtOpcUa` (Gitea `lmxopcua`). Stack: .NET 10, Blazor SSR.
UI surface: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/` (Razor Class Library).
All paths below are relative to the repo root. Verified against source on 2026-06-01.
**Summary:** OtOpcUa already uses a side-rail layout and the full Technical-Light token
set. Adoption is **lowest effort** of the three apps — the shell shape already matches the
canonical target. The one bug fixed by adoption: a latent font-path 404 that silently
falls back to system fonts today.
---
## 1. CSS / design tokens
**`theme.css`** — 379-line hand copy of the Technical-Light design system.
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/theme.css`
- Font path: `url('fonts/ibm-plex-sans-400.woff2')` (lines 24, 29, 34)
- **Bug:** the path is relative to the CSS file location (`wwwroot/css/`), so it resolves
as `wwwroot/css/fonts/…` — a 404. The browser silently falls back to system fonts. The
canonical RCL path `url('../fonts/…')` fixes this permanently.
- Wired in `App.razor` line 17:
`<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/theme.css"/>`.
**`site.css`** — 174 lines of per-app page layout (side-rail shell, login card layout,
page body padding, miscellaneous overrides).
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/site.css`
- Wired in `App.razor` line 18:
`<link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/site.css"/>`.
- After adoption: the `.side-rail`, `.rail-*`, `.login-wrap`, `.login-title` rules are
superseded by the RCL's `layout.css`. The page-layout residuals (body padding, page-
specific overrides) stay in `site.css`.
---
## 2. IBM Plex fonts
Three `.woff2` files vendored into:
`src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/fonts/`
- `ibm-plex-sans-400.woff2`
- `ibm-plex-sans-600.woff2`
- `ibm-plex-mono-500.woff2`
After adoption: delete all three; the RCL serves them from
`_content/ZB.MOM.WW.Theme/fonts/`.
---
## 3. Layout shell
**`MainLayout.razor`** — 28-line static layout (no `@rendermode`). Renders `.app-shell`
flex row, hamburger toggle, `<NavSidebar/>` inside a Bootstrap collapse div, and
`<main class="page">`.
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/MainLayout.razor`
- Structure: `.app-shell d-flex flex-column flex-lg-row` (line 8), hamburger (lines 1118),
`<div class="collapse d-lg-block" id="sidebar-collapse">` (line 21), `<NavSidebar />` (line 22),
`<main class="page">@Body</main>` (lines 2527).
**`NavSidebar.razor`** — 160-line interactive (`@rendermode InteractiveServer`) sidebar.
Hosts the collapsible `NavSection` groups and cookie-persisted expand state.
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSidebar.razor`
- Brand: `<div class="brand"><span class="mark">&#9646;</span> OtOpcUa</div>` (lines 1414).
- Nav sections: two `NavSection` groups ("Navigation", "Scripting", "Live", "Config")
with `<NavLink class="rail-link">` children.
- Rail foot (lines 4462): `<div class="rail-foot"><AuthorizeView>` — session info + sign-out
`<form method="post" action="/auth/logout">`.
- Nav expand state persisted in `otopcua_nav` cookie via
`wwwroot/js/nav-state.js` (cookie: `otopcua_nav=<comma-separated ids>`).
**`NavSection.razor`** — 36-line `NavSection` component (interactive; uses `EventCallback`
`OnToggle` for expand/collapse, not CSS `<details>`).
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/NavSection.razor`
**`LoginLayout.razor`** — plain layout (no sidebar) used by the login page.
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Layout/LoginLayout.razor`
---
## 4. Login page
**`Login.razor`** — 50-line static login page. Uses `@layout LoginLayout`,
`@attribute [AllowAnonymous]`.
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Login.razor`
- Form: `<form method="post" action="/auth/login" data-enhance="false">` (line 21).
- Hidden `returnUrl` input (line 2225), username/password inputs, error notice panel.
- The form structure exactly matches what `<LoginCard>` emits; migration is direct.
---
## 5. StatusBadge component
**`StatusBadge.razor`** — thin wrapper over `.chip` classes.
- Path: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/StatusBadge.razor`
- Parameters: `string Text`, `string CssClass` (default `chip-idle`).
- After adoption: replaced by `<StatusPill State="…">` — caller maps state to `StatusState`
enum rather than passing CSS class strings directly.
---
## 6. Divergences from spec
| Item | Current state | Spec |
|---|---|---|
| `theme.css` | Hand copy, 379 lines | Single canonical copy in RCL |
| Font-path `url()` | `url('fonts/…')`**latent 404** | `url('../fonts/…')` — correct |
| IBM Plex fonts | Vendored 3× in `wwwroot/fonts/` | Single copy in RCL `wwwroot/fonts/` |
| Shell layout | `.app-shell` + `NavSidebar` component (matches target shape) | `ThemeShell` + thin `MainLayout` |
| Nav items | `<NavLink class="rail-link">` inside interactive `NavSidebar` | `NavRailItem` inside `NavRailSection` |
| Nav expand state | Cookie-persisted via `otopcua_nav` + JS | CSS-only `<details>` in `NavRailSection` |
| Status chip | `StatusBadge` (string CSS class param) | `StatusPill` (`StatusState` enum param) |
| Login card | Inline markup in `Login.razor` | `<LoginCard>` |
---
## 7. Adoption plan
**Effort: Low.** The shell shape already matches the target. No layout migration needed.
**Steps:**
1. **Delete copies.** Remove `wwwroot/css/theme.css` and `wwwroot/fonts/ibm-plex-*.woff2`
from `ZB.MOM.WW.OtOpcUa.AdminUI`. This also fixes the latent font-path 404.
2. **Reference RCL.** Add `<PackageReference Include="ZB.MOM.WW.Theme" />` to
`ZB.MOM.WW.OtOpcUa.AdminUI.csproj`. Add `@using ZB.MOM.WW.Theme` to `_Imports.razor`.
3. **Wire `ThemeHead`.** In `App.razor` replace lines 1718:
```diff
- <link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/theme.css"/>
- <link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/site.css"/>
+ <ThemeHead />
+ <link rel="stylesheet" href="_content/ZB.MOM.WW.OtOpcUa.AdminUI/css/site.css"/>
```
(Keep `site.css` for the page-layout residuals.)
4. **Replace `MainLayout`.** Delete the current 28-line `MainLayout.razor`. Create a new
thin `MainLayout.razor` that delegates to `<ThemeShell Product="OtOpcUa Admin">` with
`Nav` and `RailFooter` slots (carry the session/sign-out block from `NavSidebar`'s
`.rail-foot` into `RailFooter`).
5. **Port nav.** Rebuild the `Nav` slot using `<NavRailSection>` + `<NavRailItem>`. The
four section groups ("Navigation", "Scripting", "Live", "Config") map directly to
`NavRailSection Title="…"` with `NavRailItem` children.
**Cookie nav state:** OtOpcUa's `otopcua_nav` cookie persistence requires JS and an
`InteractiveServer` component. If this feature is retained, keep a bespoke interactive
`NavSection` (the current `NavSection.razor` or a refactored version) alongside — it
is compatible with `ThemeShell`'s `Nav` slot. If cookie persistence is acceptable to
drop, `NavRailSection` (CSS-only `<details>`) is a drop-in replacement.
6. **Replace `StatusBadge`.** Find all usages of `<StatusBadge CssClass="chip-*">` and
replace with `<StatusPill State="StatusState.*">`. Delete `StatusBadge.razor`.
7. **Replace login card.** In `Login.razor`, replace the inline `<div class="login-wrap">
… </div>` block with `<LoginCard Product="OtOpcUa Admin" Action="/auth/login"
ReturnUrl="@ReturnUrl" Error="@Error"><AntiforgeryToken /></LoginCard>`. The code-behind
(`Error` / `ReturnUrl` supply-from-query properties) stays unchanged.
8. **Keep:** `site.css` page-layout residuals; scoped `.razor.css` files (none currently in
AdminUI); `LoginLayout.razor`; auth endpoints; all page components.
**Risk: Low** — layout shape already matches, no top-bar migration. Cookie nav state is
the only optional complexity (decide retain vs drop).
@@ -0,0 +1,165 @@
# UI Theme — current state: ScadaBridge
Repo: `~/Desktop/ScadaBridge`. Stack: .NET 10, Blazor SSR (Akka.NET cluster + central UI).
UI surfaces: `src/ZB.MOM.WW.ScadaBridge.CentralUI/` (RCL) and
`src/ZB.MOM.WW.ScadaBridge.Host/` (the Blazor host that references it).
All paths below are relative to the repo root. Verified against source on 2026-06-01.
**Summary:** ScadaBridge uses a sidebar nav layout and the Technical-Light tokens, with the
correct font-path prefix. The sidebar uses `.sidebar` / `.nav-link` classes (same idiom as
MxGateway), not `.side-rail` / `.rail-link`. Adoption is **medium effort** — sidebar-class
migration + `MainLayout` replacement, no layout redesign. ScadaBridge has several
scoped `.razor.css` files that stay per-project.
---
## 1. CSS / design tokens
**`theme.css`** — 379-line hand copy of the Technical-Light design system.
- Path: `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/theme.css`
- Font path: `url('../fonts/ibm-plex-sans-400.woff2')` (lines 24, 29, 34)
- **Correct path** — resolves from `wwwroot/css/` to `wwwroot/fonts/` without 404. This is
the canonical `url('../fonts/…')` that the RCL also uses.
- Wired in the Host's `App.razor` line 9:
`<link href="_content/ZB.MOM.WW.ScadaBridge.CentralUI/css/theme.css" rel="stylesheet" />`.
**`site.css`** — 128 lines of per-app page layout (sidebar shell, nav overrides).
- Path: `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css`
- Wired in the Host's `App.razor` line 11:
`<link href="_content/ZB.MOM.WW.ScadaBridge.CentralUI/css/site.css" rel="stylesheet" />`.
- Contains: `.sidebar` layout block (~495), Bootstrap-icons integration for nav items.
- After adoption: the `.sidebar` layout section is superseded by RCL `layout.css`. The
remaining rules (Bootstrap-icons, misc overrides) stay in `site.css`.
Note: ScadaBridge uses the `_content/ZB.MOM.WW.ScadaBridge.CentralUI/…` static-web-asset
path for its own CentralUI RCL assets — the same mechanism `ZB.MOM.WW.Theme` will use.
---
## 2. IBM Plex fonts
Three `.woff2` files vendored into:
`src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/fonts/`
- `ibm-plex-sans-400.woff2`
- `ibm-plex-sans-600.woff2`
- `ibm-plex-mono-500.woff2`
After adoption: delete all three from `CentralUI/wwwroot/fonts/`; the RCL serves them
from `_content/ZB.MOM.WW.Theme/fonts/`.
---
## 3. Layout shell
**`MainLayout.razor`** — 29-line static layout. `@inherits LayoutComponentBase`. No
`@rendermode` directive (static SSR).
- Path: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor`
- Root element: `<div class="d-flex flex-column flex-lg-row" style="min-height: 100vh;">`.
No `.app-shell` class.
- Renders `<NavMenu />` inside a Bootstrap collapse div, `<main class="flex-grow-1 p-3">`,
plus `<DialogHost />` and `<SessionExpiry />` at the bottom.
**`NavMenu.razor`** — 200+ line interactive sidebar component. `@implements IDisposable`.
- Path: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavMenu.razor`
- Brand: `<div class="brand"><span class="mark">&#9646;</span> ScadaBridge</div>` (lines ~99).
- Nav structure: `<nav class="sidebar d-flex flex-column">` with `<ul class="nav flex-column">`
and `<NavSection>` groups ("Admin", "Data", "Audit", etc.) with `<NavLink class="nav-link">`
children. Uses `AuthorizeView` + `AuthorizeView Policy="…"` to gate admin sections.
- Nav state: JS-based expand-state persistence (same pattern as OtOpcUa and MxGateway).
**`NavSection.razor`** (same name as OtOpcUa/MxGateway, independent per-project copy).
- Path: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/NavSection.razor`
---
## 4. Login page
**`Login.razor`** — 36-line static login page. `@layout LoginLayout`, `@attribute [AllowAnonymous]`.
- Path: `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Login.razor`
- Form: `<form method="post" action="/auth/login" data-enhance="false">` (line 16).
- Uses Bootstrap `.card` / `.card-body` markup — **not** the Technical-Light `.panel` /
`.login-wrap` idiom used in OtOpcUa. Does not use a `<LoginCard>` yet.
- Error notice: Bootstrap `.alert alert-danger` (line 12) rather than `.panel.notice`.
---
## 5. Scoped `.razor.css` files (stays per-project)
ScadaBridge ships several component-scoped CSS files. These are **not shared** and stay
in the CentralUI RCL after adoption:
| File | Path |
|---|---|
| `MultiSelectDropdown.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/` |
| `TreeView.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/` |
| `AuditDrilldownDrawer.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/` |
| `AuditEventDetail.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/` |
| `ExecutionDetailModal.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/` |
| `ExecutionTree.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/` |
| `AuditResultsGrid.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/` |
| `NodeBrowserDialog.razor.css` | `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Dialogs/` |
These scoped styles are component-specific overrides and are unaffected by theme adoption.
---
## 6. Divergences from spec
| Item | Current state | Spec |
|---|---|---|
| `theme.css` | Hand copy, 379 lines | Single canonical copy in RCL |
| Font-path `url()` | `url('../fonts/…')`**correct** | `url('../fonts/…')` — same |
| IBM Plex fonts | Vendored 3× in `wwwroot/fonts/` | Single copy in RCL `wwwroot/fonts/` |
| Shell class | `d-flex …` (no `.app-shell`) | `ThemeShell` + `.app-shell` |
| Nav class idiom | `.sidebar` + `.nav-link` + `<ul>/<li>` | `.side-rail` + `.rail-link` + `<a>` |
| Nav items | `<NavLink class="nav-link">` inside `<li>` | `<NavRailItem>` |
| Nav sections | `NavSection` (`EventCallback OnToggle` + JS) | `NavRailSection` (`<details>`, CSS-only) |
| Status chip | None (uses raw `.chip-*` classes inline) | `StatusPill` (`StatusState` enum) |
| Login card | Bootstrap `.card` markup (not Technical-Light `.panel`) | `<LoginCard>` |
| Scoped `.razor.css` | 8 component-scoped files | Stays per-project (no change) |
---
## 7. Adoption plan
**Effort: Medium. Risk: Medium.** The font path is already correct so no 404 fix needed.
The sidebar idiom migration (`.sidebar``.side-rail`, `.nav-link``.rail-link`) and the
`MainLayout` replacement are the main work. The scoped `.razor.css` files are unaffected.
**Steps:**
1. **Delete copies.** Remove `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/theme.css`
and `wwwroot/fonts/ibm-plex-*.woff2` from `CentralUI`.
2. **Reference RCL.** Add `<PackageReference Include="ZB.MOM.WW.Theme" />` to
`ZB.MOM.WW.ScadaBridge.CentralUI.csproj`. Add `@using ZB.MOM.WW.Theme` to
`CentralUI`'s `_Imports.razor`.
3. **Wire `ThemeHead`.** In `Host`'s `App.razor`, replace line 9
(`<link href="_content/…/css/theme.css">`) with `<ThemeHead />` (which now resolves
via `ZB.MOM.WW.Theme`). Keep the `site.css` link on line 11.
4. **Replace `MainLayout`.** Replace the 29-line `MainLayout.razor` with a thin wrapper
around `<ThemeShell Product="ScadaBridge">`. Carry `<NavMenu />` into the `Nav` slot
(or replace it — see step 5). Keep `<DialogHost />` and `<SessionExpiry />` below the
`ThemeShell` or inside `ChildContent` as needed.
5. **Port nav.** Migrate `NavMenu.razor` from `nav.sidebar` + `<ul>/<li>` + `.nav-link`
to `<NavRailSection>` + `<NavRailItem>`. The `AuthorizeView` policy gating on admin
sections stays — wrap `<NavRailSection>` inside the appropriate `<AuthorizeView>` just
as today.
6. **Clean `site.css`.** Remove the `.sidebar` layout block. Keep Bootstrap-icons
integration and any domain-specific overrides.
7. **Replace login card.** In `Login.razor`, replace the Bootstrap `.card`/`.card-body`
markup with `<LoginCard Product="ScadaBridge" Action="/auth/login" ReturnUrl="@ReturnUrl"
Error="@ErrorMessage"><AntiforgeryToken /></LoginCard>`. Align error display with the
Technical-Light `.panel.notice` style from `LoginCard`.
8. **Keep:** all scoped `.razor.css` files (8 files listed above); `site.css` domain rules;
auth and session endpoints; all page components.
**Risk note:** ScadaBridge's `AuthorizeView` policy-gated nav sections require careful
testing — verify that `<NavRailSection>` inside `<AuthorizeView>` renders correctly and
that the section is fully hidden when the policy fails (not just collapsed).
@@ -0,0 +1,244 @@
# Shared library: `ZB.MOM.WW.Theme`
**Status: Built (`0.1.0`).** The RCL lives at
[`scadaproj/ZB.MOM.WW.Theme/`](../../../ZB.MOM.WW.Theme/) — built and tested. Adoption
by the three apps is follow-on, tracked in [`../GAPS.md`](../GAPS.md). Realizes
[`../spec/SPEC.md`](../spec/SPEC.md).
---
## Package
One NuGet package — unlike `ZB.MOM.WW.Auth`'s four-package split, there are no
tokens-only or components-only consumers; all three apps consume the full kit.
| Package | Target | Notes |
|---|---|---|
| `ZB.MOM.WW.Theme` | `net10.0` Razor Class Library | Tokens + fonts + layout CSS + all components |
Published to the Gitea NuGet feed; `Version 0.1.0`. SemVer — token changes are
breaking (major bump). Build from `scadaproj/ZB.MOM.WW.Theme/`:
```bash
dotnet build -c Release # 0 warnings (TreatWarningsAsErrors)
dotnet test # 32 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
```
---
## Consumer matrix
All three apps consume the single RCL. No optional packages.
| Consumer | Surface | Consumes |
|---|---|---|
| **OtOpcUa** `ZB.MOM.WW.OtOpcUa.AdminUI` | Admin UI (Blazor SSR, side rail) | `ZB.MOM.WW.Theme` |
| **MxAccessGateway** `ZB.MOM.WW.MxGateway.Server` | Dashboard (Blazor SSR) | `ZB.MOM.WW.Theme` |
| **ScadaBridge** `ZB.MOM.WW.ScadaBridge.Host` + `ZB.MOM.WW.ScadaBridge.CentralUI` | Central UI (Blazor SSR) | `ZB.MOM.WW.Theme` |
---
## Static assets
Served at `_content/ZB.MOM.WW.Theme/…` by ASP.NET's static-web-asset pipeline.
| Path | Contents |
|---|---|
| `css/theme.css` | Design tokens, typography, Bootstrap 5 overrides (379 lines) |
| `css/layout.css` | Side-rail shell layout, collapsible nav CSS, `StatusPill` variants, `TechCard`/`TechField` helpers |
| `fonts/ibm-plex-sans-400.woff2` | IBM Plex Sans Regular — vendored, no CDN |
| `fonts/ibm-plex-sans-600.woff2` | IBM Plex Sans SemiBold — vendored, no CDN |
| `fonts/ibm-plex-mono-500.woff2` | IBM Plex Mono Medium — vendored, no CDN |
`theme.css` uses `url('../fonts/ibm-plex-*.woff2')` — the correct relative path from
`css/` to `fonts/` in the static-web-asset tree.
---
## Component API
Namespace: `ZB.MOM.WW.Theme`. All components live in this flat namespace; one
`@using ZB.MOM.WW.Theme` in `_Imports.razor` covers everything.
### `ThemeHead`
Emits `<link>` tags for `theme.css` and `layout.css`. No parameters.
```razor
<ThemeHead />
```
Place in `App.razor` `<head>` **after** the app's Bootstrap link.
---
### `ThemeShell`
Canonical side-rail chassis. **Not a `LayoutComponentBase`** — delegated to from the app's
thin `MainLayout`. The `Accent` parameter overrides `--accent` for the shell subtree.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Product` | `string` | Yes | — | Product name rendered in `BrandBar` |
| `Accent` | `string?` | No | `null` | Override `--accent` for this app (e.g. `#2f855a`) |
| `Logo` | `RenderFragment?` | No | `null` | Custom logo; replaces default `▐` glyph |
| `Nav` | `RenderFragment?` | No | `null` | Rail nav items (`NavRailSection` / `NavRailItem`) |
| `RailFooter` | `RenderFragment?` | No | `null` | Session block / sign-out at rail bottom |
| `ChildContent` | `RenderFragment?` | No | `null` | Page body (`@Body` from `MainLayout`) |
**Adoption pattern** — the thin `MainLayout`:
```razor
@* Components/Layout/MainLayout.razor — replaces the app's existing MainLayout *@
@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>
@* AuthorizeView session block / sign-out link *@
</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
```
---
### `BrandBar`
Brand glyph + product name. Rendered inside `ThemeShell`'s rail header; also usable
standalone.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| `Product` | `string` | Yes | Displayed product name |
| `Logo` | `RenderFragment?` | No | Replaces default `▐` glyph when provided |
---
### `NavRailItem`
One rail navigation link. Wraps Blazor `<NavLink class="rail-link">`.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Href` | `string` | Yes | — | Link target |
| `Text` | `string` | Yes | — | Label text |
| `Icon` | `RenderFragment?` | No | `null` | Optional icon span |
| `Match` | `NavLinkMatch` | No | `Prefix` | Active-class matching behavior |
---
### `NavRailSection`
Collapsible nav section group using CSS-only `<details open>` — no JavaScript, works in
static Blazor SSR. Apps that need interactive cookie-persisted expand state may keep a
bespoke interactive `NavSection` alongside this.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Title` | `string` | Yes | — | Eyebrow label |
| `Expanded` | `bool` | No | `true` | Initial open state |
| `ChildContent` | `RenderFragment?` | No | `null` | `NavRailItem` children |
---
### `StatusPill`
Inline status chip. Maps `StatusState` to a token-based chip class.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| `State` | `StatusState` | Yes | `Ok`, `Warn`, `Bad`, `Idle`, `Info` |
| `ChildContent` | `RenderFragment?` | No | Label text |
```csharp
public enum StatusState { Ok, Warn, Bad, Idle, Info }
```
CSS classes emitted: `chip chip-ok` / `chip-warn` / `chip-bad` / `chip-idle` / `chip-info`.
---
### `LoginCard`
Static form-POST sign-in card. Login **must** use a static form POST — `SignInAsync` must
run before the HTTP response starts; an interactive `EventCallback` fires too late.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Product` | `string` | Yes | — | Product name in the card heading |
| `Action` | `string` | No | `/auth/login` | Form `action` attribute |
| `ReturnUrl` | `string?` | No | `null` | Rendered as `<input type="hidden" name="returnUrl">` |
| `Error` | `string?` | No | `null` | Displayed as an error notice above the submit button |
| `ChildContent` | `RenderFragment?` | No | `null` | For `<AntiforgeryToken/>` |
**Required:** inject `<AntiforgeryToken/>` via `ChildContent` and **validate `ReturnUrl`
server-side** before redirecting (open-redirect risk).
```razor
<LoginCard Product="OtOpcUa" Action="/auth/login" ReturnUrl="@safeUrl" Error="@errorMsg">
<AntiforgeryToken />
</LoginCard>
```
---
### `TechButton`
Themed button wrapping Bootstrap `.btn` classes.
| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Variant` | `ButtonVariant` | No | `Primary` | `Primary`, `Secondary`, `Danger`, `Ghost` |
| `Type` | `string` | No | `"button"` | HTML `type` attribute |
| `Busy` | `bool` | No | `false` | Disables button + shows spinner |
| `ChildContent` | `RenderFragment?` | No | `null` | Button label |
| (splatted) | `IDictionary<string,object>?` | No | — | Passes through arbitrary HTML attributes |
```csharp
public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
```
---
### `TechCard`
Panel with optional header, body, and footer slots.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| `Title` | `string?` | No | String title for the panel header (alternative to `Header` slot) |
| `Header` | `RenderFragment?` | No | Custom panel header (takes precedence over `Title`) |
| `ChildContent` | `RenderFragment?` | No | Panel body content |
| `Footer` | `RenderFragment?` | No | Panel footer (padded, top-bordered) |
| `Class` | `string?` | No | Additional CSS classes on the root `<section class="panel">` |
---
### `TechField`
Labeled form-field wrapper: label, input slot, hint text, and inline error.
| Parameter | Type | Required | Notes |
|---|---|---|---|
| `Label` | `string` | Yes | `<label>` text |
| `Hint` | `string?` | No | Rendered as `.form-text` below the input |
| `Error` | `string?` | No | Rendered as `.field-error.s-bad` below the input |
| `ChildContent` | `RenderFragment?` | No | The `<input>`, `<select>`, or other control |
---
## Notes
- **Bootstrap is not vendored.** Each app keeps its own Bootstrap `<link>`. The RCL's
`theme.css` overrides `--bs-*` tokens to align Bootstrap with Technical-Light but
does not ship Bootstrap itself.
- **No global JavaScript.** `NavRailSection` is CSS-only (`<details>`). Apps may add
their own `nav-state.js` for interactive expand-state if needed (OtOpcUa has one).
- **No auth logic.** The RCL is UI-only. Wire `LoginCard` to `ZB.MOM.WW.Auth` endpoints
in the app.
- **No data grids, modals, or domain-specific components.** These stay per-project.
+123
View File
@@ -0,0 +1,123 @@
# UI Theme — Design Tokens
Canonical reference for every CSS custom property declared in `theme.css`. This is the
human-readable index of the Technical-Light design system. The authoritative source is
`ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css` (verified 2026-06-01,
379 lines). Analogous to [`../auth/spec/CANONICAL-ROLES.md`](../../auth/spec/CANONICAL-ROLES.md)
for the auth component.
The token block lives in `:root` (lines 3977). Components reference these tokens —
**no hardcoded hex values** appear in component markup or CSS. The only per-app override
is `--accent` on the `ThemeShell` root element via the `Accent` parameter.
---
## Surface tokens
| Token | Value | Role |
|---|---|---|
| `--paper` | `#f4f4f1` | Page background — warm off-white, never pure white |
| `--card` | `#ffffff` | Raised surfaces: cards, panel headers, table heads |
---
## Ink (text) tokens
| Token | Value | Role |
|---|---|---|
| `--ink` | `#1b1d21` | Primary text |
| `--ink-soft` | `#5a6066` | Secondary text, form labels |
| `--ink-faint` | `#8b9097` | Tertiary text, captions, units, nav eyebrow labels |
---
## Structure tokens
| Token | Value | Role |
|---|---|---|
| `--rule` | `#e4e4df` | Hairline borders, row dividers |
| `--rule-strong` | `#d2d2cb` | Emphasised hairlines: bar underlines, pill borders |
---
## Accent tokens
| Token | Value | Role |
|---|---|---|
| `--accent` | `#2f5fd0` | Links, sort arrows, primary actions, active nav indicator |
| `--accent-deep` | `#1e3f99` | Hover / pressed accent; raw-value emphasis |
> `--accent` is the **only token overridden per-app**. Pass it as `ThemeShell`'s
> `Accent` parameter: `<ThemeShell Accent="#2f855a">` → emits
> `style="--accent: #2f855a"` on the shell root, scoping the override to that subtree.
---
## Status tokens — foreground
| Token | Value | Role |
|---|---|---|
| `--ok` | `#2f9e44` | Success / healthy / connected state text + icon color |
| `--warn` | `#e8920c` | Warning / degraded state text + icon color |
| `--bad` | `#e03131` | Error / faulted / disconnected state text + icon color |
| `--idle` | `#868e96` | Unknown / offline / neutral state text + icon color |
---
## Status tokens — tinted backgrounds
Pair each with the matching foreground token above (e.g. `--ok-bg` background with `--ok`
foreground text). Used by `.chip-ok`, `.chip-warn`, `.chip-bad`, `.chip-idle` classes.
| Token | Value | Role |
|---|---|---|
| `--ok-bg` | `#e9f6ec` | Success tinted background |
| `--warn-bg` | `#fdf1dd` | Warning tinted background |
| `--bad-bg` | `#fceaea` | Error tinted background |
| `--idle-bg` | `#eef0f2` | Idle/neutral tinted background |
> The `Info` status variant (`StatusState.Info`, `chip-info`) is defined in `layout.css`
> (not `theme.css`) and uses `--accent-deep` foreground on `#e7ecfb` background.
---
## Typography tokens
| Token | Value | Role |
|---|---|---|
| `--mono` | `'IBM Plex Mono', ui-monospace, 'Cascadia Mono', Consolas, monospace` | Monospaced stack — numeric / code values; tabular figures via `font-variant-numeric: tabular-nums` |
| `--sans` | `'IBM Plex Sans', system-ui, -apple-system, 'Segoe UI', sans-serif` | UI body text stack — all prose, labels, nav |
IBM Plex fonts are vendored (three `.woff2` in `wwwroot/fonts/`) — graceful system-font
fallback operates if the fonts are unreachable.
---
## Bootstrap 5 override tokens
These tokens override Bootstrap 5's `--bs-*` custom properties so Bootstrap components
inherit the Technical-Light aesthetic. They are **harmless if Bootstrap is absent**.
| Token | Value | Role |
|---|---|---|
| `--bs-body-bg` | `var(--paper)` | Bootstrap body background → paper |
| `--bs-body-color` | `var(--ink)` | Bootstrap body text → primary ink |
| `--bs-body-font-family` | `var(--sans)` | Bootstrap body font → IBM Plex Sans |
| `--bs-body-font-size` | `0.9rem` | Bootstrap body size — slightly compact |
| `--bs-primary` | `var(--accent)` | Bootstrap primary color → accent |
| `--bs-border-color` | `var(--rule)` | Bootstrap border → hairline rule |
| `--bs-emphasis-color` | `var(--ink)` | Bootstrap emphasis text → primary ink |
---
## Usage rules
1. **Never hand-pick hex values in feature CSS.** Use the tokens above or the utility
classes (`.s-ok`, `.s-warn`, `.s-bad`, `.s-idle`, `.chip-*`, `.kv .v.*`).
2. **One per-app override only.** Override `--accent` via `ThemeShell`'s `Accent`
parameter. Do not override other tokens per-app.
3. **Status is colour, not iconography.** The status palette (`--ok`, `--warn`, `--bad`,
`--idle`) is the canonical way to communicate state. Use `StatusPill` for chips; use
`.s-*` utility classes for inline text.
4. **Token changes are breaking.** Renaming or removing a token requires a SemVer major
bump of `ZB.MOM.WW.Theme` and a coordinated update in every consumer app.
+214
View File
@@ -0,0 +1,214 @@
# UI Theme — normalized target spec
Status: **Draft**. The single design the sister projects converge on. Derived from the
three code-verified current-state docs (`../current-state/`). Goal is *path to shared
code* (`../shared-contract/ZB.MOM.WW.Theme.md`), so each normalized section maps to a
shared library seam.
## 0. Scope
**Normalized here:** the "Technical-Light" design token set; IBM Plex typography;
the canonical side-rail layout shell; the component kit (shell, status pill, login card,
common controls); delivery via the `ZB.MOM.WW.Theme` RCL.
**Explicitly NOT normalized** (domain-specific — keep per project): each app's `site.css`
residual page layout, route/page content, and app-specific scoped `.razor.css` files.
The kit owns the *chrome and tokens*, not the app's domain screens. Authorization logic
and interactive nav-state persistence (e.g. OtOpcUa's cookie-persisted rail sections)
are also per-project.
---
## 1. Design tokens
All color, typography, and structural values are expressed as **CSS custom properties**
declared on `:root` in `theme.css`. Components carry **no hardcoded hex values**
everything references these tokens. Bootstrap 5 `--bs-*` variables are overridden to
align Bootstrap's defaults with the Technical-Light palette.
The **one per-app override** allowed is `--accent` on the `ThemeShell` root element
(passed via the `Accent` parameter), giving each app a distinct primary color while
sharing all other tokens.
See [`DESIGN-TOKENS.md`](DESIGN-TOKENS.md) for the full enumeration.
**Canonical token groups:**
- **Surface** — `--paper`, `--card`
- **Ink (text)** — `--ink`, `--ink-soft`, `--ink-faint`
- **Structure** — `--rule`, `--rule-strong`
- **Accent** — `--accent`, `--accent-deep`
- **Status** — `--ok`, `--warn`, `--bad`, `--idle` (+ `-bg` variants)
- **Typography** — `--sans` (IBM Plex Sans), `--mono` (IBM Plex Mono)
- **Bootstrap overrides** — `--bs-body-bg`, `--bs-body-color`, `--bs-body-font-family`,
`--bs-body-font-size`, `--bs-primary`, `--bs-border-color`, `--bs-emphasis-color`
---
## 2. Typography
IBM Plex is **vendored** (three `.woff2` files in the RCL's `wwwroot/fonts/`); no CDN
dependency so air-gapped fleet deployments keep working.
| Font | Weight | File |
|---|---|---|
| IBM Plex Sans | 400 (regular) | `ibm-plex-sans-400.woff2` |
| IBM Plex Sans | 600 (semibold) | `ibm-plex-sans-600.woff2` |
| IBM Plex Mono | 500 (medium) | `ibm-plex-mono-500.woff2` |
The `@font-face` declarations in `theme.css` use **`url('../fonts/ibm-plex-*.woff2')`**
— the correct relative path from `css/theme.css` to `fonts/`. This is the **canonical
path**; per-app copies that use `url('fonts/…')` or `url('/fonts/…')` are incorrect
(OtOpcUa's `url('fonts/…')` causes a latent 404 silently masked by system-font fallback).
The RCL fixes this permanently for all consumers.
---
## 3. Canonical side-rail layout
The one canonical layout is a **side rail** (not a top nav bar). Layout structure:
```
┌─────────────────────────────────────────────────┐
│ .app-shell (flex-row on lg+; flex-col on sm) │
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ nav.side-rail │ │ main.page │ │
│ │ .brand │ │ (page body / @Body) │ │
│ │ [Nav slot] │ │ │ │
│ │ .rail-foot │ │ │ │
│ └──────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────┘
```
**Rail width / breakpoint:** the rail collapses to a hamburger toggle (`data-bs-toggle=collapse`)
below Bootstrap's `lg` breakpoint. Above `lg`, it is always visible.
**`ThemeShell` is a component, not a layout.** `@layout` in Blazor cannot accept
parameters. Each app therefore keeps a thin 3-line `MainLayout : LayoutComponentBase`
that delegates to `<ThemeShell>` with its per-app `Product`, `Accent`, `Nav`, and
`RailFooter` values (see §4 and [`../shared-contract/ZB.MOM.WW.Theme.md`](../shared-contract/ZB.MOM.WW.Theme.md)).
Nav sections within the rail are CSS-only collapsibles (`<details open>`). Apps that need
interactive expand-state persistence (e.g. OtOpcUa's cookie-persisted nav) may keep a
bespoke interactive `NavSection`; the RCL's `NavRailSection` works without JS and is
compatible with static Blazor SSR.
---
## 4. Component contract
Namespace: `ZB.MOM.WW.Theme`. All components are themed via CSS custom properties —
no inline colors. One `@using ZB.MOM.WW.Theme` covers every component and enum.
### Static-asset entry point
| Component | Description |
|---|---|
| `ThemeHead` | Emits `<link>` tags for `theme.css` and `layout.css`. Drop in `<head>` **after** Bootstrap. |
### Layout shell
| Component / Type | Key parameters | Notes |
|---|---|---|
| `ThemeShell` | `Product`*, `Accent`, `Logo`, `Nav`, `RailFooter`, `ChildContent` | Canonical side-rail chassis. Not a `LayoutComponentBase` — delegated to from `MainLayout`. `Accent` overrides `--accent` for the shell subtree. |
| `BrandBar` | `Product`*, `Logo` | Brand glyph + product name; rendered in the rail header inside `ThemeShell`. |
| `NavRailItem` | `Href`*, `Text`*, `Icon`, `Match` | Wraps `<NavLink class="rail-link">`. Active state via Blazor `NavLink`. |
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only `<details>` collapsible group; no JS, works in static SSR. |
**Thin-MainLayout delegation pattern** (required; see §3):
```razor
@* Components/Layout/MainLayout.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 info / sign-out *@</RailFooter>
<ChildContent>@Body</ChildContent>
</ThemeShell>
```
### Widgets
| Component / Type | Key parameters | Notes |
|---|---|---|
| `StatusPill` | `State`* (`StatusState`), `ChildContent` | Inline chip. `StatusState` enum: `Ok`, `Warn`, `Bad`, `Idle`, `Info`. Maps state → token class (`chip-ok`, …). |
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. `ChildContent` for `<AntiforgeryToken/>`. Validate `ReturnUrl` server-side (open-redirect risk). |
| `TechButton` | `Variant` (`ButtonVariant`), `Type`, `Busy`, `ChildContent`, splatted attrs | `ButtonVariant` enum: `Primary`, `Secondary`, `Danger`, `Ghost`. `Busy` disables + shows spinner. |
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. |
| `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Labeled input wrapper with hint text and inline error. |
\* `EditorRequired` parameter.
**Deliberately NOT included** (YAGNI / stays per-project): data grids, tree views,
multi-select dropdowns, modals, toasts, audit components, page-specific layouts.
---
## 5. Delivery
The RCL ships as the single NuGet package `ZB.MOM.WW.Theme` (`.NET 10`, `Version 0.1.0`).
**Static assets** are served at `_content/ZB.MOM.WW.Theme/…` by ASP.NET's static-web-asset
pipeline:
| Asset path | Contents |
|---|---|
| `_content/ZB.MOM.WW.Theme/css/theme.css` | Design tokens, typography, utility helpers |
| `_content/ZB.MOM.WW.Theme/css/layout.css` | Side-rail layout, collapsible nav, `StatusPill` variants, card/field helpers |
| `_content/ZB.MOM.WW.Theme/fonts/ibm-plex-sans-400.woff2` | IBM Plex Sans Regular |
| `_content/ZB.MOM.WW.Theme/fonts/ibm-plex-sans-600.woff2` | IBM Plex Sans SemiBold |
| `_content/ZB.MOM.WW.Theme/fonts/ibm-plex-mono-500.woff2` | IBM Plex Mono Medium |
**Bootstrap 5 is not vendored** by the kit — each app keeps its own Bootstrap `<link>`.
**Adoption entry points:**
1. Add `<PackageReference Include="ZB.MOM.WW.Theme">` in the app.
2. In `App.razor` `<head>`, after Bootstrap: `<ThemeHead />`.
3. Replace `MainLayout` with the thin-delegation pattern (§3/§4).
4. Add `@using ZB.MOM.WW.Theme` to `_Imports.razor`.
---
## 6. Shared vs per-project
**Shared (extracted into the RCL):**
| What | Where in RCL |
|---|---|
| Design tokens (`--paper`, `--ink`, `--accent`, `--ok`, …) | `wwwroot/css/theme.css` |
| IBM Plex fonts (three `.woff2`) | `wwwroot/fonts/` |
| Side-rail shell layout CSS | `wwwroot/css/layout.css` |
| Side-rail shell components (`ThemeShell`, `BrandBar`, nav components) | `Components/` |
| Status chip (`StatusPill`, `StatusState`) | `Components/` |
| Login card (`LoginCard`) | `Components/` |
| Common controls (`TechButton`, `TechCard`, `TechField`) | `Components/` |
**Per-project (NOT extracted):**
| What | Rationale |
|---|---|
| `site.css` page layout residual | App-specific page structure varies (body padding, two-column layouts, etc.) |
| Page / route components | Domain content — not a UI kit concern |
| Scoped `.razor.css` files | Component-specific overrides stay with the component they scope |
| Authorization / session UI | Depends on per-project auth model (`ZB.MOM.WW.Auth`) |
| Interactive nav-state persistence | Bespoke (OtOpcUa uses a cookie; ScadaBridge / MxGateway use JS state) |
---
## 7. Acceptance
A project is considered **adopted** when all of the following hold:
1. `ZB.MOM.WW.Theme` NuGet package referenced; `ZB.MOM.WW.Theme` in `_Imports.razor`.
2. `<ThemeHead />` in `App.razor` `<head>` (after Bootstrap); per-app `theme.css` copy and
IBM Plex `.woff2` files deleted from `wwwroot/`.
3. `MainLayout` replaced with the thin-delegation pattern wrapping `<ThemeShell>`.
4. Nav rebuilt with `NavRailItem` / `NavRailSection`.
5. Local `StatusBadge` / `StatusChip` component deleted; replaced by `<StatusPill>`.
6. Login form replaced with `<LoginCard>` (static form POST preserved; `<AntiforgeryToken/>`
inside `ChildContent`; `ReturnUrl` validated server-side).
7. Per-app `site.css` page-layout residual kept; scoped `.razor.css` files kept unchanged.
@@ -0,0 +1,203 @@
# UI Theme Component — Design
**Date:** 2026-06-01
**Status:** Approved (brainstorming) → ready for writing-plans
**Goal:** Normalize UI theming across the three sister apps and realize it as a single
shared .NET 10 Razor Class Library, `ZB.MOM.WW.Theme` — mirroring the auth component's
"path to shared code" treatment.
---
## 1. Motivation (code-verified, 2026-06-01)
All three sister apps have Blazor SSR + Bootstrap 5 UIs (four surfaces total):
| App | UI surface(s) | Nav layout today |
|---|---|---|
| OtOpcUa | `ZB.MOM.WW.OtOpcUa.AdminUI` | **side rail** (`NavSidebar.razor`) |
| MxAccessGateway | `ZB.MOM.WW.MxGateway.Server` Dashboard | **top nav bar** |
| ScadaBridge | `ZB.MOM.WW.ScadaBridge.Host` + `.CentralUI` (RCL) | own `MainLayout` + `NavMenu` |
Each ships a hand-copied **`theme.css`** — the "Technical-Light" design system
(379 lines: IBM Plex `@font-face`, design tokens `:root { --paper, --card, --ink,
--ink-soft, --rule, --accent, --ok, --warn, --bad, --idle }`, status palette,
typography). **The three copies are byte-for-byte identical except for three lines**
the font `src:` URL prefix (`fonts/` vs `/fonts/` vs `../fonts/`), a per-app deployment
path, not a design difference. IBM Plex `.woff2` fonts are vendored separately into each
app's `wwwroot/fonts/`.
This is the textbook drift situation: a shared design system maintained as three
copy-pasted files that have *already* begun to diverge. The split between shared and
per-project is unusually clean — see §6.
Verified paths:
- `OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/css/theme.css` (+ `site.css`, `fonts/`)
- `MxAccessGateway/src/ZB.MOM.WW.MxGateway.Server/wwwroot/css/theme.css` (+ `site.css`, `fonts/`)
- `ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/theme.css` (+ `site.css`, `fonts/`)
## 2. Decisions (from brainstorming)
| Decision | Choice |
|---|---|
| Depth/goal | **Full shared UI kit** — beyond tokens: extract layout shell + components into the RCL |
| Layout model | **One canonical layout** — a single side-rail shell; top-bar / menu apps migrate onto it |
| Branding | **Name + accent + logo per app** — uniform chassis, per-app identity via parameters |
| Kit contents | Canonical shell + tokens + fonts **and** Status indicators **and** Login card **and** Common form controls |
| Packaging | **Single RCL `ZB.MOM.WW.Theme`** (Approach A) — one package, one version |
Packaging alternatives considered and rejected: (B) split CSS/fonts vs components — YAGNI,
no tokens-only consumer exists, and splitting later is non-breaking; (C) plain content
package, no components — contradicts the "full kit" decision.
## 3. Architecture
Two deliverables, same shape as the auth component:
1. **`components/ui-theme/`** — the normalization docs (spec / design-tokens /
shared-contract / per-project current-state / GAPS).
2. **`ZB.MOM.WW.Theme/`** — the built RCL, living at `scadaproj/ZB.MOM.WW.Theme/` (its own
folder in this repo, exactly like `ZB.MOM.WW.Auth/`).
**The RCL ships:**
- **Static web assets** — the canonical `theme.css` + the IBM Plex `.woff2` files, served at
`_content/ZB.MOM.WW.Theme/css/theme.css` and `_content/ZB.MOM.WW.Theme/fonts/…`. This alone
kills the copy-paste drift, and fixes the font-path divergence for free: the RCL's
static-asset base path makes the `src: url(../fonts/…)` reference canonical and identical
for every consumer.
- **Razor components** — the canonical side-rail shell + the chosen widgets (§4).
**Accent/branding mechanism:** `ThemeLayout` renders its root as
`<div class="app-shell" style="--accent: @Accent">`, so a per-app accent overrides the
`--accent` custom property for that subtree. Product name and logo are parameters.
Everything else in Technical-Light stays shared and un-overridable.
**Adoption is follow-on, per repo** (like auth's GAPS #8). Building the RCL is self-contained
in `scadaproj`; apps referencing it, deleting their `theme.css`/fonts/`MainLayout`/login card,
and migrating top-bar → rail (MxGateway) is tracked in `GAPS.md`, not done in this repo.
## 4. Component contract (RCL public API)
Namespace `ZB.MOM.WW.Theme`. Components are themed purely via CSS custom properties — no
hardcoded colors in markup.
**Static-asset entry point**
- `<ThemeHead />` — dropped in `<head>` (App.razor); emits the `<link>` to
`_content/ZB.MOM.WW.Theme/css/theme.css`. One line replaces each app's hand-rolled wiring.
**Layout shell (canonical chassis)**
- `ThemeLayout : LayoutComponentBase` — the one side-rail shell.
Params: `string Product`, `string? Accent` (overrides `--accent`), `RenderFragment? Logo`,
`RenderFragment Nav` (rail nav items), `RenderFragment? RailFooter` (e.g. user/sign-out),
`RenderFragment ChildContent` (page body). Renders `BrandBar` in the rail header + the
`Nav` slot + a `<main>` body region.
- `BrandBar` — logo + product name; standalone, used internally by `ThemeLayout`.
- `NavRailItem` — one rail link: `string Href`, `string Text`, `RenderFragment? Icon`,
`NavLinkMatch Match`. Active state via Blazor `NavLink`. Apps fill the `Nav` slot with these.
**Status (highest-value shared widget)**
- `StatusPill``StatusState State` (`Ok | Warn | Bad | Idle | Info`) + `ChildContent`
label. Maps state → the `--ok / --warn / --bad / --idle` tokens. `enum StatusState` public.
**Auth surface**
- `LoginCard` — centered branded card: `string Product`, `RenderFragment? Logo`,
`EventCallback<LoginSubmit> OnSubmit`, `string? Error`, `bool Busy`. UI-only — raises
`OnSubmit`; **no auth logic** (the app wires it to `ZB.MOM.WW.Auth`).
`readonly record struct LoginSubmit(string Username, string Password)`.
**Common controls (thin themed wrappers over Bootstrap 5)**
- `TechButton``ButtonVariant Variant` (`Primary | Secondary | Danger | Ghost`),
`bool Busy`, `ChildContent`; passes through `type`/`onclick`.
- `TechCard``string? Title`, `RenderFragment? Header`, `ChildContent`, `RenderFragment? Footer`.
- `TechField` — labeled input wrapper: `string Label`, `string? Hint`, `string? Error`,
`ChildContent` (the `<input>`/`<select>`).
**Deliberately NOT included** (YAGNI / stays per-project): data grids, tree views, dropdowns,
modals, toasts, page-specific layouts. The kit owns chrome + tokens + the four widgets above —
not a general component library.
**Project shape:** `Microsoft.NET.Sdk.Razor`, `net10.0`, `FrameworkReference
Microsoft.AspNetCore.App`; `wwwroot/css/theme.css` + `wwwroot/fonts/*.woff2` as static assets.
Tests via **bUnit**; central package management, matching `ZB.MOM.WW.Auth`.
## 5. Normalization folder layout
```
components/ui-theme/
README.md # overview + per-project status table
spec/
SPEC.md # the ONE normalized target for UI theming
DESIGN-TOKENS.md # canonical token reference (analogous to auth CANONICAL-ROLES.md)
shared-contract/
ZB.MOM.WW.Theme.md # the RCL public API (§4), packaging, consumer matrix
current-state/
otopcua/CURRENT-STATE.md # code-verified theming today + adoption plan
mxaccessgw/CURRENT-STATE.md
scadabridge/CURRENT-STATE.md
GAPS.md # divergences vs SPEC + adoption backlog
```
**`spec/SPEC.md` sections:** §1 design tokens (Technical-Light palette + type scale; `--accent`
is the one per-app override) · §2 typography (IBM Plex Sans 400/600 + Mono 500, vendored woff2,
canonical relative font path) · §3 canonical side-rail layout · §4 component contract (the §4
surface) · §5 delivery (RCL static assets, `<ThemeHead/>`) · §6 shared vs per-project (see below)
· §7 acceptance (what "adopted" means per app).
**`spec/DESIGN-TOKENS.md`:** enumerates every token (name, value, role) as the lookup reference,
the way `CANONICAL-ROLES.md` enumerates roles. The canonical 379-line `theme.css` is the source;
this doc is its human-readable index.
**`shared-contract/ZB.MOM.WW.Theme.md`:** the package API from §4 + the consumer matrix — all
three apps consume the single RCL (OtOpcUa `AdminUI`, MxGateway `Server` Dashboard, ScadaBridge
`Host` + `CentralUI`); no optional packages, unlike auth.
**Register** the component in `components/README.md` (new row: *UI Theme — layout / tokens /
components → `ZB.MOM.WW.Theme` RCL*) and add it to the root `CLAUDE.md` / `README.md` component
tables.
## 6. Shared vs per-project
**Shared (extracted into the RCL):** design tokens, IBM Plex fonts, the canonical side-rail
shell, and the four widgets (`StatusPill`, `LoginCard`, `TechButton/Card/Field`).
**Per-project (NOT extracted):** each app's `site.css` residual page layout, its page/route
content, and app-specific scoped `.razor.css` (e.g. ScadaBridge's `MultiSelectDropdown`,
`TreeView`, `Audit/*`). The kit owns the *chrome and tokens*, not the app's domain screens.
## 7. Current-state + adoption (per project)
| Project | Surface(s) | Today | Adoption = delete / change / keep |
|---|---|---|---|
| **OtOpcUa** | `AdminUI` (side rail) | `theme.css` + IBM Plex fonts; `MainLayout` + `NavSidebar`; login card in `site.css` | **Lowest effort** — already a rail. Delete `theme.css`/fonts, render in `ThemeLayout`, swap login card for `LoginCard`. Keep `site.css` page bits. |
| **MxAccessGateway** | `Server` Dashboard (**top bar**) | identical `theme.css`/fonts; top-nav `MainLayout` | **Highest effort/risk** — top-bar → side-rail migration (the "one canonical layout" cost lands here). |
| **ScadaBridge** | `Host` + `CentralUI` (own menu) | identical `theme.css`/fonts; `MainLayout` + `NavMenu`; scoped `.razor.css` | Migrate shell to `ThemeLayout`; keep scoped component CSS — stays per-project. |
**`GAPS.md`** — divergences + prioritized adoption backlog:
- *Divergences:* tokens identical today but copy-pasted (drift started on font paths); layouts
differ (rail / top-bar / menu); fonts vendored 3×.
- *Backlog (priority · effort · risk):* (1) build the RCL [scadaproj]; (2) adopt in OtOpcUa
[low risk]; (3) adopt in ScadaBridge [med]; (4) **migrate MxGateway top-bar → rail [high risk
— UX change, flagged explicitly]**. Same "adoption is per-repo follow-on" framing as auth's
GAPS #8.
## 8. Testing
In the RCL, via **bUnit**:
- `StatusPill` maps each `StatusState` → the right token class.
- `ThemeLayout` emits the `--accent` override when `Accent` set; renders `Product`, `Logo`,
`Nav`, and body slots.
- `LoginCard` invokes `OnSubmit` with the entered `LoginSubmit`; honors `Busy`/`Error`.
- `TechButton` / `TechCard` / `TechField` render variants/slots correctly.
- A **packaging test** asserting `theme.css` + the woff2 files ship as
`_content/ZB.MOM.WW.Theme/...` static assets (the anti-drift guarantee).
- No visual/snapshot regression tests — YAGNI for this pass.
**Build/test/pack** (from `ZB.MOM.WW.Theme/`): `dotnet build -c Release` · `dotnet test` ·
`dotnet pack -c Release -o ./artifacts` → 1 nupkg.
## 9. Out of scope (this pass)
- App-side adoption (referencing the RCL, deleting copies, the MxGateway layout migration) —
tracked in `GAPS.md` as follow-on, per repo.
- A general component library beyond the four widgets.
- Dark mode / theme switching (Technical-Light is the only theme today).
- Visual regression testing.
File diff suppressed because it is too large Load Diff
@@ -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"
}