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:
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
```
|
||||
@@ -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>
|
||||
Executable
+9
@@ -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
|
||||
Executable
+24
@@ -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">▮</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 — 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><AntiforgeryToken /></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">
|
||||
☰
|
||||
</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;
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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 ~24–95), `.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">▮</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-match–based 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 ~24–95) — 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 11–18),
|
||||
`<div class="collapse d-lg-block" id="sidebar-collapse">` (line 21), `<NavSidebar />` (line 22),
|
||||
`<main class="page">@Body</main>` (lines 25–27).
|
||||
|
||||
**`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">▮</span> OtOpcUa</div>` (lines 14–14).
|
||||
- Nav sections: two `NavSection` groups ("Navigation", "Scripting", "Live", "Config")
|
||||
with `<NavLink class="rail-link">` children.
|
||||
- Rail foot (lines 44–62): `<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 22–25), 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 17–18:
|
||||
```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 (~4–95), 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">▮</span> ScadaBridge</div>` (lines ~9–9).
|
||||
- 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.
|
||||
@@ -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 39–77). 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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user