Share one ModalOverlay component across every modal-task panel #137

Merged
rob merged 3 commits from task-193-shared-overlay into main 2026-08-12 08:11:47 +00:00
Owner

Task 193. Two new components in Overlays/:

  • OverlayBackdrop — the one dimming/blurring/fading scrim every overlay uses. Always mounted, driven by Visible/Inert (kept separate: Drawer's own scrim has to stay visually dimmed but unclickable while a panel elsewhere holds the focus trap, without fading itself out). Fade is a plain CSS transition, so prefers-reduced-motion is handled in CSS alone, no C# timing needed.
  • ModalOverlay — the shell every modal-task panel floats inside: nests OverlayBackdrop, owns the fade/slide animation, the close button and its focus, inert while closing, and OverlayFocusTrap's enter/exit. It does not own the close decisionOnDismissRequested asks the host, which alone knows whether a dirty check or delete confirmation has to answer first. ChildContent is RenderFragment<bool>; its context is true while closing, so a host gates its own content the same way PlaceFormPanel already had to for PR #117's duplicate-id fix — now GroupFormPanel and GroupMembersPanel get the same gate too.

PlaceFormPanel, GroupFormPanel, GroupMembersPanel all adopt it; each panel's own backdrop/animation/close-button markup, CSS and IAsyncDisposable boilerplate is deleted, not left beside it. Drawer's own narrow-viewport scrim uses OverlayBackdrop directly — always mounted now instead of @if (IsOpen), so it fades both ways.

Adoption is proved by FindComponents<ModalOverlay>().ShouldHaveSingleItem() and renderedPanel.Nodes.OfType<IElement>().Count().ShouldBe(2) in each panel's own test class — the first catches a bespoke reimplementation, the second catches a panel keeping its own bespoke backdrop alongside ModalOverlay (found wanting in review: presence alone doesn't prove exclusivity). Both mutation-tested independently: hand-rolled markup instead of ModalOverlay reddens the first; a stray <div class="…-backdrop"> next to a genuine ModalOverlay reddens the second, with the first still green.

ADR-0116, allocated. Partially supersedes ADR-0102 for the three paragraphs whose implementation-location claims moved; the rest of ADR-0102 stands. Rebased onto main to pick up #136 (task 195's colour work) — no conflicts beyond the generated ADR index, resolved mechanically.

Review round 1 correction: the original PR body claimed HomeAndDrawerSharedGroupStateTests's own WaitForAssertion(() => Click()) retried the click — it doesn't; the reviewer instrumented it and found it fires exactly once. What actually helps is WaitForAssertion settling Blazor's pending render queue first. The real mechanism, found by the same review: OverlayFocusTrap.ExitAsync updates its own state synchronously, but Blazor calls a removed component's DisposeAsync — and so this call — asynchronously, after the render that removed it. For a render or two after any modal-task panel closes, the whole Drawer is briefly inert, not only the panel that closed — already true of PlaceFormPanel alone before this PR, never exercised until this PR gave GroupFormPanel the same delay. Not fixed here: a same-tick fix attempted in review (releasing the trap from ModalOverlay.CloseAsync itself, before OnClose) reproducibly left a newly opened panel's own ModalOverlay disposed again within the same render pass — a worse, less-understood failure than the one it replaced, and clear evidence the deterministic fix needs more room than this PR should spend on it under review. Documented precisely in ADR-0116's own Consequences and in the test's own comment; the test still uses WaitForAssertion to work around the symptom.

A follow-up ticket for the OverlayFocusTrap release-timing gap needs raising — I don't have a Vikunja task-creation tool in this environment (checked; only get_task/list_tasks/list_comments/add_comment available), so this is a blocker for whoever does: title something like "OverlayFocusTrap release lags Blazor's own DisposeAsync scheduling, leaving Drawer briefly inert after any modal-task panel closes" — full mechanism and the failed same-tick fix attempt are in ADR-0116's Consequences section and this PR's own commit 1abc741.

