Files
scadaproj/code-reviews/Theme/findings.md
T

394 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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-content` fix and the mobile shell fix;
- `nav-state.js` (localStorage persistence of `NavRailSection`, active-link reveal, and a
`MutationObserver` backstop for interactive render modes);
- `ThemeScripts`;
- the `TechButton` `Size`/`Class` extensions;
- the stateless `TreeTable<TRow>`/`ITreeRow`;
- the upstreamed `.btn` sizing 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):** `TechButton` silently discards a caller-supplied `disabled` attribute. The
explicit `disabled="@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.js` marks 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. |
<details><summary>2026-06-01 baseline checklist (<code>5f75cd4</code>)</summary>
| # | 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). |
</details>
## 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)`:
```css
.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.
<!-- 2026-11-09 re-review (scadaproj#15) findings below. -->
### 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**
1. A user collapses the "Admin" rail section; `zbnav:admin` is saved as `"0"`.
2. They move around the app by enhanced (static-SSR) navigation.
3. They reach a page whose active link lives inside "Admin", for example from a link in the page body
or a redirect.
4. `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 `setItem` writes;
- the reveal produced 3 writes of `"1"`, leaving `saved=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).