# Shared library: `ZB.MOM.WW.Theme`

**Status: Built + Published + Adopted (`0.2.0`).** The RCL lives at
[`scadaproj/ZB.MOM.WW.Theme/`](../../../ZB.MOM.WW.Theme/) — built, tested (44 bUnit tests), and
**published to the Gitea NuGet feed**. **Adopted across all three apps on 2026-06-03** — merged to each repo's
local default and **pushed to origin (gitea)**, in sync (see [`../GAPS.md`](../GAPS.md)).
Realizes [`../spec/SPEC.md`](../spec/SPEC.md).

`0.2.0` adds **shared nav-expand persistence**: `NavRailSection` gained a `Key` parameter (emitted as
`data-nav-key`, defaulting to a slug of `Title`), a vendored `wwwroot/js/nav-state.js` localStorage enhancer
(keyed by `data-nav-key`, prefix `zbnav:`, idempotent), and a new **`ThemeScripts`** component (sibling to
`ThemeHead`) that emits the enhancer `<script defer>` before `</body>`. This lets every app persist nav
expand-state from one shared, static-SSR-friendly mechanism (no per-app cookie/JS-interop island).

---

## 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.2.0`. SemVer — token changes are
breaking (major bump); the `0.1.0 → 0.2.0` bump added nav persistence (`NavRailSection.Key` +
`ThemeScripts` + `nav-state.js`) additively. Build from `scadaproj/ZB.MOM.WW.Theme/`:
```bash
dotnet build -c Release   # 0 warnings (TreatWarningsAsErrors)
dotnet test               # 44 bUnit tests
./build/pack.sh           # → ./artifacts/ZB.MOM.WW.Theme.0.2.0.nupkg
GITEA_NUGET_SOURCE=… GITEA_NUGET_KEY=… ./build/push.sh   # publish to the Gitea feed
```

---

## 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.

---

### `ThemeScripts`

Emits the nav-state localStorage enhancer `<script src="_content/ZB.MOM.WW.Theme/js/nav-state.js" defer>`.
No parameters. Place in `App.razor` **before `</body>`**. Persists each `NavRailSection`'s open/closed
state (keyed by its `data-nav-key`) across navigation and reloads; pure client-side, works in static SSR.

```razor
<ThemeScripts />
```

---

### `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 required. Open/closed
state is persisted in localStorage by `<ThemeScripts/>` (keyed by `Key` → `data-nav-key`); works in
static Blazor SSR.

| Parameter | Type | Required | Default | Notes |
|---|---|---|---|---|
| `Title` | `string` | Yes | — | Eyebrow label |
| `Key` | `string?` | No | slug of `Title` | Stable persistence key, emitted as `data-nav-key` |
| `Expanded` | `bool` | No | `true` | Initial open state (before localStorage restore) |
| `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.