Full suite (post-merge, E2E's 6 skips folded in): 1808 total / 1798 passing / 10 skipped. dotnet format --verify-no-changes clean, pinned SDK.

Task 193. Two new components in `Overlays/`: - `OverlayBackdrop` — the one dimming/blurring/fading scrim every overlay uses. Always mounted, driven by `Visible`/`Inert` (kept separate: `Drawer`'s own scrim has to stay visually dimmed but unclickable while a panel elsewhere holds the focus trap, without fading itself out). Fade is a plain CSS `transition`, so `prefers-reduced-motion` is handled in CSS alone, no C# timing needed. - `ModalOverlay` — the shell every modal-task panel floats inside: nests `OverlayBackdrop`, owns the fade/slide animation, the close button and its focus, `inert` while closing, and `OverlayFocusTrap`'s enter/exit. It does not own the close *decision* — `OnDismissRequested` asks the host, which alone knows whether a dirty check or delete confirmation has to answer first. `ChildContent` is `RenderFragment<bool>`; its `context` is `true` while closing, so a host gates its own content the same way `PlaceFormPanel` already had to for PR #117's duplicate-id fix — now `GroupFormPanel` and `GroupMembersPanel` get the same gate too. `PlaceFormPanel`, `GroupFormPanel`, `GroupMembersPanel` all adopt it; each panel's own backdrop/animation/close-button markup, CSS and `IAsyncDisposable` boilerplate is deleted, not left beside it. `Drawer`'s own narrow-viewport scrim uses `OverlayBackdrop` directly — always mounted now instead of `@if (IsOpen)`, so it fades both ways. Adoption is proved by `FindComponents<ModalOverlay>().ShouldHaveSingleItem()` **and** `renderedPanel.Nodes.OfType<IElement>().Count().ShouldBe(2)` in each panel's own test class — the first catches a bespoke reimplementation, the second catches a panel keeping its own bespoke backdrop *alongside* `ModalOverlay` (found wanting in review: presence alone doesn't prove exclusivity). Both mutation-tested independently: hand-rolled markup instead of `ModalOverlay` reddens the first; a stray `<div class="…-backdrop">` next to a genuine `ModalOverlay` reddens the second, with the first still green. ADR-0116, allocated. Partially supersedes ADR-0102 for the three paragraphs whose implementation-location claims moved; the rest of ADR-0102 stands. Rebased onto main to pick up #136 (task 195's colour work) — no conflicts beyond the generated ADR index, resolved mechanically. **Review round 1 correction:** the original PR body claimed `HomeAndDrawerSharedGroupStateTests`'s own `WaitForAssertion(() => Click())` retried the click — it doesn't; the reviewer instrumented it and found it fires exactly once. What actually helps is `WaitForAssertion` settling Blazor's pending render queue first. The real mechanism, found by the same review: `OverlayFocusTrap.ExitAsync` updates its own state synchronously, but Blazor calls a removed component's `DisposeAsync` — and so this call — asynchronously, after the render that removed it. For a render or two after *any* modal-task panel closes, the whole `Drawer` is briefly `inert`, not only the panel that closed — already true of `PlaceFormPanel` alone before this PR, never exercised until this PR gave `GroupFormPanel` the same delay. **Not fixed here**: a same-tick fix attempted in review (releasing the trap from `ModalOverlay.CloseAsync` itself, before `OnClose`) reproducibly left a *newly opened* panel's own `ModalOverlay` disposed again within the same render pass — a worse, less-understood failure than the one it replaced, and clear evidence the deterministic fix needs more room than this PR should spend on it under review. Documented precisely in ADR-0116's own Consequences and in the test's own comment; the test still uses `WaitForAssertion` to work around the symptom. **A follow-up ticket for the `OverlayFocusTrap` release-timing gap needs raising** — I don't have a Vikunja task-creation tool in this environment (checked; only `get_task`/`list_tasks`/`list_comments`/`add_comment` available), so this is a blocker for whoever does: title something like "OverlayFocusTrap release lags Blazor's own DisposeAsync scheduling, leaving Drawer briefly inert after any modal-task panel closes" — full mechanism and the failed same-tick fix attempt are in ADR-0116's Consequences section and this PR's own commit `1abc741`. Full suite (post-merge, E2E's 6 skips folded in): 1808 total / 1798 passing / 10 skipped. `dotnet format --verify-no-changes` clean, pinned SDK.
Share one ModalOverlay component across every modal-task panel
All checks were successful
CI / build (pull_request) Successful in 2m56s
CI / container-images (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 1m48s
4fa8d17cf1
rob left a comment

Verdict: changes needed

Reviewed at head 4fa8d17. CI run #583, status success, 4m50s — long enough, and gated correctly enough (e2e needs build), that I take this as the isolated e2e job having actually run and passed, not just build; no per-job breakdown is retrievable to confirm harder than that. Locally reproduced: clean Release build, dotnet format --verify-no-changes clean, full suite 1779/1769/10 exactly matching the PR body, and the coverage ratchet holds (Infrastructure back to 99%, WebUI up to 90.1%).

The flaky-test framing, interrogated by testing rather than reading. Confirmed by history that GroupFormPanel.CloseAsync on main had no delay at all (await OnClose.InvokeAsync(), nothing else) — this PR is what gives it one, so "exposed by the new shared closing delay" is doing real work, not softening a regression. But two things in the fix itself don't hold up:

  • "the click itself is retried" is not what happens. I instrumented the exact WaitForAssertion(() => Find(...).Click()) call: it invokes the click exactly once, every time, across 40 runs — Click() doesn't throw on a no-op, so there's nothing for WaitForAssertion to retry. What actually makes this pass is that WaitForAssertion settles pending renders before that one attempt runs, which happens to give ModalOverlay's disposal time to complete first. Reverting only this line (keeping the panel-removal wait) reproduces a deterministic failure, not a flaky one — #place-form-name never found, every one of 25 runs, because the click lands while <aside> is still inert. Please reword the comment to say what's actually happening; "retried" will send the next person who touches this looking for a retry loop that isn't there.
  • The root cause is one level deeper than "GroupFormPanel races its own disposal." OverlayFocusTrap.ExitAsync updates _openCallers/fires Changed synchronously, before its own JS call — so the lag isn't in that class. It's that Blazor calls a removed component's DisposeAsync (and hence ExitAsync) asynchronously, after the render that removed it, not as part of it. That means the whole drawer — not just the closing panel — is briefly inert and unclickable after any modal-task panel closes, and this was already true of PlaceFormPanel alone before this PR; it's just never been exercised by a "close one panel, immediately act outside it" sequence until this PR gave GroupFormPanel the same delay. Worth a line in the ADR or a code comment naming it, since it's a real, if narrow, structural property of OverlayFocusTrap rather than something scoped to the test that happened to catch it here.

Adoption test proves presence, not exclusivity. Mutation-tested both directions: pointing a panel at hand-rolled markup reddens FindComponents<ModalOverlay>().ShouldHaveSingleItem() as claimed. But I also added a bespoke <div class="...-backdrop"> back in alongside <ModalOverlay> in GroupFormPanel.razor and the test still passed — it counts ModalOverlay instances, not the absence of anything else. The PR body's "not a CSS/text pattern a reimplementation could coincidentally match" oversells this one specific way. Not blocking on its own, but the claim should be narrowed or the test's own doc comment should say what it doesn't check, the way this project already does elsewhere (ADR-0066's "best-effort" framing) rather than implying a completeness it doesn't have.

Everything else checks out. OverlayBackdrop.razor.css: backdrop-filter: blur(var(--backdrop-blur)) with --backdrop-blur: 4px newly declared, background-color dims, transition: opacity applies on both the add and remove of .overlay-backdrop-visible so the fade genuinely runs both ways, and prefers-reduced-motion sets transition: none (removed outright, not shortened) — same pattern on ModalOverlay.razor.css's own keyframes. PlaceDetailPanel isn't in the diff at all and still calls no OverlayFocusTrap/ModalOverlay/OverlayBackdrop method — ADR-0099's scope holds. Drawer's scrim carries HideAboveWideViewport="true", same condition as before, so desktop stays undimmed and the map stays reachable. PlaceFormPanel.RequestCloseAsync is still the one thing the close button, backdrop (via OnDismissRequested) and Escape (via HandlePanelKeyDownAsync) all route through, so the dirty-check question is still asked once, the same way, from all three. ADR-0116/ADR-0102 annotate each other correctly in both directions, scoped to exactly the three relocated paragraphs — the rest of ADR-0102 is left standing, honestly.

Verdict: changes needed Reviewed at head 4fa8d17. CI run #583, status success, 4m50s — long enough, and gated correctly enough (`e2e` needs `build`), that I take this as the isolated `e2e` job having actually run and passed, not just `build`; no per-job breakdown is retrievable to confirm harder than that. Locally reproduced: clean `Release` build, `dotnet format --verify-no-changes` clean, full suite 1779/1769/10 exactly matching the PR body, and the coverage ratchet holds (Infrastructure back to 99%, WebUI up to 90.1%). **The flaky-test framing, interrogated by testing rather than reading.** Confirmed by history that `GroupFormPanel.CloseAsync` on `main` had no delay at all (`await OnClose.InvokeAsync()`, nothing else) — this PR is what gives it one, so "exposed by the new shared closing delay" is doing real work, not softening a regression. But two things in the fix itself don't hold up: - **"the click itself is retried" is not what happens.** I instrumented the exact `WaitForAssertion(() => Find(...).Click())` call: it invokes the click exactly once, every time, across 40 runs — `Click()` doesn't throw on a no-op, so there's nothing for `WaitForAssertion` to retry. What actually makes this pass is that `WaitForAssertion` settles pending renders before that one attempt runs, which happens to give `ModalOverlay`'s disposal time to complete first. Reverting only this line (keeping the panel-removal wait) reproduces a **deterministic** failure, not a flaky one — `#place-form-name` never found, every one of 25 runs, because the click lands while `<aside>` is still `inert`. Please reword the comment to say what's actually happening; "retried" will send the next person who touches this looking for a retry loop that isn't there. - **The root cause is one level deeper than "GroupFormPanel races its own disposal."** `OverlayFocusTrap.ExitAsync` updates `_openCallers`/fires `Changed` synchronously, before its own JS call — so the lag isn't in that class. It's that Blazor calls a removed component's `DisposeAsync` (and hence `ExitAsync`) asynchronously, after the render that removed it, not as part of it. That means the *whole drawer* — not just the closing panel — is briefly `inert` and unclickable after **any** modal-task panel closes, and this was already true of `PlaceFormPanel` alone before this PR; it's just never been exercised by a "close one panel, immediately act outside it" sequence until this PR gave `GroupFormPanel` the same delay. Worth a line in the ADR or a code comment naming it, since it's a real, if narrow, structural property of `OverlayFocusTrap` rather than something scoped to the test that happened to catch it here. **Adoption test proves presence, not exclusivity.** Mutation-tested both directions: pointing a panel at hand-rolled markup reddens `FindComponents<ModalOverlay>().ShouldHaveSingleItem()` as claimed. But I also added a bespoke `<div class="...-backdrop">` back in *alongside* `<ModalOverlay>` in `GroupFormPanel.razor` and the test still passed — it counts `ModalOverlay` instances, not the absence of anything else. The PR body's "not a CSS/text pattern a reimplementation could coincidentally match" oversells this one specific way. Not blocking on its own, but the claim should be narrowed or the test's own doc comment should say what it doesn't check, the way this project already does elsewhere (ADR-0066's "best-effort" framing) rather than implying a completeness it doesn't have. **Everything else checks out.** `OverlayBackdrop.razor.css`: `backdrop-filter: blur(var(--backdrop-blur))` with `--backdrop-blur: 4px` newly declared, `background-color` dims, `transition: opacity` applies on both the add and remove of `.overlay-backdrop-visible` so the fade genuinely runs both ways, and `prefers-reduced-motion` sets `transition: none` (removed outright, not shortened) — same pattern on `ModalOverlay.razor.css`'s own keyframes. `PlaceDetailPanel` isn't in the diff at all and still calls no `OverlayFocusTrap`/`ModalOverlay`/`OverlayBackdrop` method — ADR-0099's scope holds. `Drawer`'s scrim carries `HideAboveWideViewport="true"`, same condition as before, so desktop stays undimmed and the map stays reachable. `PlaceFormPanel.RequestCloseAsync` is still the one thing the close button, backdrop (via `OnDismissRequested`) and Escape (via `HandlePanelKeyDownAsync`) all route through, so the dirty-check question is still asked once, the same way, from all three. ADR-0116/ADR-0102 annotate each other correctly in both directions, scoped to exactly the three relocated paragraphs — the rest of ADR-0102 is left standing, honestly.
Correct the flaky-race explanation and prove overlay exclusivity
Some checks failed
CI / build (pull_request) Successful in 3m33s
CI / container-images (pull_request) Has been skipped
CI / e2e (pull_request) Has been cancelled
1abc7413e6
Merge remote-tracking branch 'origin/main' into task-193-shared-overlay
All checks were successful
CI / build (pull_request) Successful in 3m24s
CI / container-images (pull_request) Has been skipped
CI / e2e (pull_request) Successful in 1m57s
66fdd7a74a
# Conflicts:
#	docs/adr/README.md
rob left a comment

Verdict: mergeable

Reviewed at head 66fdd7a (superseding my "changes needed" against 4fa8d17; 1abc741 is the pre-merge parent of this head, not orphaned, but I re-reviewed the final merged tree regardless). CI run #588, status success, 7m4s, commit SHA matches exactly. Locally reproduced: clean Release build, dotnet format --verify-no-changes clean, full suite 1808/1798/10 exactly matching the PR's own count, coverage ratchet holds.

1. Race explanation — true, checked against my own instrumentation. OverlayFocusTrap.ExitAsync does update _openCallers/fire Changed synchronously before its own JS call, confirmed by re-reading the class; the lag is genuinely Blazor calling a removed component's DisposeAsync asynchronously, after the render that removed it. Stated as a live entry under ## Consequences in ADR-0116, not a footnote, with its own bold headline.

2. The same-tick fix claim — true, I tried it myself, twice. Released the trap from ModalOverlay.CloseAsync in two placements (immediately after _closing = true, and immediately before OnClose.InvokeAsync()). Both times: the drawer's own inert genuinely clears (confirmed directly — GetAttribute("inert") returns null after the click), so that half of the claim is real. But the test still fails deterministically (25/25), and differently: .modal-overlay-panel count is 0, not 1 — the newly-opened panel never survives, not merely a still-inert control. That is the "worse and less-understood failure" ADR-0116 describes, not a restatement of the original symptom. Ticket-worthy as described; not fixable as a one-line change in this PR, matching the record's own conclusion.

3. Exclusivity assertion — sound, and I mutation-tested it independently. Added my own stray <div class="…-backdrop"> next to <ModalOverlay> in GroupFormPanel.razor: Nodes.OfType<IElement>().Count() went from 2 to 3, test reddened with exactly that message, restored and green again. The count is coupled to ModalOverlay's own root shape, which reads as brittle in isolation — but it's paired with FindComponents<ModalOverlay>(), not standing alone: the component check catches "wrong implementation", the count catches "right implementation plus something extra", and neither alone would have caught what the other does. That pairing is the right mechanism, not a workaround.

4. Nothing regressed. Diffed 4fa8d17..66fdd7a directly: only the ADR, four test files, and one unrelated line in PlaceFormPanel.HandleSavedAsync (a colour-fallback literal, merged in from #136, nothing to do with this PR) changed. Backdrop CSS, PlaceDetailPanel, Drawer's desktop scrim condition and PlaceFormPanel.RequestCloseAsync's routing are byte-identical to what I already confirmed clean in the first round.

ADR index. 117 files, 117 rows, both 0116 and 0117 present; AdrIndexTests (12/12) passes mechanically rather than by my own count alone.

Verdict: mergeable Reviewed at head 66fdd7a (superseding my "changes needed" against 4fa8d17; 1abc741 is the pre-merge parent of this head, not orphaned, but I re-reviewed the final merged tree regardless). CI run #588, status success, 7m4s, commit SHA matches exactly. Locally reproduced: clean `Release` build, `dotnet format --verify-no-changes` clean, full suite **1808/1798/10** exactly matching the PR's own count, coverage ratchet holds. **1. Race explanation — true, checked against my own instrumentation.** `OverlayFocusTrap.ExitAsync` does update `_openCallers`/fire `Changed` synchronously before its own JS call, confirmed by re-reading the class; the lag is genuinely Blazor calling a removed component's `DisposeAsync` asynchronously, after the render that removed it. Stated as a live entry under `## Consequences` in ADR-0116, not a footnote, with its own bold headline. **2. The same-tick fix claim — true, I tried it myself, twice.** Released the trap from `ModalOverlay.CloseAsync` in two placements (immediately after `_closing = true`, and immediately before `OnClose.InvokeAsync()`). Both times: the drawer's own `inert` genuinely clears (confirmed directly — `GetAttribute("inert")` returns `null` after the click), so that half of the claim is real. But the test still fails deterministically (25/25), and differently: `.modal-overlay-panel` count is **0**, not 1 — the newly-opened panel never survives, not merely a still-inert control. That is the "worse and less-understood failure" ADR-0116 describes, not a restatement of the original symptom. Ticket-worthy as described; not fixable as a one-line change in this PR, matching the record's own conclusion. **3. Exclusivity assertion — sound, and I mutation-tested it independently.** Added my own stray `<div class="…-backdrop">` next to `<ModalOverlay>` in `GroupFormPanel.razor`: `Nodes.OfType<IElement>().Count()` went from 2 to 3, test reddened with exactly that message, restored and green again. The count is coupled to `ModalOverlay`'s own root shape, which reads as brittle in isolation — but it's paired with `FindComponents<ModalOverlay>()`, not standing alone: the component check catches "wrong implementation", the count catches "right implementation plus something extra", and neither alone would have caught what the other does. That pairing is the right mechanism, not a workaround. **4. Nothing regressed.** Diffed 4fa8d17..66fdd7a directly: only the ADR, four test files, and one unrelated line in `PlaceFormPanel.HandleSavedAsync` (a colour-fallback literal, merged in from #136, nothing to do with this PR) changed. Backdrop CSS, `PlaceDetailPanel`, `Drawer`'s desktop scrim condition and `PlaceFormPanel.RequestCloseAsync`'s routing are byte-identical to what I already confirmed clean in the first round. **ADR index.** 117 files, 117 rows, both 0116 and 0117 present; `AdrIndexTests` (12/12) passes mechanically rather than by my own count alone.
rob merged commit cbd3abc575 into main 2026-08-12 08:11:47 +00:00
rob deleted branch task-193-shared-overlay 2026-08-12 08:11:47 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rob/PlaceMark!137
No description provided.