Files
mxaccessgw/docs/plans/2026-08-11-dashboard-ui-sweeps.md
T
Joseph Doherty 01033d7aaf
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 2m18s
ci / java (push) Successful in 2m24s
ci / portable (push) Successful in 8m51s
fix(dashboard): admin-UI cleanup sweep
Family-wide admin-UI cleanup pass (scadaproj admin_ui_cleanup.md) applied to the
Blazor dashboard. Behaviour is unchanged throughout — no @onclick, disabled,
binding, auth gate, or arm->confirm flow was touched.

Uncontrolled error text is now truncated at the render site. Fault messages,
Galaxy load errors, and browse-tree load failures were rendered in full into
fixed-width table cells, where a long exception string blows out the column.
Each site gets DashboardDisplay.Abbreviate plus a title attribute carrying the
untruncated text, so nothing becomes unreachable. Abbreviate is length-checked
rather than a bare range slice: `value[..n]` on a shorter string throws and
takes the whole page render down with it. The two detail views whose entire
purpose is to show one fault in full — SessionDetailsPage and GalaxyPage's
Last Error — are deliberately left untruncated.

Two classes referenced from markup had no definition anywhere in the sheet.
.browse-stale-banner was inert; .tree-load-status was a real visual defect —
loading and failed-to-load rows sit among .tree-row siblings and carry the same
leading .tree-toggle-empty spacer, but that spacer only takes its width as a
flex item, so without a flex container those rows lost their indent.

Confirm/cancel pairs in ConfirmDialog and the API-key create form are now
btn-groups with role="group" and an aria-label, replacing margin-spaced loose
buttons.

Removes a paragraph on GalaxyPage naming internal RPCs (DiscoverHierarchy,
GetLastDeployTime) — implementation detail with no meaning to a dashboard
operator.

Verified in a real browser, not bUnit: full build clean, 879/879 tests, and a
live gate against a running dashboard with a genuine ~250-char SqlClient
exception as the erroring row. Results per check, including the checks that
could NOT be exercised without an x86 worker, are recorded in
docs/plans/2026-08-11-dashboard-ui-sweeps.md.

That plan doc also records a correction: this app is NOT Bootstrap-free. The
sweep brief said it was, citing the scadaproj index; libman.json pins
bootstrap 5.3.3 and App.razor:7 links it ahead of the theme. The stale claim had
already cost this app one skipped family sweep (scadaproj#2, the /admin/secrets
modal), so that modal was live-gated here too and passes.
2026-08-11 05:48:22 -04:00

24 KiB
Raw Blame History

Dashboard UI cleanup sweep (2026-08-11)

Runs the family admin-UI cleanup playbook (../scadaproj/admin_ui_cleanup.md) against the MXAccess Gateway Blazor dashboard. Third app in the family after the ignitionoee router and the OtOpcUa AdminUI.

0. Platform correction — this app is not Bootstrap-free

The umbrella index describes mxaccessgw as the family's Bootstrap-free app. That is wrong, and every Bootstrap-dependent recipe in the playbook applies here unchanged. What actually ships:

Layer Evidence
Bootstrap 5.3.3, self-hosted libman.json:5; wwwroot/lib/bootstrap/css/bootstrap.min.css
Linked first in the head Dashboard/Components/App.razor:7
ZB.MOM.WW.Theme 0.3.1 at discovery, 0.4.0 after §6 ZB.MOM.WW.MxGateway.Server.csproj <PackageReference>; <ThemeHead /> at App.razor:8
App stylesheet, loaded last App.razor:9wwwroot/css/site.css
Bootstrap JS bundle App.razor:14

What CLAUDE.md actually forbids is Blazor component libraries (MudBlazor, Radzen, FluentUI) — not Bootstrap's CSS/JS. No Bootstrap was introduced by this sweep.

Playbook §2 foundation item 1 (app stylesheet after <ThemeHead /> so it wins the cascade) was therefore already satisfied before the sweep.

1. Discovery

1a. Foundation / CSS audit

Shipped-vs-used class matrix. Every class in Dashboard/Components/**/*.razor checked against theme 0.3.1 staticwebassets/css/{theme,layout}.css, bootstrap.min.css, and site.css. Three classes are defined nowhere:

