# 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>` (`-m` BEFORE `--`). Never `git add -A`/`-a`/`.`. For new files, `git add <explicit path>` then pathspec commit. Retry on `index.lock`.
- **Concurrency:** ≤2–3 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.~~ **DONE 2026-08-01.**
- ~~TreeView arrow-key navigation (R7).~~ **DONE 2026-08-01.**
- 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 — FIXED (2026-08-01 bookkeeping sync).** ~~(pre-existing, open since M6/K14):~~ `QueryStringDrillInTests` now registers the `IKpiHistoryQueryService` substitute in its fixture (lines ~165-167), so the 3 `SiteCallsReport` drill-in tests are green.
- **#163 — CLOSED (2026-08-01).** ~~(pre-existing):~~ `InstanceConfigureListOverrideTests` codec roundtrip verified green (6/6; `dotnet test …CentralUI.Tests --filter InstanceConfigureListOverride`) — fixed at some point since it was logged; no longer a known red. (#207 `QueryStringDrillInTests` re-verified green the same day, 4/4.)
- **NotificationReport `OffsetPager` always-visible — DECIDED + DONE (2026-08-01).** A pager whose only controls are permanently disabled is noise, so the conventional behaviour ships: the bar is hidden on a provably single page. Implemented **reusably but opt-in** — `OffsetPager.HideWhenSinglePage` (default `false`, so no consumer changes behaviour implicitly), set `true` at the `NotificationReport` call site. It is NOT defaulted on because the pager's summary span doubles as the "N total" result-count readout on `ConfigurationAuditLog`, which has no other place to show it (and whose Playwright fixture asserts `3 total` on a 3-row single page). The guard only fires when single-page-ness is *positively* established (`PageCount <= 1` **and** `Page <= 1` **and** `!HasNextPage`), so a null `TotalCount` still renders the bar.
- **`Deployments.razor` PagerWindow intentionally kept:** windowed numbered-button UX is deliberate; NOT migrated to `OffsetPager`.
- **`TemplateEdit` inline modals — MIGRATED (2026-08-01).** All four page-embedded modals (Attribute, Alarm, Native Alarm Source, Script) now open through `IDialogService.ShowAsync`, so the host owns the backdrop, focus trap, Escape and focus restoration. Each body was extracted to its own component next to the page — `TemplateAttributeDialog`, `TemplateAlarmDialog`, `TemplateNativeAlarmSourceDialog`, `TemplateScriptDialog` — following the `MoveDataConnectionDialog` pattern: the body owns the form state and renders validation/server errors INLINE while staying open, closing with `Close(true)` only on a successful save. Persistence stayed on the page (it owns `TemplateService` and the inherited-member rules) and is reached through an `OnSaveAsync` delegate returning `null` on success or the message to display. The script dialog keeps all four tab panels mounted (Monaco/JSONJoy must not tear down on tab switch) and hosts the Test Run panel, which needs the live unsaved buffer. Extraction moved markup that three structural source-scanning tests pinned (`TemplateNativeAlarmSourceEditorTests`, `AttributeListEditorTests`, `TestRunWarningTests`) — all three were repointed at the new files.
- **~~TreeView full arrow-key navigation (R7) still deferred.~~ DONE 2026-08-01** — the WAI-ARIA tree pattern (roving tabindex, Arrow/Home/End movement, Enter/Space activation, `aria-level`) now ships on `TreeView<TItem>`; see `docs/components/TreeView.md`.
- **Full-app `bg-light`/`bg-white` → theme-aware utility sweep — DONE 2026-08-01.** 35 class swaps across 19 Central UI files: surfaces/`<pre>`/`<code>` `bg-light` → `bg-body-secondary`, panel `bg-white` → `bg-body`, neutral `badge bg-light text-dark` → `badge bg-secondary-subtle text-secondary-emphasis`, `bg-light text-muted border` → `bg-body-secondary text-body-secondary border`. **Deliberately left** (7 sites): the neutral member of a *status-badge* switch/ternary whose siblings are all solid non-theme-aware colours (`bg-success`/`bg-danger`/`bg-warning`), where swapping only the neutral one breaks the set's visual weight — `Topology.razor:513,588`, `InstanceConfigure.razor:1520`, `NotificationReport.razor` `StatusBadgeClass` fallback, `TransportImport.razor` ConflictKind fallback, `SecuredWrites.razor` `text-bg-light` fallback, `Health.razor:377`. `SchemaBuilder.razor:88` was already `bg-light-subtle` (theme-aware). `bg-dark text-light` console panels and `site.css`/`#reconnect-modal` are intentional fixed colours.
