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

463 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```csharp
// 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`:
```csharp
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:**
```razor
@* 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:**
```csharp
[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:**
```csharp
[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).