Merge feat/zb-mom-ww-theme: ZB.MOM.WW.Theme shared UI kit (0.1.0) + ui-theme normalization component

This commit is contained in:
Joseph Doherty
2026-06-01 05:18:46 -04:00
47 changed files with 2996 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.