Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
26 KiB
Code Review — Theme
| Field | Value |
|---|---|
| Library | ZB.MOM.WW.Theme/ |
| Packages | ZB.MOM.WW.Theme (Razor Class Library) |
| Component spec | components/ui-theme/spec/SPEC.md |
| Shared contract | components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md |
| Status | Reviewed |
| Last reviewed | 2026-11-09 (re-review; baseline 2026-06-01 at 5f75cd4) |
| Reviewer | Claude (2026-11-09 re-review, scadaproj#15; baseline: Claude automated) |
| Commit reviewed | 91fb2b4 |
| Open findings | 0 |
Summary
2026-11-09 re-review (91fb2b4, scadaproj#15). Re-reviewed at 91fb2b4 (2026-11-09) against the 2026-06-01 baseline 5f75cd4; the kit is now 0.5.0.
New since the baseline:
- the CSS-only
<details>app shell, with the::details-contentfix and the mobile shell fix; nav-state.js(localStorage persistence ofNavRailSection, active-link reveal, and aMutationObserverbackstop for interactive render modes);ThemeScripts;- the
TechButtonSize/Classextensions; - the stateless
TreeTable<TRow>/ITreeRow; - the upstreamed
.btnsizing block and the 0.5.0 presentation classes.
The C# components hold no state, implement no IDisposable and hold no JS interop references. There is
no MarkupString anywhere, and every caller-supplied string is attribute- or text-encoded by Razor.
LoginCard still delegates ReturnUrl validation and antiforgery to the host, with explicit docs.
TreeTable documents its Bootstrap Icons dependency.
The MutationObserver is never disconnected. That is fine: it is page-lifetime, created once per
document, and the observe→mutate loop is guarded. The <details> shell relies on author display
rules for browsers without ::details-content. That could not be verified without those browsers, so it
is not raised.
All 60 bUnit/static-asset tests pass. There are two new findings:
- Theme-007 (Medium):
TechButtonsilently discards a caller-supplieddisabledattribute. The explicitdisabled="@Busy"is written after the splat, so a button the caller meant to disable renders enabled. Verified with a bUnit probe. - Theme-008 (Low):
nav-state.jsmarks a section as wired with a DOM attribute. Blazor's enhanced-navigation DOM sync strips that attribute, so toggle listeners pile up on each navigation, and the extra listeners save the transient active-link reveal as the user's preference, which is the issue #2 regression. Verified by reading the code and simulating it in jsdom, with a control.
2026-06-01 baseline (5f75cd4). ZB.MOM.WW.Theme is a small, clean, well-scoped Blazor Razor Class Library: ten flat-namespace
SSR components, two stylesheets (theme.css 379 lines, layout.css 191 lines), three vendored
IBM Plex .woff2 fonts, and two public enums. The component C# is correct and minimal — stateless
render logic, no concurrency surface, no secret handling, sensible [Parameter]/EditorRequired
choices, and TreatWarningsAsErrors. The 32 bUnit tests (20 [Fact] + 12 [Theory] cases) exercise
the public render contract of every component plus a static-asset check on the corrected font path.
The standout security-sensitive area — LoginCard (open-redirect + antiforgery) — is handled
correctly by delegating both concerns to the consumer with explicit XML docs and in-markup security
notes, matching the spec. No findings are Critical or High. The findings are concentrated in two
themes: (1) one functional gap — the mobile hamburger relies on Bootstrap's collapse JS, an
undocumented runtime dependency that contradicts the "no JavaScript" framing; and (2) CSS / token
hygiene — a token-fidelity drift on .chip-idle, hardcoded hex values that violate the spec's
"no hardcoded hex" rule, an undefined .rail-ico class, orphan/dead nav CSS, and a near-total
absence of XML docs on the public parameter surface.
Checklist coverage
2026-11-09 re-review at 91fb2b4:
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ☑ | TechButton drops caller disabled → Theme-007. Duplicate toggle listeners after enhanced navigation persist the transient reveal → Theme-008. TreeTable (keyed rows, leaf ARIA omission, invariant-culture depth style) and the NavRailSection slug are correct. |
| 2 | Public API surface & compatibility | ☑ | Additive in 0.2.0–0.5.0: ButtonSize, TechButton.Size/Class, TreeTable/ITreeRow, NavRailSection.Key, LoginCard.Heading, ThemeScripts. No removals. The CaptureUnmatchedValues contract is not fully honoured for disabled (Theme-007). |
| 3 | Concurrency & thread safety | ☑ | Stateless components; the JS is single-threaded with idempotent apply(). No issues found. |
| 4 | Error handling & resilience | ☑ | localStorage access is wrapped in try/catch, and the script degrades when window.Blazor or MutationObserver is absent. No issues found. |
| 5 | Security & secret handling | ☑ | No MarkupString and no raw HTML. Accent goes into a style attribute but is developer-supplied. LoginCard ReturnUrl is echoed into an encoded hidden field, and the open-redirect check stays documented as the host's job. localStorage holds only open/closed flags. No issues found. |
| 6 | Performance & resource management | ☑ | The MutationObserver (whole-document childList/subtree) is page-lifetime and cheap per mutation. Toggle listeners accumulate one per enhanced navigation (Theme-008). No IJSObjectReference or IDisposable to leak. |
| 7 | Spec & shared-contract adherence | ☑ | No-JS shell holds: native <details>, no Bootstrap JS. Tokens and chips are unchanged apart from documented additions. No new drift found. |
| 8 | Packaging, dependencies & project layout | ☑ | 0.5.0 RCL, FrameworkReference only. nav-state.js is shipped and asserted by StaticAssetsTests. AngleSharp is pinned to 1.5.0 in the test project only. No issues found. |
| 9 | Testing coverage | ☑ | 60 tests pass. Gaps: no test passes disabled through TechButton's splat (Theme-007), and nothing exercises nav-state.js under DOM sync (Theme-008). |
| 10 | Documentation & XML docs | ☑ | Theme-006 fix holds (XML docs on every component, parameter and enum). README documents TreeTable's Bootstrap Icons and interactivity requirements. No issues found. |
2026-06-01 baseline checklist (5f75cd4)
| # | Category | Examined | Notes |
|---|---|---|---|
| 1 | Correctness & logic bugs | ☑ | Component render logic correct; open="@Expanded" boolean-attribute binding behaves (tests confirm); chip/variant switch mappings complete. No issues found. |
| 2 | Public API surface & compatibility | ☑ | Flat ZB.MOM.WW.Theme namespace; minimal, intentional; EditorRequired applied; nullable annotations correct; no internal types leak. Surface matches contract. No issues found. |
| 3 | Concurrency & thread safety | ☑ | Stateless components, no shared mutable state, no async/statics/singletons. No issues found (UI kit). |
| 4 | Error handling & resilience | ☑ | No throwing paths; required params default to string.Empty; null RenderFragment slots are guarded (is not null / IsNullOrEmpty). No issues found. |
| 5 | Security & secret handling | ☑ | No secrets/PII. LoginCard open-redirect + CSRF correctly delegated to consumer with docs. Accent interpolates into a style attr but is developer-supplied (not untrusted). No issues found. |
| 6 | Performance & resource management | ☑ | No IDisposable, no allocations of note, fonts vendored once. No issues found. |
| 7 | Spec & shared-contract adherence | ☑ | Component/parameter API matches SPEC §4 and the shared contract. Drifts found: .chip-idle foreground (Theme-002) and "no hardcoded hex" rule (Theme-003). |
| 8 | Packaging, dependencies & project layout | ☑ | net10.0 RCL, single package id ZB.MOM.WW.Theme, FrameworkReference only, version 0.1.0 (inherited from Directory.Build.props), static assets under _content/. No issues found. |
| 9 | Testing coverage | ☑ | 32 tests cover every component's render contract + corrected font path. CSS-class wiring (e.g. .rail-ico, orphan nav CSS) is not asserted, which is how Theme-004/005 slipped through. |
| 10 | Documentation & XML docs | ☑ | README/contract accurate. XML docs present only on LoginCard; absent on the other 9 components, all their parameters, and both enums (Theme-006). |
Findings
Theme-001 — Mobile hamburger toggle silently depends on Bootstrap collapse JS
| Severity | Medium |
| Category | Spec & shared-contract adherence |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor:5 |
Description
ThemeShell renders the narrow-viewport navigation toggle as a Bootstrap collapse trigger:
<button ... data-bs-toggle="collapse" data-bs-target="#theme-rail" aria-expanded="false">, with the
rail wrapped in <div class="collapse d-lg-block" id="theme-rail">. The expand/collapse behaviour
and the aria-expanded state transition are driven entirely by Bootstrap's collapse JavaScript
bundle at runtime. The shared contract states "No global JavaScript … NavRailSection is CSS-only"
and the spec emphasizes static-SSR/no-JS operation, while §5 only requires each app to keep its own
Bootstrap <link> (CSS) — Bootstrap JS is never mentioned. A consumer that includes only
Bootstrap CSS (a reasonable reading of the docs) gets a hamburger button that does nothing below the
lg breakpoint: the rail stays display:none (.collapse) and is unreachable on mobile/narrow
windows. There is no compile-time signal, and the hardcoded aria-expanded="false" never updates
without the JS, so assistive tech is also misinformed. This affects every consumer's small-viewport
navigation.
Recommendation
Either (a) document the Bootstrap-JS runtime requirement explicitly in the README/spec adoption steps
("the side-rail mobile toggle requires Bootstrap's collapse JS bundle"), or (b) make the toggle
JS-independent to match the "no JavaScript" promise — e.g. wrap the rail in a <details>/checkbox
CSS-only disclosure (the same pattern NavRailSection already uses), so collapse works in pure static
SSR. If keeping the Bootstrap-collapse approach, document that aria-expanded requires the JS to stay
accurate.
Resolution
Resolved in 544a6dd, 2026-06-01 — removed the Bootstrap-collapse JS dependency: the shell is now a native CSS-only <details>/<summary> disclosure (no data-bs-*), force-shown on lg+; bUnit test asserts no Bootstrap collapse hooks.
Theme-002 — .chip-idle foreground diverges from the documented token pairing
| Severity | Low |
| Category | Spec & shared-contract adherence |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css:177 |
Description
DESIGN-TOKENS.md ("Status tokens — tinted backgrounds") states the chip classes pair each tinted
background with its matching foreground token — "--ok-bg background with --ok foreground … Used by
.chip-ok, .chip-warn, .chip-bad, .chip-idle". The --idle token (#868e96) is correctly used
for .s-idle and the .conn-pill .dot, but .chip-idle uses color: var(--ink-soft) (#5a6066)
instead of color: var(--idle):
.chip-idle { color: var(--ink-soft); background: var(--idle-bg); border-color: var(--rule-strong); }
So the one idle chip variant does not follow the pairing rule the design-tokens doc spells out. It is
not a contrast or readability regression (--ink-soft is darker than --idle), but code and the
normalized token reference disagree, which is exactly the spec-drift category §7 asks to flag. Note the
.chip-warn foreground is likewise a hardcoded #b56a00 rather than var(--warn) — see Theme-003.
Recommendation
Either change .chip-idle to color: var(--idle) to honor the documented pairing, or, if the darker
--ink-soft ink is the deliberate choice, amend DESIGN-TOKENS.md to record that .chip-idle uses
--ink-soft foreground (as it already does for the Info variant footnote).
Resolution
Resolved in 544a6dd, 2026-06-01 — changed .chip-idle foreground from var(--ink-soft) to var(--idle), honoring the DESIGN-TOKENS pairing rule; static-asset test asserts the pairing.
Theme-003 — Hardcoded hex values in CSS contradict the "no hardcoded hex" rule
| Severity | Low |
| Category | Spec & shared-contract adherence |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/theme.css:174 (and theme.css:96-98,145-150,175-176,251-254,281,290-291,335,348,368-369,377; layout.css:123,128,184) |
Description
SPEC.md §1 and DESIGN-TOKENS.md both assert the rule: "Components carry no hardcoded hex values
— everything references these tokens" / "no hardcoded hex values appear in component markup or CSS".
In practice both stylesheets contain numerous literal hex colors outside the :root token block —
chip/agg border tints (#c6e6cd, #eec3c3, #efd6a6, #bfe3c6, #f0d9ab, #f0c0c0), chip/notice
foregrounds (#b56a00, #8a5a00), zebra/hover fills (#fbfbf9, #f3f6fd, #eef2fc), and the
info/dir tints (#e7ecfb, #cdd9f7). These are derived shades (lighter borders, hover washes) with no
corresponding token, so they are not re-themable via the token block and any per-app accent override
will not reach them. The rule as written is therefore aspirational, and the DESIGN-TOKENS.md Info
footnote itself already references a raw #e7ecfb, so the doc partially contradicts its own rule.
Recommendation
Reconcile code and doc: either promote the recurring derived shades to named tokens (e.g.
--ok-border, --hover-bg, --info-bg/--info-border) and reference them, or soften the spec wording
to "no hardcoded hex in component markup; CSS derives border/hover shades from tokens where
practical" so the rule matches reality. This keeps the "token changes are breaking" SemVer guarantee
honest.
Resolution
Resolved in 544a6dd, 2026-06-01 — promoted every derived border/ink/wash shade to named :root tokens (--ok-border, --warn-ink, --info-bg, --hover-bg, --active-bg, --zebra-bg, …) and referenced them; no hex literal remains outside :root in either stylesheet, asserted by a new test.
Theme-004 — NavRailItem emits a .rail-ico span that no stylesheet defines
| Severity | Low |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailItem.razor:4 |
Description
When the optional Icon slot is supplied, NavRailItem wraps it: <span class="rail-ico">@Icon</span>.
Neither theme.css nor layout.css defines a .rail-ico rule (confirmed by grep). The icon therefore
renders as an unstyled inline span — no sizing, alignment, or gap relative to the label text — so an
icon + text rail link will not line up the way the other rail styling implies. It is not a crash, but
the component ships a class hook with no backing style, which is a latent presentation bug for any
consumer that uses the Icon parameter (and none of the 32 tests cover the icon path).
Recommendation
Add a .rail-ico rule to layout.css (e.g. display:inline-flex; width:1rem; margin-right:0.4rem; color:var(--ink-faint);) alongside the existing .rail-link rules, and add a bUnit test asserting the
.rail-ico span is emitted when Icon is supplied.
Resolution
Resolved in 544a6dd, 2026-06-01 — added a .rail-ico rule to layout.css (inline-flex, sized, gapped, --ink-faint); bUnit tests assert the span is emitted when Icon is supplied and omitted otherwise.
Theme-005 — Orphan and unstyled nav CSS classes in layout.css
| Severity | Low |
| Category | Documentation & XML docs |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/css/layout.css:75 (.rail-eyebrow), :103 (.rail-eyebrow-chevron); ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/NavRailSection.razor:5 (.rail-eyebrow-label) |
Description
NavRailSection renders <summary class="rail-eyebrow-toggle"><span class="rail-eyebrow-label">@Title</span></summary>
and layout.css provides the actual collapse chevron via .rail-section > summary::before (lines
178-181). That leaves several mismatched class hooks: .rail-eyebrow (line 75, non-toggle) and
.rail-eyebrow-chevron (lines 103-108) are defined in CSS but emitted by no component (dead CSS),
while .rail-eyebrow-label is emitted by the component but has no CSS rule (it merely inherits from
.rail-eyebrow-toggle). The result is two parallel chevron mechanisms (the styled-but-unused
.rail-eyebrow-chevron and the actually-used ::before) and stylesheet cruft that misleads future
maintainers about how the section header is themed.
Recommendation
Remove the dead .rail-eyebrow and .rail-eyebrow-chevron rules (or wire .rail-eyebrow-chevron into
NavRailSection and drop the ::before approach — pick one), and either add a .rail-eyebrow-label
rule or drop the now-redundant wrapper span. Keeping one chevron mechanism removes the ambiguity.
Resolution
Resolved in 544a6dd, 2026-06-01 — removed the dead .rail-eyebrow and .rail-eyebrow-chevron rules and dropped the redundant .rail-eyebrow-label wrapper span in NavRailSection, leaving one chevron mechanism (summary::before).
Theme-006 — Public component/parameter surface lacks XML documentation
| Severity | Low |
| Category | Documentation & XML docs |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/ThemeShell.razor:23 (and BrandBar.razor, NavRailItem.razor, NavRailSection.razor, StatusPill.razor, TechButton.razor, TechCard.razor, TechField.razor, ThemeHead.razor, ButtonVariant.cs, StatusState.cs) |
Description
This is a packaged library (IsPackable=true, 0.1.0) consumed by three separate apps, and
REVIEW-PROCESS.md §2.10 calls for "XML doc accuracy and presence on the public API (these are
libraries — public docs matter)." Only LoginCard carries XML docs (on ReturnUrl and ChildContent).
The other nine components, all of their [Parameter] properties, and both public enums
(ButtonVariant, StatusState) have no /// documentation, so consumers get no IntelliSense/tooltip
help on the parameter contract (e.g. that ThemeShell.Accent overrides the --accent token, that
NavRailItem.Match defaults to Prefix, or what each StatusState/ButtonVariant member maps to).
The README and shared contract document all of this, but that prose is not surfaced at the call site.
Recommendation
Add brief /// summaries to the public components, their parameters, and the two enum types — at
minimum the non-obvious ones (ThemeShell.Accent, NavRailItem.Match, TechButton.Busy,
TechCard.Title vs Header precedence, and each enum member). Consider enabling
GenerateDocumentationFile so missing-doc warnings surface under the existing TreatWarningsAsErrors.
Resolution
Resolved in 544a6dd, 2026-06-01 — added /// summaries to all nine remaining components, every public [Parameter], and both enums (ButtonVariant, StatusState); enabled GenerateDocumentationFile (CS1591 left off the error set for Razor-generated members) so docs build under TreatWarningsAsErrors with 0 warnings.
Theme-007 — TechButton silently discards a caller-supplied disabled attribute
| Severity | Medium |
| Priority | P2 |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/Components/TechButton.razor:3 |
Description
The button is rendered as
<button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">.
In Blazor, an attribute written after an @attributes splat overrides the same-named splatted
attribute. disabled is not a declared parameter, so a caller's disabled="…" is captured into
AdditionalAttributes and then overwritten by disabled="@Busy". When Busy is false, Blazor drops
the attribute entirely. 0.5.0 added a merge for class (ButtonClass folds in the splatted class),
but disabled was not merged. The XML doc on AdditionalAttributes says "arbitrary additional HTML
attributes splatted onto the underlying <button>". The existing test
Class_composition_keeps_busy_type_and_click_behavior covers only the opposite direction
(Busy=true beating caller disabled=false).
Failure scenario
A page renders <TechButton Variant="ButtonVariant.Danger" disabled="@(!CanDelete)" @onclick="Delete">Delete</TechButton>
for a user who cannot delete, or for a form that is not yet valid. The output is
<button type="button" class="btn btn-danger">Delete</button>, with no disabled, so the button is
clickable and fires Delete. A bUnit probe in the scratchpad confirmed this:
Render<TechButton>(p => p.AddUnmatched("disabled", true)) produced
<button type="button" class="btn btn-primary">Delete</button>. No current family call site passes
disabled (20 .razor files use TechButton), so this is latent. It is still a natural use of the
documented splat, and any server-side authorization check would be the only backstop.
Recommendation
Compute the effective state as Busy || callerDisabled, where callerDisabled is read from
AdditionalAttributes["disabled"] with the same case-insensitive lookup used for class. Better still,
add an explicit [Parameter] public bool Disabled. Then render disabled="@(Busy || Disabled)". Add a
bUnit test for caller disabled=true with Busy=false.
Resolution
Resolved 2026-11-09 in the commit tagged (scadaproj#44) (Theme 0.5.1, unpublished) — TechButton renders disabled="@(Busy || CallerDisabled)", where CallerDisabled reads the splatted disabled case-insensitively with Blazor's own splat semantics (false/null omit it; true, "disabled", "" render it). Chose the lookup over a new [Parameter] bool Disabled because Blazor binds parameters case-insensitively: a bool parameter would turn an existing string disabled="disabled" splat into a runtime cast failure. Regression tests TechButtonExtensionsTests.Caller_disabled_is_honoured_when_not_busy (true, "disabled", ""), Caller_disabled_false_leaves_the_button_enabled and Caller_disabled_toggles_off_on_rerender; the first and third fail without the fix.
Theme-008 — nav-state.js "wired" guard is a DOM attribute that Blazor enhanced-navigation DOM sync strips, so toggle listeners accumulate and the transient active-link reveal is persisted
| Severity | Low |
| Priority | P3 |
| Category | Correctness & logic bugs |
| Status | Resolved |
| Location | ZB.MOM.WW.Theme/src/ZB.MOM.WW.Theme/wwwroot/js/nav-state.js:7, :17-18, :39, :65-66 |
Description
wire() marks a section as initialised with the attribute data-zbnav-initialized. apply() uses
hasAttribute(INIT_ATTR) as its only guard against adding a second toggle listener (commit 9e4dedc,
"guard nav-state.js against duplicate toggle listeners"). After an enhanced navigation, Blazor's DOM
sync (blazor.web.js 10.0.1, its attribute synchronisation step) keeps the existing <details> element
but removes every attribute that is not in the newly server-rendered HTML. That includes
data-zbnav-initialized, which the server never renders. Then enhancedload calls apply(), which
finds no marker and calls wire() again, adding another toggle listener to the same element. After N
navigations the element carries N+1 listeners.
The transient-reveal protection assumes a single listener. The first listener consumes
data-zbnav-transient and returns. Every later listener sees no flag and writes
localStorage[zbnav:<key>].
Failure scenario
- A user collapses the "Admin" rail section;
zbnav:adminis saved as"0". - They move around the app by enhanced (static-SSR) navigation.
- They reach a page whose active link lives inside "Admin", for example from a link in the page body or a redirect.
apply()reveals the section and flags it transient.
The duplicate listeners then overwrite the saved preference with "1", so the section stays expanded on
every later page. This is the issue #2 bug the transient flag exists to prevent.
A jsdom simulation reproduced it, running the shipped nav-state.js, stripping the attribute the way
DOM sync does, and firing enhancedload 3 times:
- one collapse produced 4
setItemwrites; - the reveal produced 3 writes of
"1", leavingsaved=1; - the control run, with the attribute not stripped, produced 1 collapse write and 0 reveal writes,
leaving
saved=0.
Recommendation
Track wiring in a JS-side structure that DOM sync cannot touch, for example a module-level
WeakSet of wired elements or an expando property (el.__zbnavWired = true), instead of a DOM
attribute. Add a test that strips attributes, re-runs apply(), and asserts one write per toggle.
Resolution
Resolved 2026-11-09 in the commit tagged (scadaproj#48) (Theme 0.5.2, unpublished) — nav-state.js now records wired sections in a JS WeakSet (expando fallback) instead of reading the data-zbnav-initialized attribute that Blazor's DOM sync strips; the attribute is still written but never used as a guard. Regression test NavStateScriptTests runs nav-state.test.js under Node with a minimal DOM stub: after 3 simulated enhanced navigations (attribute stripped) there must be 1 toggle listener, expand+collapse must be 2 writes, and the transient active-link reveal must write nothing and leave zbnav:admin=0 (failed with 4 listeners without the fix). The test passes vacuously on machines without Node (noted in its summary).