feat(theme): upstream router controls and responsive shell fixes

This commit is contained in:
Joseph Doherty
2026-11-04 00:52:25 -05:00
parent 6ad15fcf93
commit 967da5b43b
22 changed files with 548 additions and 68 deletions
+32
View File
@@ -2,6 +2,38 @@
Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`. Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`.
## Published 0.5.0 (2026-11-04)
Published the tested ignitionoee backlog package to the shared Gitea feed on
2026-11-04. Independent download matched the reviewed artifact SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`.
The ignitionoee router deployed this package to CR on 2026-11-04. No other
consumer was deployed in this task. Do not repack or replace published 0.5.0.
Retains 0.4.2's `.rise` fill-mode fix. Adds the remaining shared application CSS
from ignitionoee (including `td.row-actions`), composable `TechButton.Class` and
`ButtonSize`, plus `ITreeRow`/stateless `TreeTable<TRow>` in `ZB.MOM.WW.Theme`.
Tree styles live once in `layout.css`, with arbitrary-depth indentation and
keyboard-focus styling. Caller-owned filtering/collapse behavior stays intact.
Test harness migrated to bUnit 2.7.2 with AngleSharp 1.5.0 to address the existing
NU1902 advisory without disabling audit. Runtime package dependencies are unchanged.
Local verification: Release build passed with zero warnings/errors; Release tests
passed 60 tests, none skipped. `build/pack.sh` produced the local 0.5.0 nupkg.
All 19 remaining ignitionoee app.css rule blocks are present verbatim in the
shared stylesheet; the Impeccable detector reported no findings on changed UI files.
Browser QA also exposed a pre-existing mobile shell bug: closed navigation hid
the page through Chromium's disclosure wrapper. The wrapper override now applies
at all widths, with an explicit mobile rule hiding only the rail and author
display rules for older browsers. Two regression tests cover CSS placement and
closed-shell component structure; computed mobile/desktop behavior was checked in
the disposable ignitionoee local browser fixture.
Browser QA confirmed the visibility fix and isolated an additional mobile
min-content sizing issue: bounding the direct page to 100% width/max-width with
border-box reduced the page/document from 1055px to the 390px viewport and kept
wide-table scrolling inside its wrapper. That measured constraint is included.
See README's 0.5.0 migration section; deployed consumer validation remains separate
from local source/package/browser verification.
## Index history (migrated from the scadaproj root CLAUDE.md) ## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.** > Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<Version>0.4.2</Version> <Version>0.5.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
+3 -1
View File
@@ -3,7 +3,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="bunit" Version="1.40.0" /> <PackageVersion Include="bunit" Version="2.7.2" />
<!-- Test DOM parser: direct pin avoids bUnit's vulnerable 1.2.0 minimum. -->
<PackageVersion Include="AngleSharp" Version="1.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" /> <PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
+51 -4
View File
@@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk):
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. | | `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
| `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. | | `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. |
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. | | `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. |
| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. | | `TechButton` (`ButtonVariant`, `ButtonSize`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Size` (`Default`/`Small`/`Large`), `Class`, `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. Caller classes are composed with required variant/size classes. |
| `TreeTable<TRow>` (`ITreeRow`) | `Rows`*, `Head`*, `Row`*, `Label`*, `Collapsed`, `OnToggle` | Stateless preorder tree table. The caller filters visible rows and owns expansion state. |
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. | | `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. | | `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. |
@@ -80,7 +81,7 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
| Path | Contents | | Path | Contents |
|---|---| |---|---|
| `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers | | `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, Bootstrap `.btn` sizing | | `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing, shared page/group headers, badges, row actions, info notices, payload blocks and tree-table styles |
**Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its **Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its
0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes 0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes
@@ -128,6 +129,52 @@ so a token-block edit re-themes the whole kit.
```bash ```bash
# from ZB.MOM.WW.Theme/ # from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 38 bUnit tests dotnet test # 60 tests verified for 0.5.0
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg ./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.5.0.nupkg
``` ```
## 0.5.0 migration (published 2026-11-04)
The tested package was published to the shared Gitea feed on 2026-11-04.
An independent feed download exactly matched the reviewed artifact (SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`).
The ignitionoee router deployed this package to CR on 2026-11-04; other consumer
rollouts remain separate. It retains the 0.4.2 `.rise` animation
fill-mode fix. The published package is immutable; this source documentation
update does not repack or replace it.
The shell now keeps page content visible when mobile navigation is collapsed.
The outer disclosure's `::details-content` wrapper is dissolved at every width;
an explicit mobile closed-state selector hides only the rail. Author display
rules retain the page for older browsers without `::details-content` support.
The menu remains a native keyboard-accessible disclosure without custom JavaScript.
Mobile pages are constrained to the shell width so wide tables scroll inside their
table wrapper instead of widening the whole document.
The remaining ignitionoee `app.css` rules now ship in `layout.css`: page and section
headers/meta, group headings/meta, count badges, `.notice.info`, `.row-actions`,
**`td.row-actions`**, and `.check-data`. Consumers may delete matching local copies
after adopting this package. The element-qualified row-action selector must remain:
the bare class loses to `.data-table td` regardless of stylesheet load order.
Use `Size="ButtonSize.Small"` or `Size="ButtonSize.Large"` on `TechButton`.
`Class="my-action"` and an attribute dictionary's `class` value are composed with
the required button classes. A lowercase `class` supplied in Razor binds to `Class`
as normal Blazor parameter binding. Supplying both spellings as separate component
parameters is unnecessary; use `Class` plus `AdditionalAttributes` when merging two
sources. Explicit `Busy` and `Type` continue to control the underlying attributes.
Implement `ZB.MOM.WW.Theme.ITreeRow` on a consumer's row model (`Key`, `Depth`,
`HasChildren`). Feed `TreeTable<TRow>` visible rows in preorder, with each subtree
contiguous after its parent. `Head` supplies all header cells; `Row` supplies cells
after the component's name cell; `Label` supplies the name. `OnToggle` reports the
stable row key, and the caller supplies a new `Collapsed` state and filters `Rows`.
The component never filters or mutates caller state. Indentation supports arbitrary
depth through `--tt-depth`; styles ship once, with no per-instance stylesheet.
Leaf controls preserve label alignment and omit subtree ARIA attributes. Interactive
expand/collapse requires an interactive Blazor host and Bootstrap Icons for chevrons.
The test harness uses bUnit 2.7.2 and directly pins AngleSharp 1.5.0. This avoids
the vulnerable parser minimum ([GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg))
and the binary incompatibility between bUnit 1.x and the patched parser. These
dependencies belong to the test project; the Theme runtime package gains none.
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Theme;
/// <summary>Bootstrap size modifier for <c>TechButton</c>.</summary>
public enum ButtonSize
{
/// <summary>The theme's standard button size, without a Bootstrap size modifier.</summary>
Default,
/// <summary>Compact button, using <c>btn-sm</c>.</summary>
Small,
/// <summary>Large button, using <c>btn-lg</c>.</summary>
Large,
}
@@ -1,6 +1,6 @@
@namespace ZB.MOM.WW.Theme @namespace ZB.MOM.WW.Theme
@* Components/TechButton.razor *@ @* Components/TechButton.razor *@
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy"> <button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">
@if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> } @if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
@ChildContent @ChildContent
</button> </button>
@@ -12,6 +12,12 @@
/// </summary> /// </summary>
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary; [Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
/// <summary>Bootstrap size modifier. Defaults to the theme's standard button size.</summary>
[Parameter] public ButtonSize Size { get; set; }
/// <summary>Additional CSS classes, composed with the variant, size and unmatched class attribute.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary> /// <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"; [Parameter] public string Type { get; set; } = "button";
@@ -30,6 +36,14 @@
/// </summary> /// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; } [Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string ButtonClass => string.Join(" ", new[]
{
"btn", VariantClass,
Size switch { ButtonSize.Small => "btn-sm", ButtonSize.Large => "btn-lg", _ => null },
Class,
AdditionalAttributes?.FirstOrDefault(pair => string.Equals(pair.Key, "class", StringComparison.OrdinalIgnoreCase)).Value?.ToString()
}.Where(value => !string.IsNullOrWhiteSpace(value)));
private string VariantClass => Variant switch private string VariantClass => Variant switch
{ {
ButtonVariant.Secondary => "btn-outline-secondary", ButtonVariant.Secondary => "btn-outline-secondary",
@@ -0,0 +1,88 @@
@namespace ZB.MOM.WW.Theme
@typeparam TRow where TRow : ITreeRow
@* Stateless preorder tree table. The caller filters Rows and owns collapse state.
Shared styles ship once in layout.css, including arbitrary-depth indentation. *@
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>@Head</tr>
</thead>
<tbody>
@foreach (TRow row in Rows)
{
<tr @key="row.Key" class="tt-d@(row.Depth)">
<td class="tt-name" style="@DepthStyle(row)">
<button type="button" class="@TwistClass(row)" disabled="@(!row.HasChildren)"
aria-expanded="@AriaExpanded(row)" aria-label="@TwistLabel(row)"
@onclick="() => OnToggle.InvokeAsync(row.Key)">
<i class="@ChevronClass(row)" aria-hidden="true"></i>
</button>
@Label(row)
</td>
@Row(row)
</tr>
}
</tbody>
</table>
</div>
@code {
/// <summary>
/// The rows to render, in order — already filtered through
/// the caller's visibility policy.
/// </summary>
/// <remarks>
/// The filtering is the caller's so that this component holds no state at all: the page owns
/// <see cref="Collapsed"/>, and what is on screen is a pure function of it.
/// </remarks>
[Parameter, EditorRequired] public IReadOnlyList<TRow> Rows { get; set; } = [];
/// <summary>
/// The header cells — every <c>&lt;th&gt;</c>, including the one over the name column. The
/// component supplies the <c>&lt;thead&gt;</c> and its <c>&lt;tr&gt;</c>.
/// </summary>
[Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { };
/// <summary>
/// One row's cells <b>after</b> the name cell: the component renders the name column, the
/// caller renders the rest.
/// </summary>
[Parameter, EditorRequired] public RenderFragment<TRow> Row { get; set; } = _ => builder => { };
/// <summary>The text of a row's name cell, beside its twist.</summary>
[Parameter, EditorRequired] public Func<TRow, string> Label { get; set; } = _ => string.Empty;
/// <summary>
/// The keys of the collapsed rows — read only to draw each twist the right way round.
/// </summary>
/// <remarks>
/// It is the same set the caller filtered <see cref="Rows"/> with, so a row can be collapsed
/// and on screen (that is what a shut branch looks like) but never expanded and hidden.
/// </remarks>
[Parameter] public IReadOnlySet<string> Collapsed { get; set; } = new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Raised with a row's <see cref="ITreeRow.Key"/> when its twist is clicked. The caller
/// toggles that key in its own collapse set; this component changes nothing itself.
/// </summary>
[Parameter] public EventCallback<string> OnToggle { get; set; }
private static string DepthStyle(TRow row)
=> "--tt-depth: " + Math.Max(0, row.Depth).ToString(System.Globalization.CultureInfo.InvariantCulture);
private bool IsCollapsed(TRow row) => Collapsed.Contains(row.Key);
private string TwistClass(TRow row) => row.HasChildren ? "tt-twist" : "tt-twist tt-leaf";
private string ChevronClass(TRow row) => IsCollapsed(row) ? "bi bi-chevron-right" : "bi bi-chevron-down";
// Null omits the attribute: a leaf's twist is decoration, and announcing it as a collapsed
// control would promise a subtree that is not there.
private string? AriaExpanded(TRow row)
=> !row.HasChildren ? null : IsCollapsed(row) ? "false" : "true";
private string? TwistLabel(TRow row)
=> !row.HasChildren ? null : (IsCollapsed(row) ? "Expand " : "Collapse ") + Label(row);
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.Theme;
/// <summary>A row in a preorder flat tree, with each subtree contiguous after its parent.</summary>
public interface ITreeRow
{
/// <summary>Stable identity, unique in the table and used for keyed rendering and collapse state.</summary>
string Key { get; }
/// <summary>Nesting depth, starting at zero for a root.</summary>
int Depth { get; }
/// <summary>Whether this row owns a subtree and offers an expand/collapse toggle.</summary>
bool HasChildren { get; }
}
@@ -11,10 +11,12 @@
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Theme</PackageId> <PackageId>ZB.MOM.WW.Theme</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description> <Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" /> <InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
@@ -1,5 +1,5 @@
/* ZB.MOM.WW.Theme — side-rail + login layout /* ZB.MOM.WW.Theme — side-rail + login layout
Tokens live in theme.css; this sheet carries only layout + the side rail. */ Tokens live in theme.css; this sheet carries application layout and shared controls. */
/* ── Bootstrap button sizing ───────────────────────────────────────────────── /* ── Bootstrap button sizing ─────────────────────────────────────────────────
Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against
@@ -45,6 +45,12 @@
--bs-btn-padding-y: .25rem; --bs-btn-padding-y: .25rem;
--bs-btn-padding-x: .6rem; --bs-btn-padding-x: .6rem;
} }
.btn-group-lg > .btn,
.btn-lg {
--bs-btn-font-size: 1rem;
--bs-btn-padding-y: .5rem;
--bs-btn-padding-x: 1rem;
}
/* ── App shell: side rail + page ─────────────────────────────────────────── */ /* ── App shell: side rail + page ─────────────────────────────────────────── */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger /* The shell is a native <details> disclosure so the narrow-viewport hamburger
@@ -59,6 +65,21 @@
min-height: calc(100vh - 3.3rem); min-height: calc(100vh - 3.3rem);
} }
/* A closed shell must hide navigation only, never the page. Modern Chromium
wraps all non-summary children in ::details-content and hides that box while
closed. Dissolve it at EVERY width so both children retain the shell's flex
layout. The mobile closed-rail rule below owns navigation visibility. */
.app-shell::details-content {
display: contents;
content-visibility: visible;
}
/* Older browsers use a UA display rule on closed disclosure children instead
of ::details-content. Author display rules keep the page present there too;
an unsupported pseudo-element rule above is simply ignored. */
.app-shell > main.page { display: block; }
.app-shell > #theme-rail { display: block; }
.app-shell .page { .app-shell .page {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -72,9 +93,8 @@
} }
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; } .app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing /* The native <details> owns its open state; the mobile rule explicitly hides
extra is needed there. On lg+ we force the rail visible (see media query) only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */
and keep the toggle hidden via Bootstrap's d-lg-none. */
/* ── Side rail ───────────────────────────────────────────────────────────── */ /* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail { .side-rail {
@@ -92,20 +112,6 @@
and force it shown regardless of the <details> open state (the hamburger and force it shown regardless of the <details> open state (the hamburger
toggle is hidden at this width). */ toggle is hidden at this width). */
@media (min-width: 992px) { @media (min-width: 992px) {
/* Chromium >=121 wraps a <details>'s content in a generated ::details-content
box that carries content-visibility:hidden while the <details> is closed.
Because our app-shell ships closed by default (SSR, no JS) and the toggle
is d-lg-none here, that wrapper would (a) hide the rail+page entirely on
lg+ and (b) sit between .app-shell and its rail/page children, collapsing
the flex-lg-row layout into a vertical stack. Dissolving the wrapper with
display:contents removes its box (so content-visibility no longer hides the
content AND rail/page become direct flex children of .app-shell again).
Browsers that don't support ::details-content treat this as an invalid
selector and drop the rule, falling back to the legacy force-show below. */
.app-shell::details-content {
display: contents;
}
#theme-rail { #theme-rail {
display: block; display: block;
position: sticky; position: sticky;
@@ -116,10 +122,16 @@
} }
} }
/* When the side rail is collapsed under <lg viewports (the <details> is closed) /* The disclosure toggles navigation while the page remains usable underneath. */
the native disclosure hides #theme-rail; when open, restore full width on
mobile so the rail spans the viewport. */
@media (max-width: 991.98px) { @media (max-width: 991.98px) {
.app-shell:not([open]) > #theme-rail { display: none; }
/* Native disclosure layout can otherwise size the page to a table's
min-content width. Bound the page so .table-wrap scrolls internally. */
.app-shell > .page {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.side-rail { .side-rail {
width: 100%; width: 100%;
min-width: 100%; min-width: 100%;
@@ -323,3 +335,79 @@
.login-body { padding: 1.4rem 1.1rem 1.25rem; } .login-body { padding: 1.4rem 1.1rem 1.25rem; }
.login-error { margin-bottom: 0.85rem; } .login-error { margin-bottom: 0.85rem; }
.field-error { font-size: 0.78rem; margin-top: 0.2rem; } .field-error { font-size: 0.78rem; margin-top: 0.2rem; }
/* Shared application presentation, upstreamed from ignitionoee in 0.5.0. */
/* ── Page header ─────────────────────────────────────────────────────────────
Title left, `.spacer` eating the slack, actions/meta pushed hard right. */
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
.page-head h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; margin: 0; text-wrap: balance; }
.page-head .spacer { flex: 1; }
.page-meta { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); }
/* ── Application sections ────────────────────────────────────────────────────
A rule-underlined h2 band; same spacer trick as the page head. */
.app-section { margin-bottom: 1.5rem; }
.app-head {
display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
padding-bottom: 0.4rem; border-bottom: 1px solid var(--rule-strong); margin-bottom: 0.75rem;
}
.app-head h2 { font-size: 1.02rem; font-weight: 600; margin: 0; }
.app-head .spacer { flex: 1; }
.app-meta { font-family: var(--mono); font-size: 0.76rem; color: var(--ink-faint); }
/* ── Group header (per-site issue groups) ────────────────────────────────── */
.group-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.group-name { font-size: 0.74rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-soft); }
.group-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-faint); }
/* ── Per-group count badge ───────────────────────────────────────────────────
Red by default (it counts problems); `.warn` for the softer amber case. */
.count-badge {
display: inline-block; min-width: 1.4rem; text-align: center;
font-family: var(--mono); font-size: 0.72rem; font-weight: 600;
padding: 0.05rem 0.35rem; border-radius: 999px;
color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad-border);
}
.count-badge.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
/* ── Inline notice, info variant ─────────────────────────────────────────────
Theme 0.3.1 ships `.notice` (amber) but no variants; `.info` re-tints it to
the on-palette blue wash. `--info-bg`/`--info-border` come from the theme. */
.notice.info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
/* ── Table row-action cell ─────────────────────────────────────────────────
The bare class is what the mockup ships and what a `<span class="row-actions">`
still needs. It is NOT enough on its own: every use in this app is a table
cell, and Theme 0.3.1's `.data-table th, .data-table td { text-align: left }`
is class+element (0-1-1) against this rule's bare class (0-1-0) — specificity
beats load order, so the alignment silently lost everywhere. Observed in a
browser on 2026-08-05 (every row-action cell left-aligned; `nowrap` applied,
`text-align` did not); the cell-level restatement below is the fix.
See docs/recipes/router-ui-render-validation.md, Task 1 Defect 2. */
.row-actions { text-align: right; white-space: nowrap; }
td.row-actions { text-align: right; }
/* ── Preformatted check/payload block ────────────────────────────────────── */
.check-data {
margin: 0.35rem 0.9rem 0.6rem; padding: 0.4rem 0.6rem; background: var(--zebra-bg);
border: 1px solid var(--rule); border-radius: 4px;
font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); line-height: 1.6;
overflow-x: auto; white-space: nowrap;
}
.check-data b { color: var(--accent-deep); font-weight: 500; }
/* ── Tree table ──────────────────────────────────────────────────────────────
Name-cell depth is a number emitted by TreeTable; it adds to the table's
standard 0.8rem cell padding and works at every depth. */
.data-table td.tt-name {
font-weight: 600;
padding-left: calc(0.8rem + var(--tt-depth, 0) * 1.2rem);
}
.tt-twist {
background: none; border: none; padding: 0; margin-right: 0.45rem;
color: var(--ink-faint); font-size: 0.75rem; line-height: 1; cursor: pointer;
}
.tt-twist:hover { color: var(--ink); }
.tt-twist:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A hidden leaf control preserves the width of its siblings' toggles. */
.tt-leaf { visibility: hidden; }
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class BrandBarTests : TestContext public class BrandBarTests : BunitContext
{ {
[Fact] [Fact]
public void Renders_product_with_default_mark() public void Renders_product_with_default_mark()
{ {
var cut = RenderComponent<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Contains("OtOpcUa", cut.Markup); Assert.Contains("OtOpcUa", cut.Markup);
Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
} }
@@ -13,7 +13,7 @@ public class BrandBarTests : TestContext
[Fact] [Fact]
public void Custom_logo_replaces_default_mark() public void Custom_logo_replaces_default_mark()
{ {
var cut = RenderComponent<BrandBar>(p => p var cut = Render<BrandBar>(p => p
.Add(x => x.Product, "ScadaBridge") .Add(x => x.Product, "ScadaBridge")
.Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>")))); .Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
Assert.NotNull(cut.Find(".brand .logo")); Assert.NotNull(cut.Find(".brand .logo"));
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class CommonControlsTests : TestContext public class CommonControlsTests : BunitContext
{ {
[Theory] [Theory]
[InlineData(ButtonVariant.Primary, "btn-primary")] [InlineData(ButtonVariant.Primary, "btn-primary")]
@@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext
[InlineData(ButtonVariant.Ghost, "btn-link")] [InlineData(ButtonVariant.Ghost, "btn-link")]
public void TechButton_maps_variant(ButtonVariant v, string cls) public void TechButton_maps_variant(ButtonVariant v, string cls)
{ {
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go")); var cut = Render<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
var btn = cut.Find("button"); var btn = cut.Find("button");
Assert.Contains("btn", btn.ClassList); Assert.Contains("btn", btn.ClassList);
Assert.Contains(cls, btn.ClassList); Assert.Contains(cls, btn.ClassList);
@@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechButton_busy_disables_and_passes_through_attributes() public void TechButton_busy_disables_and_passes_through_attributes()
{ {
var cut = RenderComponent<TechButton>(p => p var cut = Render<TechButton>(p => p
.Add(x => x.Busy, true) .Add(x => x.Busy, true)
.AddUnmatched("id", "save") .AddUnmatched("id", "save")
.AddChildContent("Save")); .AddChildContent("Save"));
@@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechButton_not_busy_is_not_disabled() public void TechButton_not_busy_is_not_disabled()
{ {
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go")); var cut = Render<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
Assert.False(cut.Find("button").HasAttribute("disabled")); Assert.False(cut.Find("button").HasAttribute("disabled"));
} }
[Fact] [Fact]
public void TechCard_renders_title_and_body() public void TechCard_renders_title_and_body()
{ {
var cut = RenderComponent<TechCard>(p => p var cut = Render<TechCard>(p => p
.Add(x => x.Title, "Drivers") .Add(x => x.Title, "Drivers")
.AddChildContent("<div class='b'>x</div>")); .AddChildContent("<div class='b'>x</div>"));
Assert.Contains("Drivers", cut.Find(".panel-head").TextContent); Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
@@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext
[Fact] [Fact]
public void TechField_renders_label_hint_error() public void TechField_renders_label_hint_error()
{ {
var cut = RenderComponent<TechField>(p => p var cut = Render<TechField>(p => p
.Add(x => x.Label, "Name") .Add(x => x.Label, "Name")
.Add(x => x.Hint, "required") .Add(x => x.Hint, "required")
.Add(x => x.Error, "missing") .Add(x => x.Error, "missing")
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class LoginCardTests : TestContext public class LoginCardTests : BunitContext
{ {
[Fact] [Fact]
public void Posts_to_action_with_username_password_fields() public void Posts_to_action_with_username_password_fields()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Action, "/auth/login")); .Add(x => x.Action, "/auth/login"));
var form = cut.Find("form"); var form = cut.Find("form");
@@ -19,7 +19,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void ReturnUrl_renders_hidden_input() public void ReturnUrl_renders_hidden_input()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.ReturnUrl, "/clusters")); .Add(x => x.ReturnUrl, "/clusters"));
var hidden = cut.Find("input[name=returnUrl]"); var hidden = cut.Find("input[name=returnUrl]");
@@ -29,7 +29,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Error_renders_notice() public void Error_renders_notice()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Error, "Bad credentials")); .Add(x => x.Error, "Bad credentials"));
Assert.Contains("Bad credentials", cut.Find(".notice").TextContent); Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
@@ -38,7 +38,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void No_returnUrl_no_hidden_input() public void No_returnUrl_no_hidden_input()
{ {
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Empty(cut.FindAll("input[name=returnUrl]")); Assert.Empty(cut.FindAll("input[name=returnUrl]"));
} }
@@ -47,7 +47,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Product_is_wrapped_in_login_product_span() public void Product_is_wrapped_in_login_product_span()
{ {
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
var product = cut.Find(".login-title .login-product"); var product = cut.Find(".login-title .login-product");
Assert.Equal("OtOpcUa", product.TextContent); Assert.Equal("OtOpcUa", product.TextContent);
Assert.Contains("sign in", cut.Find(".login-title").TextContent); Assert.Contains("sign in", cut.Find(".login-title").TextContent);
@@ -57,7 +57,7 @@ public class LoginCardTests : TestContext
[Fact] [Fact]
public void Heading_overrides_default_heading_when_set() public void Heading_overrides_default_heading_when_set()
{ {
var cut = RenderComponent<LoginCard>(p => p var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Heading, "Welcome back")); .Add(x => x.Heading, "Welcome back"));
var title = cut.Find(".login-title"); var title = cut.Find(".login-title");
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class NavRailTests : TestContext public class NavRailTests : BunitContext
{ {
[Fact] [Fact]
public void NavRailItem_renders_rail_link_with_href_and_text() public void NavRailItem_renders_rail_link_with_href_and_text()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")); .Add(x => x.Text, "Clusters"));
var a = cut.Find("a.rail-link"); var a = cut.Find("a.rail-link");
@@ -17,7 +17,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied() public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters") .Add(x => x.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>")))); .Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailItem_omits_rail_ico_span_when_no_icon() public void NavRailItem_omits_rail_ico_span_when_no_icon()
{ {
var cut = RenderComponent<NavRailItem>(p => p var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters") .Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")); .Add(x => x.Text, "Clusters"));
Assert.Empty(cut.FindAll(".rail-ico")); Assert.Empty(cut.FindAll(".rail-ico"));
@@ -38,7 +38,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_renders_title_and_children_open_by_default() public void NavRailSection_renders_title_and_children_open_by_default()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation") .Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
var details = cut.Find("details.rail-section"); var details = cut.Find("details.rail-section");
@@ -52,7 +52,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_summary_aria_expanded_true_when_open() public void NavRailSection_summary_aria_expanded_true_when_open()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation") .Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -61,7 +61,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_collapsed_when_not_expanded() public void NavRailSection_collapsed_when_not_expanded()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.False(cut.Find("details.rail-section").HasAttribute("open")); Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
@@ -71,7 +71,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_summary_aria_expanded_false_when_collapsed() public void NavRailSection_summary_aria_expanded_false_when_collapsed()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false) .Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded")); Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -80,7 +80,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default() public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Site Calls") .Add(x => x.Title, "Site Calls")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -89,7 +89,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_emits_explicit_key_when_supplied() public void NavRailSection_emits_explicit_key_when_supplied()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav") .Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -98,7 +98,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key() public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, " ") .Add(x => x.Title, " ")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -107,7 +107,7 @@ public class NavRailTests : TestContext
[Fact] [Fact]
public void NavRailSection_slug_preserves_unicode_letters() public void NavRailSection_slug_preserves_unicode_letters()
{ {
var cut = RenderComponent<NavRailSection>(p => p var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Café") .Add(x => x.Title, "Café")
.AddChildContent("<a class='rail-link'>X</a>")); .AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key")); Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -39,6 +39,36 @@ public class StaticAssetsTests
public void NavStateScript_ships() => public void NavStateScript_ships() =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js"))); Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
[Fact]
public void Shared_layout_ships_row_action_specificity_and_variable_size_modifiers()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
Assert.Matches(@"td\.row-actions\s*\{\s*text-align:\s*right", css);
foreach (var selector in new[] { ".page-head", ".page-meta", ".app-section", ".app-head",
".app-meta", ".group-head", ".group-name", ".group-meta", ".count-badge",
".count-badge.warn", ".notice.info", ".row-actions", ".check-data" })
Assert.Contains(selector, css);
Assert.Contains("var(--tt-depth, 0)", css);
Assert.Matches(@"\.btn-lg\s*\{\s*--bs-btn-font-size:\s*1rem", css);
Assert.Matches(@"\.tt-leaf\s*\{\s*visibility:\s*hidden", css);
}
[Fact]
public void Closed_mobile_shell_hides_only_the_rail_and_keeps_main_content_visible()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline);
var baseRules = css[..css.IndexOf("@media", StringComparison.Ordinal)];
Assert.Matches(@"\.app-shell::details-content\s*\{[^}]*display:\s*contents", baseRules);
Assert.Matches(@"\.app-shell\s*>\s*main\.page\s*\{[^}]*display:\s*block", baseRules);
var mobile = css[css.IndexOf("@media (max-width: 991.98px)", StringComparison.Ordinal)..];
Assert.Matches(@"\.app-shell:not\(\[open\]\)\s*>\s*#theme-rail\s*\{\s*display:\s*none", mobile);
var page = Regex.Match(mobile, @"\.app-shell\s*>\s*\.page\s*\{([^}]*)\}").Groups[1].Value;
Assert.Matches(@"\bwidth:\s*100%", page);
Assert.Matches(@"\bmax-width:\s*100%", page);
Assert.Matches(@"\bbox-sizing:\s*border-box", page);
}
// Theme-002: .chip-idle pairs the idle background with the matching --idle // Theme-002: .chip-idle pairs the idle background with the matching --idle
// foreground token (per DESIGN-TOKENS.md), not --ink-soft. // foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact] [Fact]
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class StatusPillTests : TestContext public class StatusPillTests : BunitContext
{ {
[Theory] [Theory]
[InlineData(StatusState.Ok, "chip-ok")] [InlineData(StatusState.Ok, "chip-ok")]
@@ -10,7 +10,7 @@ public class StatusPillTests : TestContext
[InlineData(StatusState.Info, "chip-info")] [InlineData(StatusState.Info, "chip-info")]
public void Maps_state_to_chip_class(StatusState state, string expected) public void Maps_state_to_chip_class(StatusState state, string expected)
{ {
var cut = RenderComponent<StatusPill>(p => p var cut = Render<StatusPill>(p => p
.Add(x => x.State, state) .Add(x => x.State, state)
.AddChildContent("Connected")); .AddChildContent("Connected"));
var span = cut.Find("span"); var span = cut.Find("span");
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TechButtonExtensionsTests : BunitContext
{
[Theory]
[InlineData(ButtonSize.Default, null)]
[InlineData(ButtonSize.Small, "btn-sm")]
[InlineData(ButtonSize.Large, "btn-lg")]
public void Size_preserves_variant_and_composes_both_class_inputs(ButtonSize size, string? expected)
{
var cut = Render<TechButton>(p => p
.Add(x => x.Variant, ButtonVariant.Secondary)
.Add(x => x.Size, size)
.Add(x => x.Class, "explicit-action")
.Add(x => x.AdditionalAttributes, new Dictionary<string, object>
{
["class"] = "caller-action", ["aria-label"] = "Save changes"
})
.AddChildContent("Save"));
var button = cut.Find("button");
foreach (var cls in new[] { "btn", "btn-outline-secondary", "explicit-action", "caller-action" })
Assert.Contains(cls, button.ClassList);
Assert.Equal("Save changes", button.GetAttribute("aria-label"));
if (expected is not null) Assert.Contains(expected, button.ClassList);
else
{
Assert.DoesNotContain("btn-sm", button.ClassList);
Assert.DoesNotContain("btn-lg", button.ClassList);
}
}
[Fact]
public void Class_composition_keeps_busy_type_and_click_behavior()
{
var clicks = 0;
var attributes = new Dictionary<string, object>
{
["class"] = "caller", ["disabled"] = false,
["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
};
var cut = Render<TechButton>(p => p
.Add(x => x.Class, "extra")
.Add(x => x.Type, "submit")
.Add(x => x.Busy, true)
.Add(x => x.AdditionalAttributes, attributes)
.AddChildContent("Save"));
Assert.True(cut.Find("button").HasAttribute("disabled"));
Assert.Equal("submit", cut.Find("button").GetAttribute("type"));
Assert.NotNull(cut.Find(".spinner-border"));
Assert.Equal("caller", attributes["class"]);
cut.Render(p => p.Add(x => x.Busy, false));
cut.Find("button").Click();
Assert.Equal(1, clicks);
Assert.DoesNotContain(cut.FindAll("span"), x => x.ClassList.Contains("spinner-border"));
}
[Fact]
public void Lowercase_class_attribute_preserves_required_button_classes()
{
var cut = Render<TechButton>(p => p.AddUnmatched("class", "caller").AddChildContent("Go"));
foreach (var cls in new[] { "btn", "btn-primary", "caller" })
Assert.Contains(cls, cut.Find("button").ClassList);
}
}
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeHeadTests : TestContext public class ThemeHeadTests : BunitContext
{ {
[Fact] [Fact]
public void Emits_theme_and_layout_links_to_content_path() public void Emits_theme_and_layout_links_to_content_path()
{ {
var cut = RenderComponent<ThemeHead>(); var cut = Render<ThemeHead>();
var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList(); 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/theme.css", hrefs);
Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs); Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext public class ThemeScriptsTests : BunitContext
{ {
[Fact] [Fact]
public void ThemeScripts_emits_nav_state_script_tag() public void ThemeScripts_emits_nav_state_script_tag()
{ {
var cut = RenderComponent<ThemeScripts>(); var cut = Render<ThemeScripts>();
var script = cut.Find("script"); var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src")); Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer")); Assert.True(script.HasAttribute("defer"));
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests; namespace ZB.MOM.WW.Theme.Tests;
public class ThemeShellTests : TestContext public class ThemeShellTests : BunitContext
{ {
[Fact] [Fact]
public void Renders_product_nav_and_body() public void Renders_product_nav_and_body()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>"))) .Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.AddChildContent("<div class='pagebody'>BODY</div>")); .AddChildContent("<div class='pagebody'>BODY</div>"));
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void Accent_sets_css_variable_on_shell_root() public void Accent_sets_css_variable_on_shell_root()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "ScadaBridge") .Add(x => x.Product, "ScadaBridge")
.Add(x => x.Accent, "#2f855a")); .Add(x => x.Accent, "#2f855a"));
var shell = cut.Find(".app-shell"); var shell = cut.Find(".app-shell");
@@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void No_accent_emits_no_style() public void No_accent_emits_no_style()
{ {
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway")); var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
Assert.False(cut.Find(".app-shell").HasAttribute("style")); Assert.False(cut.Find(".app-shell").HasAttribute("style"));
} }
[Fact] [Fact]
public void RailFooter_renders_when_supplied() public void RailFooter_renders_when_supplied()
{ {
var cut = RenderComponent<ThemeShell>(p => p var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa") .Add(x => x.Product, "OtOpcUa")
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>")))); .Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess")); Assert.NotNull(cut.Find(".rail-foot .sess"));
@@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext
[Fact] [Fact]
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse() public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
{ {
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa")); var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
// Root is a <details> whose direct <summary> is the hamburger toggle. // Root is a <details> whose direct <summary> is the hamburger toggle.
var shell = cut.Find("details.app-shell"); var shell = cut.Find("details.app-shell");
@@ -62,4 +62,18 @@ public class ThemeShellTests : TestContext
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml); Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
Assert.DoesNotContain("data-bs-target", shell.OuterHtml); Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
} }
[Fact]
public void Closed_shell_keeps_page_body_separate_from_the_toggled_rail()
{
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OEE")
.Add(x => x.Nav, builder => builder.AddMarkupContent(0, "<a href='/oee'>OEE</a>"))
.AddChildContent("<h1>OEE report</h1><input aria-label='Filter' />"));
Assert.False(cut.Find("details.app-shell").HasAttribute("open"));
Assert.NotNull(cut.Find("details.app-shell > #theme-rail > nav"));
Assert.Equal("OEE report", cut.Find("details.app-shell > main.page > h1").TextContent);
Assert.NotNull(cut.Find("details.app-shell > main.page > input"));
Assert.Empty(cut.FindAll("#theme-rail main"));
}
} }
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TreeTableTests : BunitContext
{
private sealed record TestRow(string Key, int Depth, bool HasChildren, string Name) : ITreeRow;
private IRenderedComponent<TreeTable<TestRow>> RenderTreeTable(IReadOnlyList<TestRow> rows,
IReadOnlySet<string>? collapsed = null, Action<string>? toggle = null)
=> Render<TreeTable<TestRow>>(p => p
.Add(x => x.Rows, rows)
.Add(x => x.Collapsed, collapsed ?? new HashSet<string>())
.Add(x => x.Label, row => row.Name)
.Add(x => x.Head, builder => builder.AddMarkupContent(0, "<th>Scope</th><th>Value</th>"))
.Add(x => x.Row, row => builder =>
{
builder.OpenElement(0, "td");
builder.AddContent(1, "Value for " + row.Name);
builder.CloseElement();
})
.Add(x => x.OnToggle, key => toggle?.Invoke(key)));
[Fact]
public void Caller_fragments_and_arbitrary_depth_are_rendered_without_inline_stylesheets()
{
var cut = RenderTreeTable([new("root", 0, true, "Site"), new("deep", 7, false, "Machine <7>")]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Equal(2, cut.FindAll("tbody tr").Count);
Assert.Equal("Value for Machine <7>", cut.FindAll("tbody td")[3].TextContent);
Assert.Contains("Machine <7>", cut.Find(".tt-d7 .tt-name").TextContent);
Assert.Contains("--tt-depth: 7", cut.Find(".tt-d7 .tt-name").GetAttribute("style"));
Assert.Empty(cut.FindAll("style"));
}
[Fact]
public void Leaf_keeps_alignment_without_promising_a_subtree()
{
var cut = RenderTreeTable([new("leaf", 1, false, "Cell")]);
var twist = cut.Find("button");
Assert.Contains("tt-leaf", twist.ClassList);
Assert.True(twist.HasAttribute("disabled"));
Assert.False(twist.HasAttribute("aria-expanded"));
Assert.False(twist.HasAttribute("aria-label"));
Assert.Equal("true", cut.Find("i").GetAttribute("aria-hidden"));
}
[Fact]
public void Toggle_reports_stable_key_and_caller_controls_expansion()
{
string? toggled = null;
var cut = RenderTreeTable([new("site-key", 0, true, "Site")],
new HashSet<string> { "site-key" }, key => toggled = key);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Expand Site", cut.Find("button").GetAttribute("aria-label"));
cut.Find("button").Click();
Assert.Equal("site-key", toggled);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
cut.Render(p => p.Add(x => x.Collapsed, new HashSet<string>()));
Assert.Equal("true", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Collapse Site", cut.Find("button").GetAttribute("aria-label"));
Assert.Contains("bi-chevron-down", cut.Find("i").ClassList);
}
[Fact]
public void Empty_rows_keep_the_header_and_empty_body()
{
var cut = RenderTreeTable([]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Empty(cut.FindAll("tbody tr"));
}
}
@@ -4,6 +4,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="bunit" /> <PackageReference Include="bunit" />
<PackageReference Include="AngleSharp" />
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" /> <PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" /> <PackageReference Include="xunit.runner.visualstudio" />