Files
ScadaBridge/docs/plans/2026-06-18-m10-uiux-platform.md
T

38 KiB
Raw Blame History

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.

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> (-m BEFORE --). Never git add -A/-a/.. For new files, git add <explicit path> then pathspec commit. Retry on index.lock.
  • Concurrency: ≤23 concurrent committers per wave; post-wave HEAD-presence check (git log --oneline shows 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:

  • IDialogService gains:
    // 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);
    
    where DialogContext<TResult> exposes void Close(TResult result) and void Cancel().
  • DialogService: add DialogKind.Custom; store the RenderFragment and the typed-close delegate on DialogState (widen the record additively — keep existing positional params, add optional Content + Size + a non-generic Func<object?,bool>/boxed TCS already present). Reuse the existing single-dialog EnsureNoActiveDialog guard and the boxed _tcs/Resolve plumbing (custom resolves with the boxed TResult?).
  • DialogHost.razor:
    • render branch for DialogKind.Custom@state.Content(ctx) inside .modal-body, with the footer suppressed (the custom body supplies its own buttons), and modal-dialog size class from state.Size (e.g. modal-lg).
    • Focus trap: add an @onkeydown handler that, on Tab/Shift+Tab, keeps focus within _modalRef (query focusable elements via a tiny JS interop helper sbDialog.focusTrap(modalEl, shiftKey) OR a pure-Blazor first/last sentinel approach — prefer the JS helper added to a new wwwroot/js/dialog.js, registered in App.razor head as <script src="_content/ZB.MOM.WW.ScadaBridge.CentralUI/js/dialog.js"></script>). Keep Escape-cancel.
    • Focus restoration: before opening, capture document.activeElement (JS sbDialog.captureFocus() returns an opaque handle or stashes it); on close, sbDialog.restoreFocus(). Implement in dialog.js; call from OnAfterRenderAsync open/close branches.
    • Tokenized backdrop: change the backdrop <div class="modal-backdrop fade show"> to also carry a class sb-modal-backdrop (the token rule is added in T34a; here just add the hook class — no inline rgba).

Steps:

  1. Write DialogHostShowAsyncTests.cs (bUnit): render DialogHost with a DialogService; call ShowAsync<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 to null. Add an assertion that the backdrop element carries class sb-modal-backdrop. Run → FAIL (API absent).
  2. Add DialogContext<TResult>, the ShowAsync<TResult> interface method + DialogService implementation (new DialogKind.Custom, additive DialogState fields).
  3. Add the DialogKind.Custom render branch + size class + sb-modal-backdrop class in DialogHost.razor.
  4. Create wwwroot/js/dialog.js (focusTrap, captureFocus, restoreFocus); wire the keydown trap + capture/restore in DialogHost; add the <script> include to Host/Components/App.razor head.
  5. Run the new test + the full CentralUI.Tests dialog suite → PASS.
  6. Commit: git add the new test + js, then git 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.css and layout.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:

  1. Read theme.css and layout.css from 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) whether theme.css already loads before site.css (it does, via <ThemeHead/> at App.razor:10 vs site.css at :12).
  2. 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. --accent should stay or shift).
  3. 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 (add data-test hooks only)
  • Modify: src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/InstanceConfigure.razor (add data-test hooks 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:

  1. Add data-test hooks: in AlarmTriggerEditor.razor HiLo 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); in InstanceConfigure.razor the modal Cancel (alarm-cancel-override), modal Clear (alarm-clear-from-modal), and the _editingError alert (alarm-override-error).
  2. In InstanceConfigureFixture.cs, provision a second alarm of a non-HiLo type via CliRunner.AddAlarmAsync(TemplateId, "<Name>", "<NonHiLoType>", priority:…, attribute: AttributeName, …); expose its name/type as fixture fields.
  3. Add Playwright scenarios in InstanceConfigureTests.cs (new [SkippableFact] methods, reuse the existing structure + CliRunner.GetInstanceDocumentAsync read-back + finally-teardown via DeleteInstanceAlarmOverrideAsync):
    • HiLo setpoint edit merges: open the HiLo row's edit modal, change a setpoint (e.g. hi→70) without touching hiHi, Save, assert toast + override badge, read-back asserts the override's effective HiLo config merged (changed hi, retained inherited hiHi).
    • 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 2000 or a non-numeric setpoint), Save, assert alarm-override-error visible 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.
  4. Run the Playwright test class (needs the docker cluster up — if unavailable the Skip.IfNot short-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, string new 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 IDataConnectionMoveService and calls ctx.Close(true) on success / shows inline error + stays open on guard failure. Rework MoveDataConnectionDialogTests.cs to 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:

  1. Add a .sb-modal-backdrop { background: var(--sb-backdrop, rgba(0,0,0,0.4)); } rule + a semantic token block: in :root define --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.).
  2. Verify the existing .modal-content / #reconnect-modal rules still read sensibly in dark (they use var(--bs-white) — confirm or token-ize).
  3. (No test — pure CSS; visual verification deferred to INT docker smoke.) dotnet build the CentralUI project to confirm the static asset still bundles.
  4. 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 test Click_NextPage_CallsService_WithCursor_OfLastRow must 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 9 rgba(0,0,0,0.5))
  • Modify: Components/Dialogs/TestBindingsDialog.razor (line 7 rgba(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.js include)

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 key sb-theme, default light), sbTheme.set(mode) (writes localStorage + document.documentElement.setAttribute("data-bs-theme", mode)), sbTheme.toggle().
  • App.razor head: an inline <script> that reads localStorage['sb-theme'] and sets data-bs-theme on <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 → JS sbTheme.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 — add role="button" + aria-label/aria-expanded + keyboard @onkeydown Enter/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; assert ToastNotification container has aria-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:

  1. Full-solution build: dotnet build ZB.MOM.WW.ScadaBridge.slnx — must be 0 warnings / 0 errors (TreatWarningsAsErrors).
  2. EF model-drift check: dotnet ef migrations has-pending-model-changes (M10 adds no entities/migrations — confirm none introduced).
  3. Touched test projects, unfiltered: run ZB.MOM.WW.ScadaBridge.CentralUI.Tests in 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).
  4. Docker rebuild + smoke: bash docker/deploy.sh; verify /health/ready 200 on central-a/b + traefik (localhost:9001/9002/9000).
  5. Playwright (incl. T41): run ZB.MOM.WW.ScadaBridge.CentralUI.PlaywrightTests against the live cluster — alarm-override scenarios + a smoke of the migrated dialogs + the dark toggle.
  6. 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.
  7. Whole-branch integration review: dispatch a code-reviewer over git diff ba335519..HEAD focused 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.
  8. Docs sync: update README.md (component table / Central UI notes if needed), the M10 design doc status, and docs/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).
  9. 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.razor PagerWindow → OffsetPager (different windowed UX; intentionally left).
  • Complex TemplateEdit page-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).