40 KiB
M10 — UI/UX Platform Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development to implement this plan task-by-task.
Status: DELIVERED — 2026-06-18. All five tasks shipped: DialogService.ShowAsync<T> custom-content modal host with focus-trap/restore (T33); dark-mode CSS-variable token layer + localStorage toggle + SSR no-flash (T34); OffsetPager/KeysetPager/DateTimeRangeFilter presentational components adopted across five pages (T35); TreeView chevron a11y + regression tests (T36); Playwright alarm-override trigger-config scenarios (T41). This is the final milestone of the system-completion roadmap.
Goal: Consolidate Central UI cross-cutting presentation into reusable primitives — a custom-content modal host, a dark-mode token layer, extracted pager/filter components — plus a bounded a11y pass and Playwright coverage for the alarm-override trigger-config UI. No new functional surfaces.
Architecture: Extend the existing DialogHost/DialogService with a generic ShowAsync<TResult> overload that owns focus-trap + focus-restoration + a single tokenized backdrop, then migrate the 5 simple ad-hoc dialogs onto it. Add a CSS-variable dark token layer in site.css (loads after the compiled ZB.MOM.WW.Theme theme.css, overriding its light-only :root tokens under [data-bs-theme="dark"]) plus a localStorage-backed toggle. Extract three purely presentational components (OffsetPager, KeysetPager, DateTimeRangeFilter) — navigation/cursor/UTC logic stays page-side — and adopt them per-page so each shared file is owned by exactly one task.
Tech Stack: Blazor Server (.NET 10, TreatWarningsAsErrors), Bootstrap 5.3.3 (data-bs-theme aware), external ZB.MOM.WW.Theme 0.3.1 (compiled NuGet — no source), bUnit + xUnit, Microsoft.Playwright C#. No new NuGet packages; no third-party Blazor component frameworks.
Base: branch worktree-m10-uiux-platform off origin/main @ ba335519. Design: docs/plans/2026-06-18-m10-uiux-platform-design.md (committed 1a23b902).
Execution rules (every task)
- Worktree: you are already in
.claude/worktrees/m10-uiux-platform. Implementers do NOT create worktrees. - Commits: pathspec form only —
git commit -m "msg" -- <paths>(-mBEFORE--). Nevergit add -A/-a/.. For new files,git add <explicit path>then pathspec commit. Retry onindex.lock. - Concurrency: ≤2–3 concurrent committers per wave; post-wave HEAD-presence check (
git log --onelineshows every wave commit; recover orphans via cherry-pick). - Builds/tests: targeted per task (build only the touched project; run the named filtered test). Full-solution build + docker rebuild + Playwright + live smoke happen only in the INT task.
- File ownership: the
Files:block is the contract. Each shared file is owned by exactly one task per wave. If you need a file not listed, STOP — it's a plan defect; surface it.
Wave / dependency map
| Wave | Tasks (native IDs assigned at creation) |
|---|---|
| 1 | T33a (modal host) ∥ T34-spike ∥ T41 |
| 2 | T33b (migrate dialogs, ⟵T33a) ∥ T34a (token layer, ⟵spike) ∥ T35a (OffsetPager) ∥ T35b (KeysetPager) ∥ T35c (DateTimeRangeFilter) |
| 3 | T35d (NotificationReport ⟵T35a,T35c,T34a) ∥ T35e (ConfigAudit ⟵T35a,T35c) ∥ T35f (SiteCalls ⟵T35b,T35c,T34a) ∥ T35g (AuditGrid+FilterBar ⟵T35b,T35c) ∥ T35h (EventLogs ⟵T35c) ∥ T34c (standalone backdrops ⟵T34a) |
| 4 | T34b (dark toggle ⟵spike,T34a) ∥ T36a (aria pass) |
| 5 | INT |
Wave 1
Task T33a: Modal host — ShowAsync<TResult> + focus trap + focus restoration + tokenized backdrop
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: T34-spike, T41
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/IDialogService.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogService.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DialogHost.razor - Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DialogHostShowAsyncTests.cs
Context: The host today (read it) renders Confirm/Prompt only, with a Bootstrap .modal-backdrop, once-per-dialog focus, and Escape-to-cancel. We add a third dialog kind — custom content — that renders an arbitrary RenderFragment and resolves a typed result the body sets via a callback. This becomes the single home for the migrated dialogs (T33b), the tokenized backdrop (T34a), and the focus trap (T36/this task).
Design contract:
IDialogServicegains:where// Shows arbitrary content. The body fragment receives a controller it can call to // close the dialog with a typed result (or null = cancel). Resolves when closed. Task<TResult?> ShowAsync<TResult>(string title, RenderFragment<DialogContext<TResult>> body, string? size = null);DialogContext<TResult>exposesvoid Close(TResult result)andvoid Cancel().DialogService: addDialogKind.Custom; store theRenderFragmentand the typed-close delegate onDialogState(widen the record additively — keep existing positional params, add optionalContent+Size+ a non-genericFunc<object?,bool>/boxed TCS already present). Reuse the existing single-dialogEnsureNoActiveDialogguard and the boxed_tcs/Resolveplumbing (custom resolves with the boxedTResult?).DialogHost.razor:- render branch for
DialogKind.Custom→@state.Content(ctx)inside.modal-body, with the footer suppressed (the custom body supplies its own buttons), andmodal-dialogsize class fromstate.Size(e.g.modal-lg). - Focus trap: add an
@onkeydownhandler that, onTab/Shift+Tab, keeps focus within_modalRef(query focusable elements via a tiny JS interop helpersbDialog.focusTrap(modalEl, shiftKey)OR a pure-Blazor first/last sentinel approach — prefer the JS helper added to a newwwwroot/js/dialog.js, registered inApp.razorhead as<script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/dialog.js"></script>). Keep Escape-cancel. - Focus restoration: before opening, capture
document.activeElement(JSsbDialog.captureFocus()returns an opaque handle or stashes it); on close,sbDialog.restoreFocus(). Implement indialog.js; call fromOnAfterRenderAsyncopen/close branches. - Tokenized backdrop: change the backdrop
<div class="modal-backdrop fade show">to also carry a classsb-modal-backdrop(the token rule is added in T34a; here just add the hook class — no inline rgba).
- render branch for
Steps:
- Write
DialogHostShowAsyncTests.cs(bUnit): renderDialogHostwith aDialogService; callShowAsync<string>("T", ctx => builder => { ... button calls ctx.Close("ok") ... }); assert the custom body renders, the footer is absent, clicking the body's close button resolves the awaited task to"ok", and Escape resolves tonull. Add an assertion that the backdrop element carries classsb-modal-backdrop. Run → FAIL (API absent). - Add
DialogContext<TResult>, theShowAsync<TResult>interface method +DialogServiceimplementation (newDialogKind.Custom, additiveDialogStatefields). - Add the
DialogKind.Customrender branch + size class +sb-modal-backdropclass inDialogHost.razor. - Create
wwwroot/js/dialog.js(focusTrap,captureFocus,restoreFocus); wire the keydown trap + capture/restore inDialogHost; add the<script>include toHost/Components/App.razorhead. - Run the new test + the full CentralUI.Tests dialog suite → PASS.
- Commit:
git addthe new test + js, thengit commit -m "feat(centralui): DialogHost ShowAsync<T> custom-content + focus trap/restore + backdrop hook (T33a)" -- <files>.
Acceptance: existing ConfirmAsync/PromptAsync behavior unchanged (their tests still green); ShowAsync<T> resolves typed result / null-on-cancel; focus is trapped within the modal and restored to the trigger on close; backdrop has no inline rgba.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~Dialog"
Task T34-spike: Verify ZB.MOM.WW.Theme dark-mode feasibility
Classification: small Estimated implement time: ~3 min Parallelizable with: T33a, T41
Files:
- Create:
docs/plans/2026-06-18-m10-t34-spike-findings.md - Read-only:
~/.nuget/packages/zb.mom.ww.theme/0.3.1/staticwebassets/css/theme.cssandlayout.css
Context: The dark toggle (T34b) is gated on this. The package is a compiled NuGet — we cannot edit it. Known: theme.css defines --paper/--card/--ink/--accent + --bs-body-bg/--bs-body-color/--bs-primary in a light-only :root. The viable path is overriding those tokens under [data-bs-theme="dark"] in our site.css (which loads after theme.css in App.razor, so equal-specificity selectors win by source order).
Steps:
- Read
theme.cssandlayout.cssfrom the nuget cache. Determine: (a) the exact list of CSS custom properties the side-rail / shell rules consume (var(--paper),var(--card),var(--ink),var(--bs-*), etc.) — i.e. are the rail backgrounds/text driven by tokens or hard-coded hex?; (b) whethertheme.cssalready loads beforesite.css(it does, via<ThemeHead/>atApp.razor:10vssite.cssat:12). - Write
2026-06-18-m10-t34-spike-findings.md: VERDICT (rail tokenizes → dark feasible by token override / rail hard-codes → rail stays light, ship tokens-only + log coordination follow-up), the concrete dark token override list (each--token: <dark value>to put under[data-bs-theme="dark"]), and any caveats (e.g.--accentshould stay or shift). - Commit:
git add docs/plans/2026-06-18-m10-t34-spike-findings.md && git commit -m "docs(m10): T34 theme dark-mode feasibility spike findings" -- docs/plans/2026-06-18-m10-t34-spike-findings.md.
Acceptance: findings doc states a clear VERDICT + the dark token list. The controller surfaces the verdict to the user before dispatching T34b if it is "rail stays light".
Task T41: Alarm-override Playwright — trigger-config scenarios
Classification: standard Estimated implement time: ~5 min Parallelizable with: T33a, T34-spike
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/AlarmTriggerEditor.razor(adddata-testhooks only) - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor(adddata-testhooks only — do NOT touch the backdrop; T34c owns that) - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureFixture.cs(provision a second, non-HiLo alarm) - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests/Deployment/InstanceConfigureTests.cs
Context: The override modal (InstanceConfigure.razor:367-428) edits trigger config via <AlarmTriggerEditor> (HiLo merges setpoints; other types whole-replace) plus a priority input, an _editingError alert, a Cancel and a Clear Override button. AlarmTriggerEditor.razor has no data-test hooks; the cancel/clear/error elements in InstanceConfigure.razor have none either. The existing test AlarmOverride_SetPriority_ThenClear_RoundTrips only exercises priority. Fixture provisions one HiLo alarm (AlarmName="HiHi", hi:80, hiHi:95, attr Value).
Steps:
- Add
data-testhooks: inAlarmTriggerEditor.razorHiLo setpoint inputs (alarm-hilo-hi,alarm-hilo-hihi,alarm-hilo-lo,alarm-hilo-lolo) and the ValueMatch/Threshold inputs used by the non-HiLo case (e.g.alarm-threshold-input/alarm-operator-select— name per the rendered fields); inInstanceConfigure.razorthe modal Cancel (alarm-cancel-override), modal Clear (alarm-clear-from-modal), and the_editingErroralert (alarm-override-error). - In
InstanceConfigureFixture.cs, provision a second alarm of a non-HiLo type viaCliRunner.AddAlarmAsync(TemplateId, "<Name>", "<NonHiLoType>", priority:…, attribute: AttributeName, …); expose its name/type as fixture fields. - Add Playwright scenarios in
InstanceConfigureTests.cs(new[SkippableFact]methods, reuse the existing structure +CliRunner.GetInstanceDocumentAsyncread-back +finally-teardown viaDeleteInstanceAlarmOverrideAsync):- HiLo setpoint edit merges: open the HiLo row's edit modal, change a setpoint (e.g.
hi→70) without touchinghiHi, Save, assert toast + override badge, read-back asserts the override's effective HiLo config merged (changedhi, retained inheritedhiHi). - Non-HiLo whole-replace: open the non-HiLo row, set its config, Save, read-back asserts the whole trigger config replaced.
- Validation error: enter an invalid value (e.g. priority
2000or a non-numeric setpoint), Save, assertalarm-override-errorvisible and dialog stays open (no toast, no badge). - Cancel: open edit, change a field, click
alarm-cancel-override, assert modal closed and read-back shows no override. - Clear-from-modal: with an override present, open edit, click
alarm-clear-from-modal, assert badge gone + read-back shows no override.
- HiLo setpoint edit merges: open the HiLo row's edit modal, change a setpoint (e.g.
- Run the Playwright test class (needs the docker cluster up — if unavailable the
Skip.IfNotshort-circuits; in that case the controller runs it in INT). Commit pathspec.
Acceptance: new scenarios assert via UI and CLI read-back; hooks added are stable (kebab-case data-test); fixture teardown leaves the instance override-free. Note: the InstanceConfigure.razor backdrop tokenization is explicitly out of scope here (T34c, wave 3) — only add hooks.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests --filter "FullyQualifiedName~InstanceConfigure" (or run in INT if no cluster).
Wave 2
Task T33b: Migrate the 5 simple dialogs to ShowAsync<T>
Classification: standard Estimated implement time: ~5 min Parallelizable with: T34a, T35a, T35b, T35c Blocked by: T33a
Files:
- Delete/replace:
Components/Shared/MoveFolderDialog.razor,MoveTemplateDialog.razor,RenameFolderDialog.razor,ComposeIntoDialog.razor,MoveDataConnectionDialog.razor - Modify:
Components/Pages/Design/Templates.razor(invokes 4 of them) - Modify:
Components/Pages/Design/DataConnections.razor(invokes MoveDataConnection) - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/MoveDataConnectionDialogTests.cs(rework to drive via the host)
Context (verbatim recon): each of the 5 dialogs is an @if(IsVisible) component rendering its OWN backdrop <div class="modal show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);"> with @bind-IsVisible + an OnSubmit/OnMoved callback. Parents: Templates.razor shows MoveFolder (_showMoveFolderDialog, OpenMoveFolderDialog/SubmitMoveFolder), MoveTemplate (_showMoveTemplateDialog/SubmitMoveTemplate), RenameFolder (_showRenameFolderDialog/SubmitRenameFolder), ComposeInto (_showComposeDialog/SubmitCompose). DataConnections.razor shows MoveDataConnection (_showMoveDialog, OpenMoveDialog/OnConnectionMoved); that dialog injects IDataConnectionMoveService and does its async move + inline error itself.
Approach: Convert each dialog from an @if-rendered component into a small content component that exposes a RenderFragment-returning helper (or keep it as a component rendered as the ShowAsync body). Simplest, lowest-risk pattern: turn each into a reusable body component invoked from the parent via DialogService.ShowAsync<TResult>(...), e.g. in Templates.razor:
var result = await Dialog.ShowAsync<int?>("Move folder", ctx =>
@<MoveFolderBody Options="@_folderOptions" FolderName="@name"
OnPick="@(id => ctx.Close(id))" OnCancel="@(() => ctx.Cancel())" />);
if (result is { } newParentId) { /* existing SubmitMoveFolder logic */ }
- The 4 simple Templates dialogs return their picked value (
int?parent/folder,stringnew name, or a(parentId, slot)tuple) — keep the parent's existing validation/service-call/toast/reload logic; the dialog body only collects input. - MoveDataConnection keeps its async-move-with-inline-error semantics: the body component still injects
IDataConnectionMoveServiceand callsctx.Close(true)on success / shows inline error + stays open on guard failure. ReworkMoveDataConnectionDialogTests.csto render the host + drive the body (the 5 assertions: picker renders, dispatch with ids, guard error stays open, success closes+signals). - Result: the 5 inline
rgba(0,0,0,0.4)backdrops are deleted (host owns the single backdrop). Remaining standalone backdrops are handled by T34c.
Steps: (1) rework MoveDataConnectionDialogTests.cs to the host-driven flow → FAIL; (2) migrate MoveDataConnection first (it has the test); make it green; (3) migrate the 4 Templates dialogs + update Templates.razor call sites; (4) build CentralUI + run the dialog + Templates + DataConnections test suites; (5) pathspec commit.
Acceptance: all 5 dialogs open via ShowAsync, no inline rgba backdrops remain in these 5 files, each dialog's observable behavior (validation, async move, error-stays-open, success-closes) is preserved, MoveDataConnectionDialogTests green, build clean.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~Dialog|FullyQualifiedName~Templates|FullyQualifiedName~DataConnections"
Task T34a: Dark token layer in site.css + .sb-modal-backdrop
Classification: standard Estimated implement time: ~4 min Parallelizable with: T33b, T35a, T35b, T35c Blocked by: T34-spike
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css
Context: site.css already loads after the Theme theme.css (App.razor:12 vs :10), so a [data-bs-theme="dark"] block here overrides the package's light :root tokens by source order. The spike findings doc lists the exact tokens. No App.razor change (keep dark tokens in the already-linked site.css — do NOT add a new stylesheet/link; that avoids colliding with T34b's App.razor edits).
Steps:
- Add a
.sb-modal-backdrop { background: var(--sb-backdrop, rgba(0,0,0,0.4)); }rule + a semantic token block: in:rootdefine--sb-backdrop: rgba(0,0,0,0.4);(light); in[data-bs-theme="dark"]define the dark token overrides from the spike (--paper,--card,--ink,--bs-body-bg,--bs-body-color,--bs-border-color,--sb-backdrop: rgba(0,0,0,0.6), etc.). - Verify the existing
.modal-content/#reconnect-modalrules still read sensibly in dark (they usevar(--bs-white)— confirm or token-ize). - (No test — pure CSS; visual verification deferred to INT docker smoke.)
dotnet buildthe CentralUI project to confirm the static asset still bundles. - Pathspec commit.
Acceptance: site.css defines --sb-backdrop (light + dark) + .sb-modal-backdrop + a [data-bs-theme="dark"] token block matching the spike list; CentralUI builds. No toggle yet (T34b applies the attribute).
Test: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI
Task T35a: OffsetPager component
Classification: standard Estimated implement time: ~4 min Parallelizable with: T33b, T34a, T35b, T35c
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/OffsetPager.razor - Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/OffsetPagerTests.cs
Context: NotificationReport + ConfigurationAuditLog share a prev/next + "Page X of Y · N total" pattern with page-side "next enabled" logic. Deployments uses windowed numbered buttons (PagerWindow) and is out of scope. Component is purely presentational — the page owns the page number field, the query, and the next-enabled decision.
Contract:
@* OffsetPager: prev/next + page summary. Page owns navigation + data fetch. *@
@if (TotalCount is { } || ShowWhenEmpty) { ... }
@code {
[Parameter] public int Page { get; set; } = 1;
[Parameter] public EventCallback<int> PageChanged { get; set; } // emits Page±1
[Parameter] public bool HasNextPage { get; set; } // page decides
[Parameter] public int? TotalCount { get; set; } // for "· N total"
[Parameter] public int PageSize { get; set; } // for "of Y"
[Parameter] public bool Disabled { get; set; }
private bool HasPrev => Page > 1;
private int? PageCount => (TotalCount is { } t && PageSize > 0) ? (int)Math.Ceiling(t / (double)PageSize) : null;
}
Markup: Prev button (disabled when !HasPrev || Disabled, click → PageChanged.InvokeAsync(Page-1)); a label Page @Page@(PageCount is {} pc ? $" of {pc}" : "")@(TotalCount is {} t ? $" · {t} total" : ""); Next button (disabled when !HasNextPage || Disabled, click → PageChanged.InvokeAsync(Page+1)). Add data-test="pager-prev"/pager-next/pager-summary.
Steps: TDD — bUnit test: prev disabled at Page=1; next disabled when HasNextPage=false; clicking next emits PageChanged(Page+1); summary text renders "Page 2 of 5 · 230 total" for Page=2,PageSize=50,TotalCount=230. Implement. Green. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~OffsetPager"
Task T35b: KeysetPager component
Classification: standard Estimated implement time: ~4 min Parallelizable with: T33b, T34a, T35a, T35c
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/KeysetPager.razor - Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/KeysetPagerTests.cs
Context: SiteCallsReport + AuditResultsGrid share a "Page X · Y rows" + prev/next bar where the cursor stack lives in the page (different cursor types: a tuple vs AuditLogPaging). The component is just the button bar; navigation stays page-side via callbacks.
Contract:
[Parameter] public int PageNumber { get; set; } = 1;
[Parameter] public int RowCount { get; set; }
[Parameter] public bool CanGoBack { get; set; } // page: _cursorStack.Count > 0
[Parameter] public bool HasNextPage { get; set; } // page: from query response
[Parameter] public bool Disabled { get; set; } // loading
[Parameter] public EventCallback OnPrevious { get; set; }
[Parameter] public EventCallback OnNext { get; set; }
Markup: label Page @PageNumber · @RowCount rows; Prev (disabled when !CanGoBack || Disabled, click → OnPrevious); Next (disabled when !HasNextPage || Disabled, click → OnNext). data-test="keyset-prev"/keyset-next/keyset-summary.
Steps: TDD bUnit (prev disabled when CanGoBack=false; next disabled when HasNextPage=false; clicks fire callbacks; summary text). Implement. Green. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~KeysetPager"
Task T35c: DateTimeRangeFilter component
Classification: standard Estimated implement time: ~4 min Parallelizable with: T33b, T34a, T35a, T35b
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DateTimeRangeFilter.razor - Create:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/DateTimeRangeFilterTests.cs
Context: ~5 pages duplicate a from/to type="datetime-local" pair bound to DateTime? (NotificationReport _fromFilter/_toFilter; ConfigurationAuditLog _filterFrom/_filterTo; SiteCallsReport _fromFilter/_toFilter; AuditFilterBar audit-from/audit-to; EventLogs _filterFrom/_filterTo). Pages convert to UTC differently (some SpecifyKind(Local).ToUniversalTime(), ConfigurationAuditLog via BrowserTime.LocalInputToUtc JS). Therefore the component is input-only — it emits the raw DateTime? (Unspecified) values; each page keeps its own UTC conversion. This is the key design decision that lets one component serve all pages without forcing a UTC strategy.
Contract:
[Parameter] public DateTime? From { get; set; }
[Parameter] public EventCallback<DateTime?> FromChanged { get; set; }
[Parameter] public DateTime? To { get; set; }
[Parameter] public EventCallback<DateTime?> ToChanged { get; set; }
[Parameter] public string IdPrefix { get; set; } = "dtr"; // distinct ids per page: "no", "sc", "audit-filter", ...
[Parameter] public string FromLabel { get; set; } = "From";
[Parameter] public string ToLabel { get; set; } = "To";
Markup: two <input type="datetime-local"> with id="@(IdPrefix)-from"/-to, @bind/@bind:after two-way wired to FromChanged/ToChanged, data-test="@(IdPrefix)-from"/-to. No Apply button (pages own that).
Steps: TDD bUnit (renders two datetime-local inputs with the prefixed ids; setting the from input invokes FromChanged with the parsed DateTime?). Implement. Green. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~DateTimeRangeFilter"
Wave 3
Each wave-3 task owns exactly ONE page/cluster file set → all mutually parallelizable. Adoption tasks replace the page's hand-rolled pager/filter markup with the new component, preserving the page's existing fields, query, cursor stack, and UTC conversion (wire them to the component's params/callbacks). Keep each page's bUnit test green; update selectors only where the test asserts old pager markup.
Task T35d: Adopt OffsetPager + DateTimeRangeFilter into NotificationReport (+ backdrop token)
Classification: standard Estimated implement time: ~5 min Parallelizable with: T35e, T35f, T35g, T35h, T34c Blocked by: T35a, T35c, T34a
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Notifications/NotificationReport.razor - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/NotificationReportPageTests.cs(only if it asserts old pager/filter markup)
Wiring: replace the pager markup (lines ~208-221) with <OffsetPager Page="_pageNumber" PageChanged="OnPageChanged" HasNextPage="@(_notifications.Count >= _pageSize)" TotalCount="_totalCount" PageSize="_pageSize" /> (add OnPageChanged(int p){ _pageNumber=p; await FetchPage(); }). Replace the from/to inputs (lines ~76-84) with <DateTimeRangeFilter From="_fromFilter" FromChanged="v => _fromFilter=v" To="_toFilter" ToChanged="v => _toFilter=v" IdPrefix="no" /> — keep the existing ToUtc (lines ~677-682) on query. Replace the inline detail-modal backdrop (line 229 rgba(0,0,0,0.4)) with the sb-modal-backdrop class. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~NotificationReport"
Task T35e: Adopt OffsetPager + DateTimeRangeFilter into ConfigurationAuditLog
Classification: standard Estimated implement time: ~5 min Parallelizable with: T35d, T35f, T35g, T35h, T34c Blocked by: T35a, T35c
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Audit/ConfigurationAuditLog.razor - Modify: its bUnit test if present (find under tests/)
Wiring: pager (lines ~177-183) → <OffsetPager Page="_page" PageChanged="..." HasNextPage="HasMore" TotalCount="_totalCount" PageSize="_pageSize" />; from/to (lines ~62-75) → <DateTimeRangeFilter ... IdPrefix="audit-filter" />, keeping the existing BrowserTime.LocalInputToUtc conversion (lines ~326-327) on query. No inline backdrop here. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~ConfigurationAuditLog"
Task T35f: Adopt KeysetPager + DateTimeRangeFilter into SiteCallsReport (+ backdrop token)
Classification: standard Estimated implement time: ~5 min Parallelizable with: T35d, T35e, T35g, T35h, T34c Blocked by: T35b, T35c, T34a
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/SiteCalls/SiteCallsReport.razor.cs - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/SiteCallsReportPageTests.cs(if it asserts old pager markup)
Wiring: pager (lines ~216-234) → <KeysetPager PageNumber="_pageNumber" RowCount="_rows.Count" CanGoBack="@(_cursorStack.Count > 0)" HasNextPage="@(_nextCursor is not null)" Disabled="_loading" OnPrevious="PrevPage" OnNext="NextPage" /> — keep the .razor.cs cursor-stack PrevPage/NextPage/FetchPage intact. From/to (lines ~76-84) → <DateTimeRangeFilter ... IdPrefix="sc" />, keep the .razor.cs ToUtc (lines ~482-485). Inline relay-modal backdrop (line 295 rgba(0,0,0,0.4)) → sb-modal-backdrop. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~SiteCallsReport"
Task T35g: Adopt KeysetPager into AuditResultsGrid + DateTimeRangeFilter into AuditFilterBar
Classification: standard Estimated implement time: ~5 min Parallelizable with: T35d, T35e, T35f, T35h, T34c Blocked by: T35b, T35c
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditResultsGrid.razor+.razor.cs - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Audit/AuditFilterBar.razor - Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Components/Audit/AuditResultsGridTests.cs(keyset cursor testClick_NextPage_CallsService_WithCursor_OfLastRowmust stay green — it drives next-page; ensure the new button carries the click)
Wiring: AuditResultsGrid pager (lines ~75-76) → <KeysetPager PageNumber="_pageNumber" RowCount="_rows.Count" CanGoBack="CanGoBack" HasNextPage="@(_loading ? false : _rows.Count >= _pageSize)" Disabled="_loading" OnPrevious="PrevPage" OnNext="NextPage" /> — keep .razor.cs cursor logic + the error-safe prev-pop. AuditFilterBar custom-range inputs (lines ~96-105, only shown when TimeRange == Custom) → <DateTimeRangeFilter ... IdPrefix="audit" />, keep its existing binding/conversion. Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~AuditResultsGrid|FullyQualifiedName~AuditFilterBar"
Task T35h: Adopt DateTimeRangeFilter into EventLogs
Classification: standard Estimated implement time: ~3 min Parallelizable with: T35d, T35e, T35f, T35g, T34c Blocked by: T35c
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/EventLogs.razor - Modify: its bUnit test if present
Wiring: from/to inputs (lines ~60-73) → <DateTimeRangeFilter From="_filterFrom" FromChanged="v=>_filterFrom=v" To="_filterTo" ToChanged="v=>_filterTo=v" IdPrefix="filter" />, keep its existing query/conversion + continuation paging untouched (EventLogs uses continuation paging — do not introduce a pager component here). Commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~EventLog"
Task T34c: Tokenize remaining standalone backdrops + bg-* audit
Classification: standard Estimated implement time: ~4 min Parallelizable with: T35d, T35e, T35f, T35g, T35h Blocked by: T34a
Files:
- Modify:
Components/Dialogs/NodeBrowserDialog.razor(line 9rgba(0,0,0,0.5)) - Modify:
Components/Dialogs/TestBindingsDialog.razor(line 7rgba(0,0,0,0.5)) - Modify:
Components/Pages/Deployment/MoveInstanceDialog.razor,MoveAreaDialog.razor,CreateAreaDialog.razor(line 3 each) - Modify:
Components/Pages/Deployment/InstanceConfigure.razor(line 369) - Modify:
Components/Pages/Design/TemplateEdit.razor(lines 697, 957, 1080, 1302)
Context: these are the standalone modal backdrops NOT migrated to the host (T33b removed the 5 simple ones; T35d/T35f tokenized NotificationReport/SiteCalls inline backdrops). Replace each inline style="background: rgba(0,0,0,0.x);" with class="... sb-modal-backdrop" (append to existing classes; drop the inline style). Spot-audit bg-light/bg-white/bg-dark literals in these files for obvious dark-mode contrast breaks and convert the worst offenders to Bootstrap theme-aware classes (bg-body, bg-body-secondary, text-bg-*) — bounded, don't chase every instance.
Steps: replace the 9 backdrops; build CentralUI; pathspec commit. Note ordering: InstanceConfigure.razor is also touched by T41 (wave 1, hooks) — T41 completes before this wave, so no concurrent edit.
Test: dotnet build src/ZB.MOM.WW.ScadaBridge.CentralUI
Wave 4
Task T34b: Dark-mode toggle + persistence + SSR hydration
Classification: high-risk Estimated implement time: ~5 min Parallelizable with: T36a Blocked by: T34-spike, T34a
Files:
- Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DarkModeToggle.razor - Create:
src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/theme.js - Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor(add the toggle into<RailFooter>) - Modify:
src/ZB.MOM.WW.ScadaBridge.Host/Components/App.razor(pre-hydration inline script +theme.jsinclude)
Context (gated by spike): if the spike verdict is "rail stays light", the controller surfaces it to the user BEFORE this task and the toggle ships content-only (or the task is deferred). Assuming feasible:
theme.js:sbTheme.get()(localStorage keysb-theme, defaultlight),sbTheme.set(mode)(writes localStorage +document.documentElement.setAttribute("data-bs-theme", mode)),sbTheme.toggle().App.razorhead: an inline<script>that readslocalStorage['sb-theme']and setsdata-bs-themeon<html>before body render (prevents the light-flash on load) — place before the CSS links is fine since it only sets the attribute. Plus a<script src="_content/.../js/theme.js"></script>include.DarkModeToggle.razor: a sun/moon<button>(aria-label="Toggle dark mode",aria-pressed),@onclick→ JSsbTheme.toggle()then read back current mode to swap the icon. Render in the rail footer next to the sign-out form.
Steps: (1) create theme.js + DarkModeToggle.razor; (2) wire the inline hydration script + include into App.razor; (3) add the toggle to MainLayout <RailFooter>; (4) bUnit test the toggle renders with aria-label/aria-pressed and calls the JS module on click (mock IJSRuntime); (5) build CentralUI + Host; (6) pathspec commit. Live dark/light verification happens in INT docker smoke.
Acceptance: toggle flips data-bs-theme on <html>, persists across reload via localStorage, no light-flash on first paint (hydration script), toggle is keyboard-reachable + labeled. Confirmed visually in INT.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~DarkModeToggle"
Task T36a: Accessibility pass — TreeView chevron + icon-button audit + toast aria-live verify
Classification: standard Estimated implement time: ~4 min Parallelizable with: T34b
Files:
- Modify:
src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/TreeView.razor(chevron pseudo-button line ~54 — addrole="button"+aria-label/aria-expanded+ keyboard@onkeydownEnter/Space to toggle) - Modify: any other icon-only buttons surfaced lacking an accessible name (bounded — recon found most already labeled; do not touch wave-3-owned report pages)
- Create/Modify:
tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Shared/a11y assertion test (TreeView chevron has aria-label + role=button; assertToastNotificationcontainer hasaria-live="polite"+aria-atomic)
Context: recon confirmed Toast (aria-live="polite" + role="alert"), LoadingSpinner (role="status" + visually-hidden), and AlarmStateBadges (text + aria-label) are already compliant — so this task mainly adds the TreeView chevron accessible name + keyboard activation and locks the compliant state with a regression test. Keep it bounded; do NOT edit report pages owned by wave-3 tasks.
Steps: add role="button", tabindex="0", aria-label (e.g. @(expanded ? "Collapse" : "Expand") @label), aria-expanded, and Enter/Space @onkeydown to the TreeView toggle; add the bUnit a11y test; build + run; pathspec commit.
Test: dotnet test tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests --filter "FullyQualifiedName~TreeView|FullyQualifiedName~Toast"
Wave 5
Task INT: Integration — build, docker, Playwright, a11y smoke, docs, whole-branch review
Classification: high-risk Estimated implement time: ~10 min (controller-driven, not a single subagent)
Steps:
- Full-solution build:
dotnet build ZB.MOM.WW.ScadaBridge.slnx— must be 0 warnings / 0 errors (TreatWarningsAsErrors). - EF model-drift check:
dotnet ef migrations has-pending-model-changes(M10 adds no entities/migrations — confirm none introduced). - Touched test projects, unfiltered: run
ZB.MOM.WW.ScadaBridge.CentralUI.Testsin full. Prove any red is pre-existing per integration-catches-cross-cutting-gaps (git diff --stat ba335519..HEAD -- <failing test's target files>empty ⇒ pre-existing; known pre-existing reds: #163 InstanceConfigureListOverrideTests, #207 QueryStringDrillInTests). - Docker rebuild + smoke:
bash docker/deploy.sh; verify/health/ready200 on central-a/b + traefik (localhost:9001/9002/9000). - Playwright (incl. T41): run
ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTestsagainst the live cluster — alarm-override scenarios + a smoke of the migrated dialogs + the dark toggle. - a11y + dark smoke (Chrome): load the UI, toggle dark mode, confirm the side-rail + content + a migrated dialog all go dark with no light-flash and readable contrast; tab into a dialog and confirm focus is trapped + restored.
- Whole-branch integration review: dispatch a code-reviewer over
git diff ba335519..HEADfocused on: every migrated dialog's behavior preserved (re-run their fixtures), the modal host shared-infra change not regressing any page, the dark token override actually winning over the Theme package, and no inline rgba backdrops left except intentional ones. - Docs sync: update
README.md(component table / Central UI notes if needed), the M10 design doc status, anddocs/plans/2026-06-15-stillpending-completion-design.md(mark M10 / T33-T36,T41 delivered). Log any deferrals as FOLLOWUP tasks (e.g. Deployments-PagerWindow not unified; complex TemplateEdit modals not migrated; TreeView arrow-key R7 still deferred; Theme-package shell-theming coordination if spike said rail stays light). - Pathspec commit the docs.
Acceptance: full build 0/0; CentralUI.Tests green modulo proven-pre-existing reds; docker healthy; Playwright green; dark mode + focus trap verified live; integration review INTEGRATION-CLEAN; docs synced.
Deferred / out of scope (log as FOLLOWUPs at INT)
- Unified offset+keyset pagination framework (blocked by total-count mismatch).
Deployments.razorPagerWindow → OffsetPager (different windowed UX; intentionally left).- Complex
TemplateEditpage-embedded modals → host migration. - TreeView arrow-key navigation (R7).
- Theme-package side-rail dark theming, IF the spike verdict is "rail stays light" (coordination follow-up).
Follow-ups logged at delivery (INT findings)
- #207 (pre-existing, open since M6/K14):
QueryStringDrillInTestsfixture does not registerIKpiHistoryQueryService— 3SiteCallsReportdrill-in tests red on this gap; unrelated to M10. - #163 (pre-existing):
InstanceConfigureListOverrideTestscodec roundtrip red; pre-dates M10. - NotificationReport
OffsetPageralways-visible: pager is now always visible when results exist (previously hidden on sub-page-size sets); buttons are correctly disabled on a single page — product decision whether to re-add an@if (_totalCount > _pageSize)guard. Deployments.razorPagerWindow intentionally kept: windowed numbered-button UX is deliberate; NOT migrated toOffsetPager.TemplateEditinline modals NOT migrated: the page-embedded modals (wave-3 T34c already tokenized their backdrops); full migration to the host is deferred.- TreeView full arrow-key navigation (R7) still deferred.
- Full-app
bg-light/bg-white→ theme-aware utility sweep deferred: only the bounded modal-surface offenders were addressed in T34c; INT dark-mode smoke may surface additional instances.