diff --git a/CLAUDE.md b/CLAUDE.md index 6ac9ed2..d729eae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`). diff --git a/README.md b/README.md index e721f5d..ac65e54 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ZB.MOM.WW.Theme/.gitignore b/ZB.MOM.WW.Theme/.gitignore new file mode 100644 index 0000000..0808c4a --- /dev/null +++ b/ZB.MOM.WW.Theme/.gitignore @@ -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 diff --git a/ZB.MOM.WW.Theme/Directory.Build.props b/ZB.MOM.WW.Theme/Directory.Build.props new file mode 100644 index 0000000..b234d68 --- /dev/null +++ b/ZB.MOM.WW.Theme/Directory.Build.props @@ -0,0 +1,10 @@ + + + net10.0 + enable + enable + latest + 0.1.0 + true + + diff --git a/ZB.MOM.WW.Theme/Directory.Packages.props b/ZB.MOM.WW.Theme/Directory.Packages.props new file mode 100644 index 0000000..2dda526 --- /dev/null +++ b/ZB.MOM.WW.Theme/Directory.Packages.props @@ -0,0 +1,12 @@ + + + true + + + + + + + + + diff --git a/ZB.MOM.WW.Theme/README.md b/ZB.MOM.WW.Theme/README.md new file mode 100644 index 0000000..da25a9f --- /dev/null +++ b/ZB.MOM.WW.Theme/README.md @@ -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 `` in `App.razor`. +2. In `App.razor` ``, **after** your Bootstrap link, add ``: + ```razor + @* Bootstrap — yours, not vendored by the kit *@ + + ``` +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 + + + @* session info / sign-out link *@ + @Body + + ``` + +Add `@using ZB.MOM.WW.Theme` to your `_Imports.razor` so all components are available +without per-file usings. + +### Login page + +Use `` 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 `` inside the card and **validate `ReturnUrl` +server-side** before redirecting (open-redirect risk): + +```razor + + + +``` + +## Components + +| Component | Parameters (key) | Notes | +|---|---|---| +| `ThemeHead` | — | Emits `` tags for `theme.css` and `layout.css`. Place in `` 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 ``. | +| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `
` 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 `` 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 +``` diff --git a/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.slnx b/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.slnx new file mode 100644 index 0000000..5ed5942 --- /dev/null +++ b/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ZB.MOM.WW.Theme/build/pack.sh b/ZB.MOM.WW.Theme/build/pack.sh new file mode 100755 index 0000000..c166ead --- /dev/null +++ b/ZB.MOM.WW.Theme/build/pack.sh @@ -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 diff --git a/ZB.MOM.WW.Theme/build/push.sh b/ZB.MOM.WW.Theme/build/push.sh new file mode 100755 index 0000000..cd1ce5d --- /dev/null +++ b/ZB.MOM.WW.Theme/build/push.sh @@ -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 diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs new file mode 100644 index 0000000..37d0146 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ButtonVariant.cs @@ -0,0 +1,3 @@ +namespace ZB.MOM.WW.Theme; + +public enum ButtonVariant { Primary, Secondary, Danger, Ghost } diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor new file mode 100644 index 0000000..d1420d9 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/BrandBar.razor @@ -0,0 +1,12 @@ +@* Components/BrandBar.razor *@ +@namespace ZB.MOM.WW.Theme +
+ @if (Logo is not null) { @Logo } + else { } + @Product +
+ +@code { + [Parameter, EditorRequired] public string Product { get; set; } = string.Empty; + [Parameter] public RenderFragment? Logo { get; set; } +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor new file mode 100644 index 0000000..0f6fb3e --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/LoginCard.razor @@ -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. ). *@ + + +@code { + [Parameter, EditorRequired] public string Product { get; set; } = string.Empty; + [Parameter] public string Action { get; set; } = "/auth/login"; + + /// + /// Optional URL to redirect to after a successful login. Echoed into a hidden + /// returnUrl 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. + /// + [Parameter] public string? ReturnUrl { get; set; } + + [Parameter] public string? Error { get; set; } + + /// + /// Content rendered inside the form, before the username/password fields. + /// The caller MUST supply an antiforgery token here (e.g. <AntiforgeryToken />) + /// because this static POST form is not auto-protected by Blazor's antiforgery middleware. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor new file mode 100644 index 0000000..dd5d982 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor @@ -0,0 +1,13 @@ +@* Components/NavRailItem.razor *@ +@namespace ZB.MOM.WW.Theme + + @if (Icon is not null) { @Icon } + @Text + + +@code { + [Parameter, EditorRequired] public string Href { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Text { get; set; } = string.Empty; + [Parameter] public RenderFragment? Icon { get; set; } + [Parameter] public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix; +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor new file mode 100644 index 0000000..b620734 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor @@ -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 +
+ @Title +
@ChildContent
+
+ +@code { + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public bool Expanded { get; set; } = true; + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor new file mode 100644 index 0000000..99de38a --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/StatusPill.razor @@ -0,0 +1,17 @@ +@* Components/StatusPill.razor *@ +@namespace ZB.MOM.WW.Theme +@ChildContent + +@code { + [Parameter, EditorRequired] public StatusState State { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private string ChipClass => State switch + { + StatusState.Ok => "chip-ok", + StatusState.Warn => "chip-warn", + StatusState.Bad => "chip-bad", + StatusState.Info => "chip-info", + _ => "chip-idle", + }; +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor new file mode 100644 index 0000000..871db30 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor @@ -0,0 +1,22 @@ +@namespace ZB.MOM.WW.Theme +@* Components/TechButton.razor *@ + + +@code { + [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary; + [Parameter] public string Type { get; set; } = "button"; + [Parameter] public bool Busy { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter(CaptureUnmatchedValues = true)] public IDictionary? AdditionalAttributes { get; set; } + + private string VariantClass => Variant switch + { + ButtonVariant.Secondary => "btn-outline-secondary", + ButtonVariant.Danger => "btn-danger", + ButtonVariant.Ghost => "btn-link", + _ => "btn-primary", + }; +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor new file mode 100644 index 0000000..8277737 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechCard.razor @@ -0,0 +1,16 @@ +@namespace ZB.MOM.WW.Theme +@* Components/TechCard.razor *@ +
+ @if (Header is not null) {
@Header
} + else if (!string.IsNullOrEmpty(Title)) {
@Title
} +
@ChildContent
+ @if (Footer is not null) {
@Footer
} +
+ +@code { + [Parameter] public string? Title { get; set; } + [Parameter] public RenderFragment? Header { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + [Parameter] public RenderFragment? Footer { get; set; } + [Parameter] public string? Class { get; set; } +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor new file mode 100644 index 0000000..0ba0d55 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechField.razor @@ -0,0 +1,15 @@ +@namespace ZB.MOM.WW.Theme +@* Components/TechField.razor *@ +
+ + @ChildContent + @if (!string.IsNullOrEmpty(Hint)) {
@Hint
} + @if (!string.IsNullOrEmpty(Error)) {
@Error
} +
+ +@code { + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter] public string? Hint { get; set; } + [Parameter] public string? Error { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeHead.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeHead.razor new file mode 100644 index 0000000..3d20232 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeHead.razor @@ -0,0 +1,4 @@ +@namespace ZB.MOM.WW.Theme +@* Components/ThemeHead.razor — drop in , AFTER your Bootstrap . *@ + + diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor new file mode 100644 index 0000000..0e4977b --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor @@ -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 +
+ +
+ +
+
@ChildContent
+
+ +@code { + [Parameter, EditorRequired] public string Product { get; set; } = string.Empty; + [Parameter] public string? Accent { get; set; } + [Parameter] public RenderFragment? Logo { get; set; } + [Parameter] public RenderFragment? Nav { get; set; } + [Parameter] public RenderFragment? RailFooter { get; set; } + [Parameter] public RenderFragment? ChildContent { get; set; } + + private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}"; +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs new file mode 100644 index 0000000..c39b646 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/StatusState.cs @@ -0,0 +1,3 @@ +namespace ZB.MOM.WW.Theme; + +public enum StatusState { Ok, Warn, Bad, Idle, Info } diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj new file mode 100644 index 0000000..5362dc5 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/ZB.MOM.WW.Theme.csproj @@ -0,0 +1,15 @@ + + + ZB.MOM.WW.Theme + true + true + ZB.MOM.WW.Theme + Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family. + + + + + + + + diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/_Imports.razor b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/_Imports.razor new file mode 100644 index 0000000..b47a016 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/_Imports.razor @@ -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 diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css new file mode 100644 index 0000000..c155f43 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css @@ -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 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; } diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css new file mode 100644 index 0000000..53fb793 --- /dev/null +++ b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css @@ -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; +} diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-mono-500.woff2 b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-mono-500.woff2 new file mode 100644 index 0000000..99c2610 Binary files /dev/null and b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-mono-500.woff2 differ diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2 b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2 new file mode 100644 index 0000000..93bcd64 Binary files /dev/null and b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-400.woff2 differ diff --git a/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-600.woff2 b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-600.woff2 new file mode 100644 index 0000000..0ac91d6 Binary files /dev/null and b/ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/fonts/ibm-plex-sans-600.woff2 differ diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs new file mode 100644 index 0000000..40b6774 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/BrandBarTests.cs @@ -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(p => p.Add(x => x.Product, "OtOpcUa")); + Assert.Contains("OtOpcUa", cut.Markup); + Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo + } + + [Fact] + public void Custom_logo_replaces_default_mark() + { + var cut = RenderComponent(p => p + .Add(x => x.Product, "ScadaBridge") + .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "")))); + Assert.NotNull(cut.Find(".brand .logo")); + Assert.Empty(cut.FindAll(".brand .mark")); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs new file mode 100644 index 0000000..97ca130 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/CommonControlsTests.cs @@ -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(p => p.Add(x => x.Variant, v).AddChildContent("Go")); + var btn = cut.Find("button"); + Assert.Contains("btn", btn.ClassList); + Assert.Contains(cls, btn.ClassList); + } + + [Fact] + public void TechButton_busy_disables_and_passes_through_attributes() + { + var cut = RenderComponent(p => p + .Add(x => x.Busy, true) + .AddUnmatched("id", "save") + .AddChildContent("Save")); + var btn = cut.Find("button"); + Assert.True(btn.HasAttribute("disabled")); + Assert.Equal("save", btn.GetAttribute("id")); + } + + [Fact] + public void TechButton_not_busy_is_not_disabled() + { + var cut = RenderComponent(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(p => p + .Add(x => x.Title, "Drivers") + .AddChildContent("
x
")); + Assert.Contains("Drivers", cut.Find(".panel-head").TextContent); + Assert.NotNull(cut.Find(".panel-body .b")); + } + + [Fact] + public void TechField_renders_label_hint_error() + { + var cut = RenderComponent(p => p + .Add(x => x.Label, "Name") + .Add(x => x.Hint, "required") + .Add(x => x.Error, "missing") + .AddChildContent("")); + Assert.Contains("Name", cut.Find("label").TextContent); + Assert.Contains("required", cut.Find(".form-text").TextContent); + Assert.Contains("missing", cut.Find(".field-error").TextContent); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs new file mode 100644 index 0000000..a98a74c --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/LoginCardTests.cs @@ -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(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.Action, "/auth/login")); + var form = cut.Find("form"); + Assert.Equal("post", form.GetAttribute("method")); + Assert.Equal("/auth/login", form.GetAttribute("action")); + Assert.NotNull(cut.Find("input#username")); + Assert.NotNull(cut.Find("input#password")); + Assert.Contains("OtOpcUa", cut.Find(".login-title").TextContent); + } + + [Fact] + public void ReturnUrl_renders_hidden_input() + { + var cut = RenderComponent(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.ReturnUrl, "/clusters")); + var hidden = cut.Find("input[name=returnUrl]"); + Assert.Equal("/clusters", hidden.GetAttribute("value")); + } + + [Fact] + public void Error_renders_notice() + { + var cut = RenderComponent(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.Error, "Bad credentials")); + Assert.Contains("Bad credentials", cut.Find(".notice").TextContent); + } + + [Fact] + public void No_returnUrl_no_hidden_input() + { + var cut = RenderComponent(p => p.Add(x => x.Product, "OtOpcUa")); + Assert.Empty(cut.FindAll("input[name=returnUrl]")); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs new file mode 100644 index 0000000..479a05b --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/NavRailTests.cs @@ -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(p => p + .Add(x => x.Href, "/clusters") + .Add(x => x.Text, "Clusters")); + var a = cut.Find("a.rail-link"); + Assert.Equal("/clusters", a.GetAttribute("href")); + Assert.Contains("Clusters", a.TextContent); + } + + [Fact] + public void NavRailSection_renders_title_and_children_open_by_default() + { + var cut = RenderComponent(p => p + .Add(x => x.Title, "Navigation") + .AddChildContent("X")); + var details = cut.Find("details.rail-section"); + Assert.True(details.HasAttribute("open")); + Assert.Contains("Navigation", cut.Find("summary").TextContent); + Assert.NotNull(cut.Find(".rail-section-body .rail-link")); + } + + [Fact] + public void NavRailSection_collapsed_when_not_expanded() + { + var cut = RenderComponent(p => p + .Add(x => x.Title, "Nav").Add(x => x.Expanded, false) + .AddChildContent("X")); + Assert.False(cut.Find("details.rail-section").HasAttribute("open")); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs new file mode 100644 index 0000000..2bc89ec --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StaticAssetsTests.cs @@ -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))); +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs new file mode 100644 index 0000000..de42144 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/StatusPillTests.cs @@ -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(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()); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs new file mode 100644 index 0000000..1cbed92 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeHeadTests.cs @@ -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(); + 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); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs new file mode 100644 index 0000000..cb8c6c8 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ThemeShellTests.cs @@ -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(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "N"))) + .AddChildContent("
BODY
")); + Assert.NotNull(cut.Find(".side-rail .brand")); + Assert.Contains("OtOpcUa", cut.Markup); + Assert.NotNull(cut.Find(".side-rail .rail-link")); + Assert.NotNull(cut.Find("main.page .pagebody")); + } + + [Fact] + public void Accent_sets_css_variable_on_shell_root() + { + var cut = RenderComponent(p => p + .Add(x => x.Product, "ScadaBridge") + .Add(x => x.Accent, "#2f855a")); + var shell = cut.Find(".app-shell"); + Assert.Contains("--accent: #2f855a", shell.GetAttribute("style")); + } + + [Fact] + public void No_accent_emits_no_style() + { + var cut = RenderComponent(p => p.Add(x => x.Product, "MXAccess Gateway")); + Assert.False(cut.Find(".app-shell").HasAttribute("style")); + } + + [Fact] + public void RailFooter_renders_when_supplied() + { + var cut = RenderComponent(p => p + .Add(x => x.Product, "OtOpcUa") + .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "S")))); + Assert.NotNull(cut.Find(".rail-foot .sess")); + } +} diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj new file mode 100644 index 0000000..0c719d7 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/ZB.MOM.WW.Theme.Tests.csproj @@ -0,0 +1,15 @@ + + + false + + + + + + + + + + + + diff --git a/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/_Imports.cs b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/_Imports.cs new file mode 100644 index 0000000..5fc9bb5 --- /dev/null +++ b/ZB.MOM.WW.Theme/tests/ZB.MOM.WW.Theme.Tests/_Imports.cs @@ -0,0 +1,4 @@ +global using Bunit; +global using Xunit; +global using Microsoft.AspNetCore.Components; +global using ZB.MOM.WW.Theme; diff --git a/components/README.md b/components/README.md index a65fa6b..5dfd0ef 100644 --- a/components/README.md +++ b/components/README.md @@ -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`. diff --git a/components/ui-theme/GAPS.md b/components/ui-theme/GAPS.md new file mode 100644 index 0000000..c2d94e3 --- /dev/null +++ b/components/ui-theme/GAPS.md @@ -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 | ✅ `` (NavLink) | ⛔ `
  • ` inside `