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:
@@ -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><summary></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><details open></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><button></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><section class="panel"></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><input></c>, <c><select></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">
|
||||
☰
|
||||
</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: <value>"</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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user