Fix all baseline code-review findings across the six shared libraries

Resolves the 35 findings from the 2026-06-01 baseline (commit 26ba1c7),
test-first for every behavioral change. +51 tests (331 -> 382 passing, 0 failed).

- Telemetry-001 (HIGH): RedactionEnricher now honours property removal, so a
  redactor that drops a key actually scrubs the secret from the event.
- Auth: LDAP validator ValidateOnStart; API-key verify no longer fails on a
  best-effort MarkUsed write or a corrupt scopes column (fail-closed); LDAP cert
  validation hook; KeyPrefix persistence aligned; README algorithm corrected.
- Health: Akka checks return Degraded (not throw) when the cluster isn't up yet;
  GrpcDependencyHealthCheck catch-all; null 'description' rendered; composite
  endpoint builder; XML docs shipped.
- Audit: CompositeAuditWriter no longer re-throws OperationCanceledException;
  TruncatingAuditRedactor over-redact scrubs Target + safe negative max; options
  record; XML docs shipped.
- Configuration: TryAddEnumerable idempotent registration; consistent port
  quoting; strict invariant port parsing; XML docs + README packaged.
- Theme: mobile toggle is now CSS-only (no Bootstrap JS); token/CSS hygiene;
  XML docs on the public parameter surface.

Shared-contract/spec docs updated where the code was the source of truth
(observability service.instance.id, MapZbMetrics, redactor reach). All changes
additive/back-compatible at v0.1.0. code-reviews bookkeeping follows separately.
This commit is contained in:
Joseph Doherty
2026-06-01 11:22:14 -04:00
parent 26ba1c7215
commit 544a6ddb77
72 changed files with 1539 additions and 191 deletions
+11 -1
View File
@@ -7,6 +7,11 @@ woff2 fonts, and side-rail layout CSS — all served from `_content/ZB.MOM.WW.Th
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.
The kit ships **no JavaScript**: every interactive affordance — the narrow-viewport
side-rail hamburger and the collapsible nav sections — is a native CSS-only
`<details>`/`<summary>` disclosure, so the chrome works unchanged in static Blazor SSR
with only Bootstrap **CSS** present (no Bootstrap collapse JS bundle required).
## Adopt
1. Reference the NuGet package in your app; keep your own Bootstrap 5 `<link>` in `App.razor`.
@@ -83,11 +88,16 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
`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.)
All colours are CSS custom-property tokens declared once in `theme.css`'s `:root` block —
no hardcoded hex appears outside it. Derived border/hover/wash shades (e.g. `--ok-border`,
`--warn-ink`, `--info-bg`, `--hover-bg`, `--active-bg`, `--zebra-bg`) are named tokens too,
so a token-block edit re-themes the whole kit.
## Build
```bash
# from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 32 bUnit tests
dotnet test # 38 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
```
@@ -1,3 +1,19 @@
namespace ZB.MOM.WW.Theme;
public enum ButtonVariant { Primary, Secondary, Danger, Ghost }
/// <summary>
/// Visual variant for <c>TechButton</c>, mapped to a Bootstrap <c>.btn-*</c> class.
/// </summary>
public enum ButtonVariant
{
/// <summary>Primary action — emits <c>btn-primary</c> (filled accent button).</summary>
Primary,
/// <summary>Secondary action — emits <c>btn-outline-secondary</c> (outlined button).</summary>
Secondary,
/// <summary>Destructive action — emits <c>btn-danger</c> (filled red button).</summary>
Danger,
/// <summary>Low-emphasis action — emits <c>btn-link</c> (text-only button).</summary>
Ghost,
}
@@ -7,6 +7,11 @@
</div>
@code {
/// <summary>Product name displayed next to the brand glyph. Required.</summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Optional custom logo. When provided it replaces the default brand glyph (<c>▐</c>).
/// </summary>
[Parameter] public RenderFragment? Logo { get; set; }
}
@@ -36,7 +36,12 @@
</div>
@code {
/// <summary>Product name shown in the card heading. Required.</summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Form <c>action</c> URL the sign-in POST targets. Defaults to <c>/auth/login</c>.
/// </summary>
[Parameter] public string Action { get; set; } = "/auth/login";
/// <summary>
@@ -47,6 +52,9 @@
/// </summary>
[Parameter] public string? ReturnUrl { get; set; }
/// <summary>
/// Optional error message shown as a notice above the submit button (e.g. a failed login).
/// </summary>
[Parameter] public string? Error { get; set; }
/// <summary>
@@ -6,8 +6,21 @@
</NavLink>
@code {
/// <summary>Link target (the <c>href</c> of the underlying <c>NavLink</c>). Required.</summary>
[Parameter, EditorRequired] public string Href { get; set; } = string.Empty;
/// <summary>Visible label text for the rail link. Required.</summary>
[Parameter, EditorRequired] public string Text { get; set; } = string.Empty;
/// <summary>
/// Optional leading icon, rendered in a <c>.rail-ico</c> span before the label.
/// </summary>
[Parameter] public RenderFragment? Icon { get; set; }
/// <summary>
/// Active-class matching behaviour passed to the underlying <c>NavLink</c>.
/// Defaults to <see cref="NavLinkMatch.Prefix"/>; use <see cref="NavLinkMatch.All"/>
/// for exact matches such as the home/overview link.
/// </summary>
[Parameter] public NavLinkMatch Match { get; set; } = NavLinkMatch.Prefix;
}
@@ -2,12 +2,24 @@
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>
<summary class="rail-eyebrow-toggle">@Title</summary>
<div class="rail-section-body">@ChildContent</div>
</details>
@code {
/// <summary>
/// Eyebrow label shown in the section's <c>&lt;summary&gt;</c> header. Required.
/// </summary>
[Parameter, EditorRequired] public string Title { get; set; } = string.Empty;
/// <summary>
/// Initial open state of the collapsible section. Defaults to <c>true</c> (expanded).
/// Backed by the native <c>&lt;details open&gt;</c> attribute — no JavaScript required.
/// </summary>
[Parameter] public bool Expanded { get; set; } = true;
/// <summary>
/// Section content — typically <see cref="NavRailItem"/> children.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -3,7 +3,13 @@
<span class="chip @ChipClass">@ChildContent</span>
@code {
/// <summary>
/// Status the chip represents. Mapped to a token-based <c>.chip-*</c> class
/// (e.g. <see cref="StatusState.Ok"/> → <c>chip-ok</c>). Required.
/// </summary>
[Parameter, EditorRequired] public StatusState State { get; set; }
/// <summary>Chip label content (e.g. the status text).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
private string ChipClass => State switch
@@ -6,10 +6,28 @@
</button>
@code {
/// <summary>
/// Visual variant, mapped to a Bootstrap <c>.btn-*</c> class.
/// Defaults to <see cref="ButtonVariant.Primary"/>.
/// </summary>
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
[Parameter] public string Type { get; set; } = "button";
/// <summary>
/// When <c>true</c>, disables the button and shows an inline spinner — use while an
/// async action is in flight. Defaults to <c>false</c>.
/// </summary>
[Parameter] public bool Busy { get; set; }
/// <summary>Button label content.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Arbitrary additional HTML attributes splatted onto the underlying
/// <c>&lt;button&gt;</c> (e.g. <c>id</c>, <c>@onclick</c>, <c>name</c>, <c>value</c>).
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string VariantClass => Variant switch
@@ -8,9 +8,23 @@
</section>
@code {
/// <summary>
/// Optional string title rendered in the panel header. Ignored when <see cref="Header"/>
/// is supplied (<see cref="Header"/> takes precedence).
/// </summary>
[Parameter] public string? Title { get; set; }
/// <summary>
/// Optional custom panel header content. Takes precedence over <see cref="Title"/>.
/// </summary>
[Parameter] public RenderFragment? Header { get; set; }
/// <summary>Panel body content.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
/// <summary>Optional panel footer content (padded, top-bordered).</summary>
[Parameter] public RenderFragment? Footer { get; set; }
/// <summary>Additional CSS classes appended to the root <c>&lt;section class="panel"&gt;</c>.</summary>
[Parameter] public string? Class { get; set; }
}
@@ -8,8 +8,17 @@
</div>
@code {
/// <summary>Field label text. Required.</summary>
[Parameter, EditorRequired] public string Label { get; set; } = string.Empty;
/// <summary>Optional hint text rendered as <c>.form-text</c> below the input.</summary>
[Parameter] public string? Hint { get; set; }
/// <summary>
/// Optional inline error message rendered as <c>.field-error.s-bad</c> below the input.
/// </summary>
[Parameter] public string? Error { get; set; }
/// <summary>The control itself — an <c>&lt;input&gt;</c>, <c>&lt;select&gt;</c>, etc.</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
}
@@ -1,13 +1,15 @@
@* Components/ThemeShell.razor — the one canonical side-rail chassis.
Not a LayoutComponentBase: the app's thin MainLayout delegates to this. *@
Not a LayoutComponentBase: the app's thin MainLayout delegates to this.
The narrow-viewport hamburger uses a CSS-only <details> disclosure (no
Bootstrap JS): the rail is force-shown on lg+ and toggles via the native
<details> open state below lg, so it works in pure static SSR. *@
@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">
<details class="app-shell d-flex flex-column flex-lg-row" style="@AccentStyle">
<summary class="rail-toggle btn btn-outline-secondary btn-sm d-lg-none m-2 align-self-start"
aria-label="Toggle navigation">
&#9776;
</button>
<div class="collapse d-lg-block" id="theme-rail">
</summary>
<div class="theme-rail" id="theme-rail">
<nav class="side-rail">
<BrandBar Product="@Product" Logo="@Logo" />
@Nav
@@ -18,14 +20,41 @@
</nav>
</div>
<main class="page">@ChildContent</main>
</div>
</details>
@code {
/// <summary>
/// Product name shown in the rail's <see cref="BrandBar"/> header. Required.
/// </summary>
[Parameter, EditorRequired] public string Product { get; set; } = string.Empty;
/// <summary>
/// Optional per-app accent colour. When set, emitted as <c>style="--accent: &lt;value&gt;"</c>
/// on the shell root, overriding the <c>--accent</c> design token for this subtree only.
/// This is the single per-app token override the kit allows (e.g. <c>"#2f855a"</c>).
/// </summary>
[Parameter] public string? Accent { get; set; }
/// <summary>
/// Optional custom logo rendered in the rail header. When omitted, the default
/// brand glyph is shown.
/// </summary>
[Parameter] public RenderFragment? Logo { get; set; }
/// <summary>
/// Rail navigation content — typically <see cref="NavRailSection"/> /
/// <see cref="NavRailItem"/> elements.
/// </summary>
[Parameter] public RenderFragment? Nav { get; set; }
/// <summary>
/// Optional content pinned to the bottom of the rail (e.g. session info / sign-out).
/// </summary>
[Parameter] public RenderFragment? RailFooter { get; set; }
/// <summary>
/// The page body — normally <c>@Body</c> forwarded from the app's <c>MainLayout</c>.
/// </summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
private string? AccentStyle => Accent is null ? null : $"--accent: {Accent}";
@@ -1,3 +1,23 @@
namespace ZB.MOM.WW.Theme;
public enum StatusState { Ok, Warn, Bad, Idle, Info }
/// <summary>
/// Status conveyed by a <c>StatusPill</c> chip, mapped to a token-based
/// <c>.chip-*</c> class (status is communicated by colour, not iconography).
/// </summary>
public enum StatusState
{
/// <summary>Success / healthy / connected — emits <c>chip-ok</c> (green).</summary>
Ok,
/// <summary>Warning / degraded — emits <c>chip-warn</c> (amber).</summary>
Warn,
/// <summary>Error / faulted / disconnected — emits <c>chip-bad</c> (red).</summary>
Bad,
/// <summary>Unknown / offline / neutral — emits <c>chip-idle</c> (grey). The default.</summary>
Idle,
/// <summary>Informational — emits <c>chip-info</c> (on-palette blue).</summary>
Info,
}
@@ -2,6 +2,13 @@
<PropertyGroup>
<RootNamespace>ZB.MOM.WW.Theme</RootNamespace>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Emit the XML doc file so missing-doc (CS1591) warnings on the public API
surface under TreatWarningsAsErrors. Razor compiles each component to a
public class whose generated members carry no docs, so CS1591 is left
out of the error set (it would flag generated members, not authored ones)
while still producing the doc XML consumers see at the call site. -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<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>
@@ -2,9 +2,13 @@
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+. */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger
toggle works with NO JavaScript (no Bootstrap collapse bundle): below lg the
<summary> hamburger opens/closes the rail; on lg+ the rail is force-shown
regardless of the open state (see the lg media query below). The outer flex
direction is supplied by Bootstrap utilities on the wrapper
(`d-flex flex-column flex-lg-row`) so the 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);
@@ -15,6 +19,18 @@
min-width: 0;
}
/* Hamburger <summary>: hide the native disclosure triangle so it reads as a
plain button (the styling comes from the Bootstrap .btn utilities). */
.app-shell > summary.rail-toggle {
list-style: none;
cursor: pointer;
}
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing
extra is needed there. On lg+ we force the rail visible (see media query)
and keep the toggle hidden via Bootstrap's d-lg-none. */
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
width: 220px;
@@ -27,9 +43,12 @@
border-right: 1px solid var(--rule-strong);
}
/* On lg+ keep the side rail pinned so it stays visible when content scrolls. */
/* On lg+ keep the side rail pinned so it stays visible when content scrolls,
and force it shown regardless of the <details> open state (the hamburger
toggle is hidden at this width). */
@media (min-width: 992px) {
#theme-rail {
display: block;
position: sticky;
top: 0;
height: 100vh;
@@ -38,8 +57,9 @@
}
}
/* When the side rail is collapsed under <lg viewports the Bootstrap collapse
container removes the fixed width; restore full width on mobile. */
/* When the side rail is collapsed under <lg viewports (the <details> is closed)
the native disclosure hides #theme-rail; when open, restore full width on
mobile so the rail spans the viewport. */
@media (max-width: 991.98px) {
.side-rail {
width: 100%;
@@ -72,17 +92,9 @@
}
.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. */
/* Section eyebrow — the <summary> label of a NavRailSection. The collapse
chevron is supplied by the `.rail-section > summary::before` rule below; this
rule styles the uppercase eyebrow text itself. */
.rail-eyebrow-toggle {
display: flex;
align-items: center;
@@ -100,12 +112,6 @@
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;
@@ -120,17 +126,28 @@
color: var(--ink-soft);
}
.rail-link:hover {
background: #f3f6fd;
background: var(--hover-bg);
color: var(--ink);
text-decoration: none;
}
.rail-link.active {
background: #eef2fc;
background: var(--active-bg);
border-left-color: var(--accent);
color: var(--accent-deep);
font-weight: 600;
}
/* Optional leading icon inside a rail link (NavRailItem's Icon slot). Sizes and
aligns the icon and gives it a gap from the label text. */
.rail-ico {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
margin-right: 0.4rem;
color: var(--ink-faint);
}
/* ── Session block, pinned to the rail foot ──────────────────────────────── */
.rail-foot {
margin-top: auto;
@@ -180,8 +197,8 @@
.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; }
/* StatusPill: info variant (on-palette, reuses the info blue wash) */
.chip-info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
/* TechCard body/footer padding; TechField error; LoginCard body */
.panel-body { padding: 0.85rem 0.9rem; }
@@ -62,6 +62,29 @@
--bad-bg: #fceaea;
--idle-bg: #eef0f2;
/* Status — derived border tints (lighter than the foreground; for chips,
pills, and tinted cards). Pair with the matching -bg above. */
--ok-border: #c6e6cd;
--warn-border: #efd6a6;
--bad-border: #eec3c3;
--ok-border-soft: #bfe3c6; /* conn pill (slightly lighter) */
--warn-border-soft: #f0d9ab; /* conn pill (slightly lighter) */
--bad-border-soft: #f0c0c0; /* conn pill (slightly lighter) */
/* Warning ink — a deeper amber than --warn, for chip/notice/value text that
needs more contrast on the warm tint than the pure --warn would give. */
--warn-ink: #b56a00;
--warn-ink-deep: #8a5a00;
/* Info / accent tint — on-palette blue wash (StatusPill Info, dir-read tag) */
--info-bg: #e7ecfb;
--info-border: #cdd9f7;
/* Neutral surface washes — derived from --paper/--accent, no own foreground */
--zebra-bg: #fbfbf9; /* even-row / sticky-head fill — barely-there */
--hover-bg: #f3f6fd; /* row / rail-link hover — faint cool wash */
--active-bg: #eef2fc; /* active rail-link fill */
/* 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;
@@ -81,7 +104,7 @@
not a gradient wash. Keep it subtle. */
body {
background:
radial-gradient(1200px 480px at 88% -8%, #ffffff 0%, rgba(255,255,255,0) 70%),
radial-gradient(1200px 480px at 88% -8%, var(--card) 0%, rgba(255,255,255,0) 70%),
var(--paper);
color: var(--ink);
font-family: var(--sans);
@@ -142,11 +165,11 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
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"] { color: var(--ok); border-color: var(--ok-border-soft); 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"] { color: var(--warn); border-color: var(--warn-border-soft); 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"] { color: var(--bad); border-color: var(--bad-border-soft); background: var(--bad-bg); }
.conn-pill[data-state="disconnected"] .dot { background: var(--bad); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.25; } }
@@ -171,10 +194,10 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
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); }
.chip-ok { color: var(--ok); background: var(--ok-bg); border-color: var(--ok-border); }
.chip-warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
.chip-bad { color: var(--bad); background: var(--bad-bg); border-color: var(--bad-border); }
.chip-idle { color: var(--idle); 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
@@ -248,10 +271,10 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
font-weight: 400;
color: var(--ink-faint);
}
.agg-card.alert { border-color: #eec3c3; background: var(--bad-bg); }
.agg-card.alert { border-color: var(--bad-border); 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; }
.agg-card.caution { border-color: var(--warn-border); background: var(--warn-bg); }
.agg-card.caution .agg-value { color: var(--warn-ink); }
/* ── Metric card + key/value rows ────────────────────────────────────────────
A .panel-head over a stack of .kv rows: label left, monospace value right.
@@ -278,7 +301,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
padding: 0.32rem 0.9rem;
font-size: 0.85rem;
}
.kv:nth-child(even) { background: #fbfbf9; }
.kv:nth-child(even) { background: var(--zebra-bg); }
.kv .k { color: var(--ink-soft); }
.kv .v {
font-family: var(--mono);
@@ -332,7 +355,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-faint);
background: #fbfbf9;
background: var(--zebra-bg);
position: sticky;
top: 0;
}
@@ -345,7 +368,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
.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:hover { background: var(--hover-bg); }
.data-table tbody tr:last-child td { border-bottom: none; }
.empty-row {
@@ -365,15 +388,15 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
padding: 0.1rem 0.4rem;
border-radius: 3px;
}
.dir-read { color: var(--accent-deep); background: #e7ecfb; }
.dir-write { color: #8a5a00; background: var(--warn-bg); }
.dir-read { color: var(--accent-deep); background: var(--info-bg); }
.dir-write { color: var(--warn-ink-deep); 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;
color: var(--warn-ink);
background: var(--warn-bg);
border-color: #efd6a6;
border-color: var(--warn-border);
}
@@ -13,6 +13,28 @@ public class NavRailTests : TestContext
Assert.Contains("Clusters", a.TextContent);
}
// Theme-004: when Icon is supplied it is wrapped in a .rail-ico span (now styled).
[Fact]
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
{
var cut = RenderComponent<NavRailItem>(p => p
.Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
var ico = cut.Find("a.rail-link .rail-ico");
Assert.NotNull(ico);
Assert.NotNull(cut.Find("a.rail-link .rail-ico .ico"));
}
[Fact]
public void NavRailItem_omits_rail_ico_span_when_no_icon()
{
var cut = RenderComponent<NavRailItem>(p => p
.Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters"));
Assert.Empty(cut.FindAll(".rail-ico"));
}
[Fact]
public void NavRailSection_renders_title_and_children_open_by_default()
{
@@ -1,4 +1,6 @@
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
namespace ZB.MOM.WW.Theme.Tests;
@@ -32,4 +34,36 @@ public class StaticAssetsTests
[InlineData("ibm-plex-mono-500.woff2")]
public void Fonts_are_vendored(string file) =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "fonts", file)));
// Theme-002: .chip-idle pairs the idle background with the matching --idle
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact]
public void ChipIdle_pairs_idle_foreground_with_idle_background()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "theme.css"));
var rule = Regex.Match(css, @"\.chip-idle\s*\{[^}]*\}").Value;
Assert.Contains("color: var(--idle)", rule);
Assert.Contains("background: var(--idle-bg)", rule);
Assert.DoesNotContain("--ink-soft", rule);
}
// Theme-003: no hardcoded hex colours appear outside the :root token block in
// either stylesheet — every shade is a named token.
[Theory]
[InlineData("theme.css")]
[InlineData("layout.css")]
public void No_hardcoded_hex_outside_root_token_block(string file)
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", file));
// Strip the :root { ... } declaration block(s) — the one place hex literals live.
var withoutRoot = Regex.Replace(css, @":root\s*\{[^}]*\}", string.Empty);
var hexLiterals = Regex.Matches(withoutRoot, @"#[0-9a-fA-F]{3,8}\b")
.Select(m => m.Value)
.ToList();
Assert.True(hexLiterals.Count == 0,
$"{file} has hardcoded hex outside :root: {string.Join(", ", hexLiterals)}");
}
}
@@ -40,4 +40,26 @@ public class ThemeShellTests : TestContext
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess"));
}
// Theme-001: the mobile hamburger must work without Bootstrap collapse JS.
// The shell is a native <details> disclosure whose <summary> is the toggle —
// no data-bs-toggle / data-bs-target attributes, so no Bootstrap JS dependency.
[Fact]
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
{
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
// Root is a <details> whose direct <summary> is the hamburger toggle.
var shell = cut.Find("details.app-shell");
var summary = cut.Find("details.app-shell > summary.rail-toggle");
Assert.NotNull(summary);
// The rail is plain markup inside the <details> — no Bootstrap .collapse class.
var rail = cut.Find("#theme-rail");
Assert.DoesNotContain("collapse", rail.ClassList);
// No Bootstrap collapse JS hooks anywhere in the shell markup.
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
}
}