Ghost class Site Consequence Verdict
tree-load-status Shared/BrowseTreeNodeView.razor:42, :49 Real visual defect. The row's indent spacer is <span class="tree-toggle tree-toggle-empty">; .tree-toggle sets flex:none;width:1.1rem, which only takes effect on a flex item. .tree-row/.tree-attr are flex; this container is an undefined block, so the span stays inline and width is ignored — " Loading…" and "Failed to load: …" render flush left, out of alignment with every sibling row. Define it
browse-stale-banner Pages/BrowsePage.razor:78 The banner carries @onclick="ClearStaleBanner" with no pointer affordance and no styling of its own — a click-to-dismiss control that does not look clickable. Define it
tree-node Shared/BrowseTreeNodeView.razor:15 Structural wrapper only; nothing needs to style it. A no-op, but a deliberate one. Leave

(Reported as ghosts by the raw extractor but false positives: h-100 at Shared/MetricCard.razor:1 — Bootstrap-defined, mangled by the extractor's handling of the inline @(...) class expression.)

Scoped-CSS bundle. N/A — the project contains zero *.razor.css files, so no *.bundle.scp.css is emitted and nothing is missing from the head. (This was OtOpcUa's finding; it does not exist here.)

Phantom CSS custom properties. Zero. Dashboard/Components/ contains no var(--…) at all — tokens are used only from site.css, and every one of them (--ink, --ink-soft, --ink-faint, --card, --rule, --rule-strong, --mono, --accent, --accent-deep, --ok, --ok-bg, --bad, --bad-bg, --warn, --warn-bg, --idle, --idle-bg) resolves against theme 0.3.1. This app has the OtOpcUa-clean result, not the router's.

Button sizing — the one real foundation defect. Theme 0.3.1 ships no .btn rule (confirmed: .btn appears in layout.css only inside a comment). But site.css:187 does, and it sets font-size directly rather than through Bootstrap's variable:

.btn { border-radius: 5px; font-size: 0.82rem; font-weight: 500; white-space: nowrap; }

Bootstrap renders .btn { font-size: var(--bs-btn-font-size) }, and .btn-sm / .btn-group-sm > .btn size themselves purely by redefining that variable (.btn-sm{--bs-btn-font-size:0.875rem}). A literal font-size on .btn at equal specificity, loaded later, wins over the variable-driven declaration for every button — so btn-sm and btn-group-sm are font-size no-ops app-wide and small buttons differ from full-size ones by padding alone. This is the same class of defect the other two apps hit from the opposite direction (no .btn rule at all), and it takes the same fix.

Dark scheme. Theme 0.3.1 is light-only; site.css makes no prefers-color-scheme / data-bs-theme claim, and its header comment ("Layers over theme.css … every colour resolves to a theme.css token") is accurate. Nothing to correct.

1b. Button inventory

grep -rn "btn-group" returns three — this app already uses the convention where it matters:

Site Members State
Pages/ApiKeysPage.razor:189 Rotate / Revoke, or Delete Correct — btn-group btn-group-sm, no per-button btn-sm, @if inside the group
Pages/SessionsPage.razor:90 Close / Kill Correct
Pages/SessionDetailsPage.razor:34 Close session / Kill worker Correct

Adjacent related buttons not yet grouped (both are feet, the playbook's named case):

Site Members Fix
Shared/ConfirmDialog.razor:17,22 Cancel + @ConfirmButtonClass confirm, in a modal-footer Wrap in btn-group
Pages/ApiKeysPage.razor:136,137 Save (type="submit") + Cancel, in the create-key card body Wrap in btn-group btn-group-sm; fold the two btn-sm and drop the me-1 spacer

Refused / not candidates:

  • Pages/WorkersPage.razor:74 — a lone Kill button. Nothing to group.
  • Pages/ApiKeysPage.razor:22 — lone page-head "Create API Key".
  • Pages/BrowsePage.razor:109 (Clear all) and :167 (Remove) — lone buttons.
  • Shared/BrowseTreeNodeView.razor:19 .tree-toggle — a bare expander, deliberately unstyled as a button; MainLayout.razor:32 Sign Out / :36 Sign In are the theme's rail-btn, one per auth branch and mutually exclusive.

Row actions styled as links: none. Every action in the app is already a real <button>; the only <a> in Dashboard/Components/ is MainLayout.razor:36, which navigates. btn-link: zero uses. &middot; action separators: zero — the · occurrences (BrowsePage.razor:54,102,106,287, BrowseTreeNodeView.razor:71, AlarmsPage.razor:220) all separate metadata facts or serve as a bullet glyph, never actions. Inline width: sizing hacks: zero. py-0: zero.

Arm→confirm flows (restyle-only; the two-step must survive):

Page Flow
SessionsPage Close / Kill → ConfirmDialogConfirmPendingAsync
SessionDetailsPage Close session / Kill worker → ConfirmDialog
WorkersPage Kill → ConfirmDialog
ApiKeysPage Rotate / Revoke / Delete → ConfirmDialog; Create → modal form

Size mismatches within a group: none.

1c. Prose inventory (DELETE / KEEP / RELOCATE)

Site Text Verdict
Pages/GalaxyPage.razor:134-138 "Browse data is served by the galaxy_repository.v1.GalaxyRepository gRPC service. Clients call DiscoverHierarchy for the full tree and GetLastDeployTime to detect redeployments." DELETE — unconditional client-API documentation on an operator page. Every fact is already in docs/GalaxyRepository.md (service name at :44, GetLastDeployTime at :49, DiscoverHierarchy at :50). Plain delete, no relocation needed.
Pages/AlarmsPage.razor:151-154 "Cleared alarms are not retained — this list reflects only alarms currently Active or ActiveAcked, refreshed every 3 seconds." KEEP — decodes what the list contains and does not contain; the operator cannot infer "cleared alarms are absent" from the data.
Pages/AlarmsPage.razor:26-30 Alarms-disabled banner citing MxGateway:Alarms:Enabled KEEP — conditional state banner. Config key verified against the options class: GatewayOptions.AlarmsAlarmsOptions.Enabled, present in appsettings.json.
Pages/BrowsePage.razor:33-35, :41, :120-124 Empty states KEEP
Pages/BrowsePage.razor:70, :90 "Showing the first N matches — refine the filter." / "Double-click a tag, or right-click for the menu." KEEP — truncation notice, and the only decoder of two interactions that have no visible affordance.
Pages/SessionDetailsPage.razor:115-118 "Waiting for events. The dashboard mirrors the session's gRPC event stream — events appear here only while a gRPC client is also consuming this session's events." KEEP — explains an empty state that otherwise reads as a bug.
Pages/GalaxyPage.razor:33-38 Unknown-status empty state KEEP

Stale-claim hunt (wrong facts outrank style): none found. No milestone labels ("F8/F9 pending", "Batch 2"), no dead repo hyperlinks, no superseded architecture claims. Every config key cited in markup resolves — MxGateway:Alarms:Enabled (above) is the only one. Also checked and cleared: SettingsPage.razor:32 renders Ldap.ServiceAccountPassword, but Configuration/GatewayConfigurationProvider.cs:30 substitutes RedactedValue before the snapshot is built, so no credential reaches the page.

1d. Density / layout scan (per page, not per file)

Structural mitigations already present app-wide: site.css:137 caps .dashboard-table td at max-width: 26rem with overflow-wrap: break-word, so the OtOpcUa /hosts failure mode (one long exception widens the table until the actions column scrolls off) cannot happen here. Every table is also inside .table-responsive. panel-head appears zero times, so the "sections running together" tell does not fire — each group is its own section.dashboard-section card already.

Residual finding — unbounded free-text columns. The 26rem cap converts the horizontal blowout into vertical blowout: a multi-line exception makes one row several times taller than its neighbours and pushes the rest of the table off-screen.

Site Column Bound to
Shared/FaultList.razor:28 Message @fault.Message — worker/COM fault text, uncontrolled
Pages/SessionsPage.razor:86 Fault session.LastFault
Pages/WorkersPage.razor:70 Fault worker.LastFault
Shared/BrowseTreeNodeView.razor:51 (tree row) @Node.LoadErrorworst case: .tree-attr/.tree-row siblings are white-space: nowrap inside a fixed-height scroller, so a long browse error stretches the left pane horizontally
Pages/DashboardHome.razor:47 Galaxy panel Snapshot.Galaxy.LastError on the compact overview

Deliberately not truncated (the full text must stay reachable — playbook rule): SessionDetailsPage.razor:84 "Last fault" and GalaxyPage.razor:47 "Last Error" are both full-width rows on the drill-down page each summary links to.

Not firing: identity slamSessionsPage.razor:75-81 puts a worker pid next to a status chip, which the playbook explicitly permits ("chips stay with the name line"); no cell renders two identifiers. Inline full-width expander rows — zero colspan detail rows in the app.

1e. Tree tables

No adoption. BrowsePage + BrowseTreeNodeView is a lazy-loading nav/picker tree with a context menu and no columns — the rubric's explicit "different animal, leave it alone". No table in the app flattens hierarchy through a path-prefix column, hand-rolled rowspan, indent-by-padding, or faked group-header rows; Sessions/Workers/Events/Alarms/Galaxy are flat fact lists and Top Templates is rank-ordered (a tree would destroy load-bearing ordering). Porting the router's TreeTable here would create an orphan.

Guard tests

Checked for source-scan tests pinning dashboard markup: none. The only .razor reference in the test project is a prose comment (Gateway/GatewayApplicationTests.cs:116). Nothing to re-pin.

2. Changes

Foundation

  1. wwwroot/css/site.css — button sizing via Bootstrap CSS variables. Replace the literal font-size on .btn with --bs-btn-* overrides and add the btn-sm / btn-group-sm block, so the small-button distinction works again. Box properties are not redefined wholesale — border-radius: 5px stays literal because the three existing btn-groups already render seamed under it (.btn-group > .btn:not(:first-child) outranks .btn). Carries the header comment the other two apps carry: delete the local copy when a ZB.MOM.WW.Theme release ships a .btn rule. That release shipped the same day — see §6.
  2. site.css — define .tree-load-status as a flex row matching .tree-row, restoring the indent alignment of the tree's loading/error rows.
  3. site.css — define .browse-stale-banner with cursor: pointer and tightened padding, so the click-to-dismiss banner reads as clickable. No behavior change.
  4. Dashboard/Components/DashboardDisplay.cs — add Abbreviate(string?, int). Safe helper (length-checked, never a raw [..n] slice — the OtOpcUa #504 failure), - for null/whitespace, ellipsis on truncation.

No prose relocation task: the single DELETE is already docs-covered.

Page batches

Batch A — free-text truncation (Shared/FaultList.razor, Pages/SessionsPage.razor, Pages/WorkersPage.razor, Shared/BrowseTreeNodeView.razor, Pages/DashboardHome.razor): Abbreviate + full text on title, at the five sites in 1d.

Batch B — button feet (Shared/ConfirmDialog.razor, Pages/ApiKeysPage.razor): the two btn-group wraps from 1b. @onclick, disabled, type="submit", and both arm→confirm flows unchanged.

Batch C — prose (Pages/GalaxyPage.razor): delete :134-138.

3. Verification

Build + tests

  • dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx — 0 warnings, 0 errors (TreatWarningsAsErrors=true).
  • dotnet test src/ZB.MOM.WW.MxGateway.Tests — 879/879 passed, the macOS baseline.

Post-merge greps

  • Zero phantom var(--…) usages (there were none to begin with).
  • Zero ghost classes remaining except the deliberate tree-node.
  • btn-group count 3 → 5, matching the 1b inventory.
  • Zero &middot; action separators, zero inline width: hacks (none existed).
  • Scoped-bundle link: N/A, no *.razor.css.

Live browser gate

Recorded in §4 below.

4. Live browser gate — results

Run against a local dotnet run of the gateway on macOS (http://localhost:5120, the launch profile's port), not windev — the sweep is CSS/markup-only and redeploying the shared NSSM service was not warranted. Rig configuration (env overrides only, no committed config touched): Dashboard:DisableLogin=true (to reach the Admin-only surfaces), Ldap:Enabled=false, Authentication:Mode=Disabled, SQLite + Galaxy snapshot paths under the session scratchpad, ApiKeyPepper set locally. The rig was stopped and its throwaway auth DB deleted afterwards.

Realistic erroring data: the Galaxy SQL Server (localhost, ZB) does not exist on macOS, so every refresh fails with a genuine 250-character Microsoft.Data.SqlClient SSPI exception, and the alarm monitor's auto-opened session fails on the missing x86 worker — both real, uncontrolled error text of exactly the kind §1d is about.

# Check Result Evidence
1 Computed-style probes PASS body 14.4px; .btn 13.6px (0.85rem); .btn-sm 12.48px (0.78rem) — the small/full distinction is live, where before the fix both computed to 13.12px. btn-group-sm members: both 12.48px, first member's right radius 0px, last member's 5pxseams intact, confirming the literal border-radius does not break group machinery. .tree-load-statusdisplay: flex; .browse-stale-bannercursor: pointer. Stylesheet order: bootstrap → theme → layout → site.css last. The theme's own .rail-toggle btn-sm also picks up 12.48px, so the block reaches theme-owned buttons, not just app markup.
2 Every page leads with data, no unconditional doc block above the fold PASS All 9 routes' first blocks are dashboard-page-headermetric-grid / dashboard-section / state alert. /galaxy now ends at the Sync Info table — the deleted client-API paragraph is gone.
3 Seamed btn-groups; destructive members red PASS /apikeys row actions render Rotate + Revoke as one seamed group with Revoke in btn-outline-danger red. The ConfirmDialog foot renders Cancel + a solid red Revoke seamed together.
4 Arm→confirm: arm → confirm UI appears → Cancel → verify disarm PASS Revoke → "Revoke API key?" dialog → Cancel. Post-cancel probe: dialogOpen:false, backdrops:0, key status still Active, row action back to btn-outline-danger outline. No write completed. Save/Cancel in the create-key modal also verified: Cancel closes without creating; Save creates (validation message surfaced correctly on the first attempt with a missing display name).
5 Anything the scoped bundle resurrected N/A No *.razor.css in the project — see 1a.
6 KEEP-list legends / hints / empty states still render PASS /browse: both empty states plus the "Right-click a tag … Add to subscription panel" hint. /alarms: the "Cleared alarms are not retained …" legend under the table. /galaxy: Object Categories / Top Templates empty states.
7 Density with realistic data; sections as separate cards; no horizontal scroll PASS / renders the SQL error truncated to one line with ; /galaxy renders the same error in full (the drill-down) — the intended split is visible side by side. /apikeys with a 3-entry read_subtrees constraint wraps inside the column with no table overflow. documentElement.scrollWidth == clientWidth on every page walked. Each group is its own card.

Not exercisable on this rig (recorded, not claimed as passing): the Sessions / Workers / Recent Faults truncation call sites and the BrowseTreeNodeView load-error row need a registered session, which requires the x86 worker — on macOS the launch fails before the session is ever registered, so those tables stay empty. Their shared helper and the container CSS were both verified live by other means (the / Galaxy error for Abbreviate, the computed-style probe for .tree-load-status). Re-check them on windev the next time that service is redeployed.

Side note surfaced, not acted on: SettingsPage renders LDAP service password as [redacted] in the browser — confirming GatewayConfigurationProvider's substitution end to end.

5. Follow-up gate: /admin/secrets delete modal (scadaproj#2)

Not part of the sweep. Run because the same stale "mxgw is Bootstrap-free" claim corrected in §0 was the stated reason this app was excused from the 2026-07-19 family-wide /admin/secrets modal sweep: ConfirmDeleteModal shipped a bare class="modal", which Bootstrap 5's .modal{display:none} made permanently invisible on every Bootstrap host. The exemption's reasoning was false, so mxgw was in scope and its delete modal had never been live-gated. 8f7ee49 bumped ZB.MOM.WW.Secrets.Ui to 0.2.3, which is supposed to carry the fix — but "supposed to" is what the original claim was.

Static check — decoding the UTF-16 literals out of ZB.MOM.WW.Secrets.Ui.dll 0.2.3 shows the component now inlines its own <style> under a private namespace: .zb-secrets-modal with display: flex; position: fixed; inset: 0; z-index: 1081, over a .zb-secrets-modal-backdrop at z-index: 1080. No bare modal class.

Live check — local rig, scratch secrets store (Secrets:SqlitePath + ZB_SECRETS_MASTER_KEY under the session scratchpad), throwaway secret uisweep/throwaway, delete armed but never confirmed:

Probe Result
Modal root classes zb-secrets-modal — and document.querySelector('.modal') is null, so Bootstrap's .modal{display:none} has nothing to bite
Modal computed style display: flex, position: fixed, z-index: 1081, visibility: visible, opacity: 1, box 1600×827
Backdrop display: block, position: fixed, z-index: 1080, rgba(15,18,20,.45), covers the full viewport
Confirm button hit-testable elementFromPoint at its centre returns the button itself — not covered by the backdrop
Cancel → disarm modal and backdrop both removed; secret still listed; no delete performed

PASS. The 0.2.3 fix holds on this Bootstrap host. The rig was stopped and both throwaway stores (secrets + auth) deleted.

6. Follow-up: ZB.MOM.WW.Theme 0.3.1 → 0.4.0 (dependency bump, not UI cleanup)

0.4.0 upstreams the button-sizing block this sweep installed locally, written against the finding in §1a — the kit sizes only through --bs-btn-* variables and carries a comment in layout.css saying why, so nobody simplifies it back into literals.

  • ZB.MOM.WW.MxGateway.Server.csproj: 0.3.10.4.0. No Directory.Packages.props in this repo; the version lives on the PackageReference.
  • site.css: the four --bs-btn-* overrides and the whole .btn-group-sm > .btn, .btn-sm rule deleted — layout.css now supplies them and <ThemeHead /> emits it before site.css.

Trimmed, not deleted wholesale. The local .btn rule also carried border-radius: 5px, font-weight: 500, white-space: nowrap, which predate the sweep and which 0.4.0 does not ship — it upstreamed sizing only. Removing the block entirely would have silently dropped three app-specific declarations. What remains is .btn { border-radius: 5px; font-weight: 500; white-space: nowrap; } — shape, not size. border-radius deliberately stays a literal rather than --bs-btn-border-radius, because .btn-sm redefines that variable and small buttons would shrink to the Bootstrap small radius.

Verification

  • Build: 0 warnings, 0 errors. Suite: 879/879.
  • Computed-style probe re-run on 0.4.0 with the local block gone: .btn 13.6px, .btn-sm 12.48px — identical to the pre-bump numbers, so the kit rule reaches. btn-group-sm members both 12.48px with the seam intact (first member right radius 0px, last 5px). Retained declarations confirmed live on both sizes: radius 5px, weight 500, nowrap.
  • Cascade confirmed by enumerating document.styleSheets: --bs-btn-font-size is now declared in exactly two sheets — bootstrap.min.css (1rem / 0.875rem) and layout.css (.85rem / .78rem). site.css no longer declares it, so the duplicate is gone.

Tree-hygiene note. The first suite run after the bump failed GatewayTreeHygieneTests.SourceTree_ContainsNoSqliteDatabaseFiles — unrelated to the bump. The §4 rig's first start used the default relative Secrets:SqlitePath (mxgateway-secrets.db), which resolved against the server project directory and left a DB in the source tree; only the §5 run redirected it to the scratchpad. The file was untracked, was deleted, and the suite went green. The §4/§5 cleanup notes were therefore incomplete as originally written — the scratchpad copies were removed but this one was missed. The hygiene test is what caught it, which is what it exists for.

7. Follow-up: ZB.MOM.WW.Theme 0.4.0 → 0.4.1 (pin-only, no rendering change here)

0.4.1 was published the same day to fix .rail-btn-block, a modifier shipped broken in 0.4.0 (display: block; width: auto fills for an <a> but shrink-wraps a <button>; now width: calc(100% - 1.2rem) with box-sizing: border-box, accounting for .rail-btn's side margins). Bumped for family-pin alignment.

Why nothing needed re-verifying. Confirmed rather than assumed:

  • diff of the two restored packages: theme.css byte-identical; layout.css differs only inside the .rail-btn-block rule and its comment. The .btn sizing block is unchanged, so the §6 measurements (.btn 13.6px, .btn-sm 12.48px) still hold and the probe was not re-run.
  • This app does not use rail-btn-block. It does carry the exact element pair the bug turned on — MainLayout.razor:32 is a form-submit <button class="rail-btn"> (Sign Out) and MainLayout.razor:36 an <a class="rail-btn"> (Sign In) — but base .rail-btn is display: inline-block, which shrink-wraps both element types identically. The asymmetry only appears once the block modifier is applied, so this dashboard was never affected.

Verification. Build 0 warnings / 0 errors; suite 879/879; staticwebassets.build.json resolves zb.mom.ww.theme/0.4.1. No stale-HTTP-cache clear was needed — restore picked 0.4.1 directly.