Ring the selected pin's base in its own group colour #199

Merged
rob merged 2 commits from selected-pin-ring into main 2026-08-20 07:51:52 +00:00
Owner

Task 256 (#80).

The ring is a CSS pseudo-element on the selected marker's own icon element, not a Leaflet layer of its own. map.js records which marker is selected and puts a class plus a --placemark-selection-ring-colour custom property on that icon; leaflet-theme.css draws the circle at (12, 41) — the pin's anchor — behind the pin (z-index: -1) and out of hit-testing (pointer-events: none). Riding on the icon element is what makes the ring follow the pin for free: it exists exactly when and where the pin does, so a cluster collapsing it takes the ring with it, and a wholesale setMarkers rebuild puts it back through the marker's own add handler without .NET re-sending anything.

Contrast comes from two box-shadow edges (outer white, inner dark) rather than a theme token, because the colour is whatever hex the group holds (ADR-0117) and could vanish against either scheme's tiles on its own.

LeafletMap.SelectedMarkerId is a parameter of its own rather than a flag on MapMarker: Markers is compared with SequenceEqual and replaced wholesale, so a per-marker flag would rebuild every pin to move one ring.

No ADR — this settles nothing that was open.

Tests: 8 new bUnit (LeafletMapTests, HomeTests), 6 new Playwright (SelectedPinRingJourneyTests, both colour schemes). Each was watched to fail against a deliberate break and pass again after restoring — including one that did not: the first version of the E2E class passed with map.js's marker-level add handler deleted outright, because every test selected and then read, an order in which setSelectedMarker's own sweep suffices. SelectedMarker_ItsGroupRecolouredWhileStillSelected_KeepsTheRingInTheNewColour was added to force the layer rebuild that half exists for, and it reddens.

Clustering is not driven separately in E2E: it destroys and rebuilds the icon element by the same path a recolour does, which that test covers.

Task 256 (#80). The ring is a CSS pseudo-element on the selected marker's own icon element, not a Leaflet layer of its own. `map.js` records which marker is selected and puts a class plus a `--placemark-selection-ring-colour` custom property on that icon; `leaflet-theme.css` draws the circle at `(12, 41)` — the pin's anchor — behind the pin (`z-index: -1`) and out of hit-testing (`pointer-events: none`). Riding on the icon element is what makes the ring follow the pin for free: it exists exactly when and where the pin does, so a cluster collapsing it takes the ring with it, and a wholesale `setMarkers` rebuild puts it back through the marker's own `add` handler without .NET re-sending anything. Contrast comes from two `box-shadow` edges (outer white, inner dark) rather than a theme token, because the colour is whatever hex the group holds (ADR-0117) and could vanish against either scheme's tiles on its own. `LeafletMap.SelectedMarkerId` is a parameter of its own rather than a flag on `MapMarker`: `Markers` is compared with `SequenceEqual` and replaced wholesale, so a per-marker flag would rebuild every pin to move one ring. No ADR — this settles nothing that was open. Tests: 8 new bUnit (`LeafletMapTests`, `HomeTests`), 6 new Playwright (`SelectedPinRingJourneyTests`, both colour schemes). Each was watched to fail against a deliberate break and pass again after restoring — including one that did not: the first version of the E2E class passed with `map.js`'s marker-level `add` handler deleted outright, because every test selected and then read, an order in which `setSelectedMarker`'s own sweep suffices. `SelectedMarker_ItsGroupRecolouredWhileStillSelected_KeepsTheRingInTheNewColour` was added to force the layer rebuild that half exists for, and it reddens. Clustering is not driven separately in E2E: it destroys and rebuilds the icon element by the same path a recolour does, which that test covers.
Ring the selected pin's base in its own group colour
Some checks failed
CI / build (pull_request) Successful in 6m6s
CI / container-images (pull_request) Successful in 5s
CI / e2e (pull_request) Has been cancelled
8d473a6f0e
rob left a comment

Verdict: changes needed

One required change: nothing in the suite discriminates the stacking-context premise the whole design rests on — see the inline comment on ReadRingAsync. z-index: -1 reads -1 from the pseudo-element whether or not the icon establishes a context, so the assertion cannot fail for the thing it is there to guard. Two lines in ReadRingAsync closes it.

Everything else below is a note, not a gate.

The mechanism itself checks out. L.divIcon (so ::before renders at all), iconSize: [25, 41], iconAnchor: [12, 41]; left: 12px; bottom: 0 + translate(-50%, 50%) puts the ring's centre on exactly (12, 41), and html { box-sizing: border-box } (app.css) makes that a 22px outer diameter with the stroke at radius 8–11, which is what the click test's "11px radius" assumes. Leaflet writes both a transform and a numeric z-index onto .leaflet-marker-icon, either of which forms the context, and paint order then puts a negative-z-index pseudo-element above the icon's own background but below its inline <svg> — behind the pin, not behind the tiles. .leaflet-container's overflow: hidden is the only thing that clips, at the map edge, where the pin clips too.

Mutation account, checked rather than taken on trust:

  • Deleting the marker-level add handler is caught by SelectedMarker_ItsGroupRecolouredWhileStillSelected_KeepsTheRingInTheNewColour and by nothing else. The other five E2E tests and all eight bUnit tests select-then-read and would stay green. The PR body says exactly this and it is right.
  • Deleting setSelectedMarker's sweep, or the id it records, reddens every E2E test.
  • SelectedMarker_ClickJustBelowTheRing_ReachesTheMapNotTheMarker does discriminate pointer-events: none: Leaflet gives an interactive icon pointer-events: auto, the pseudo-element inherits it, the clicked point (anchor + 6px) falls inside the border-radius circle, and the resulting marker click re-selects and leaves the panel up. The page/host coordinate mixing is safe only because .app-shell is position: fixed; inset: 0 and the host fills it — worth knowing if that layout ever changes.
  • The eight bUnit tests each fail against the obvious break (the first-render call, the null branch, the redundant-call guard, the deliberate no-re-send on a Markers change).

SelectedMarkerId as its own parameter is right: Markers is compared with SequenceEqual and replaced wholesale, and MapMarker is a record, so a per-marker flag would rebuild every pin to move one ring.

Unverified visually, flagged so Rob knows to eyeball it: the 22px diameter, the 3px stroke and the contrast of the two shadow edges have only ever been read as computed styles. Nothing asserts the ring's geometry at all — colour, z-index and pointer-events are the only properties any test reads — so "sits at the pin's base" is correct by arithmetic and unproven by observation.

E2E does run in CI. .forgejo/workflows/ci.yml has a dedicated e2e job, runs-on: ubuntu-24.04, needs: build, setting PLACEMARK_RUN_E2E_TESTS: "true" and running dotnet test tests/PlaceMark.E2E.Tests/.... There is no JS unit-test runner and no package.json anywhere in the repo — map.js is only ever exercised through Playwright — which is probably what the parallel PR meant, but "no E2E runner" is false. Run #811 for this head is still in flight, so nothing here is confirmed green yet.

PR #198: no functional interaction. Its current-location marker carries placemark-current-location-icon, is added straight to the map rather than to placeMarkMarkers, and is interactive: false — so the sweep never visits it, the ring selector never matches it, and this suite's .placemark-marker-icon locators do not pick it up. Textual conflicts only, in map.js, LeafletMap.razor.cs, Home.razor, LeafletMapTests.cs and HomeTests.cs (both add to the same SetUpMapModule helper).

Verdict: changes needed One required change: nothing in the suite discriminates the stacking-context premise the whole design rests on — see the inline comment on `ReadRingAsync`. `z-index: -1` reads `-1` from the pseudo-element whether or not the icon establishes a context, so the assertion cannot fail for the thing it is there to guard. Two lines in `ReadRingAsync` closes it. Everything else below is a note, not a gate. The mechanism itself checks out. `L.divIcon` (so `::before` renders at all), `iconSize: [25, 41]`, `iconAnchor: [12, 41]`; `left: 12px; bottom: 0` + `translate(-50%, 50%)` puts the ring's centre on exactly (12, 41), and `html { box-sizing: border-box }` (app.css) makes that a 22px outer diameter with the stroke at radius 8–11, which is what the click test's "11px radius" assumes. Leaflet writes both a `transform` and a numeric `z-index` onto `.leaflet-marker-icon`, either of which forms the context, and paint order then puts a negative-z-index pseudo-element above the icon's own background but below its inline `<svg>` — behind the pin, not behind the tiles. `.leaflet-container`'s `overflow: hidden` is the only thing that clips, at the map edge, where the pin clips too. Mutation account, checked rather than taken on trust: - Deleting the marker-level `add` handler is caught by `SelectedMarker_ItsGroupRecolouredWhileStillSelected_KeepsTheRingInTheNewColour` and by nothing else. The other five E2E tests and all eight bUnit tests select-then-read and would stay green. The PR body says exactly this and it is right. - Deleting `setSelectedMarker`'s sweep, or the id it records, reddens every E2E test. - `SelectedMarker_ClickJustBelowTheRing_ReachesTheMapNotTheMarker` does discriminate `pointer-events: none`: Leaflet gives an interactive icon `pointer-events: auto`, the pseudo-element inherits it, the clicked point (anchor + 6px) falls inside the border-radius circle, and the resulting marker click re-selects and leaves the panel up. The page/host coordinate mixing is safe only because `.app-shell` is `position: fixed; inset: 0` and the host fills it — worth knowing if that layout ever changes. - The eight bUnit tests each fail against the obvious break (the first-render call, the null branch, the redundant-call guard, the deliberate no-re-send on a `Markers` change). `SelectedMarkerId` as its own parameter is right: `Markers` is compared with `SequenceEqual` and replaced wholesale, and `MapMarker` is a record, so a per-marker flag would rebuild every pin to move one ring. Unverified visually, flagged so Rob knows to eyeball it: the 22px diameter, the 3px stroke and the contrast of the two shadow edges have only ever been read as computed styles. Nothing asserts the ring's geometry at all — colour, `z-index` and `pointer-events` are the only properties any test reads — so "sits at the pin's base" is correct by arithmetic and unproven by observation. E2E does run in CI. `.forgejo/workflows/ci.yml` has a dedicated `e2e` job, `runs-on: ubuntu-24.04`, `needs: build`, setting `PLACEMARK_RUN_E2E_TESTS: "true"` and running `dotnet test tests/PlaceMark.E2E.Tests/...`. There is no JS unit-test runner and no `package.json` anywhere in the repo — `map.js` is only ever exercised through Playwright — which is probably what the parallel PR meant, but "no E2E runner" is false. Run #811 for this head is still in flight, so nothing here is confirmed green yet. PR #198: no functional interaction. Its current-location marker carries `placemark-current-location-icon`, is added straight to the map rather than to `placeMarkMarkers`, and is `interactive: false` — so the sweep never visits it, the ring selector never matches it, and this suite's `.placemark-marker-icon` locators do not pick it up. Textual conflicts only, in `map.js`, `LeafletMap.razor.cs`, `Home.razor`, `LeafletMapTests.cs` and `HomeTests.cs` (both add to the same `SetUpMapModule` helper).
@ -74,0 +112,4 @@
transform: translate(-50%, 50%);
border: 3px solid var(--placemark-selection-ring-colour, currentColor);
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.9), inset 0 0 0 1px rgba(0, 0, 0, 0.35);
Author
Owner

The two edges are not equivalent, and the weaker one is the one a pale group colour depends on. The outer white is 1px at 0.9 alpha; the inner dark is 1px at 0.35 alpha, and being inset it is clipped to the padding box, so it renders as a hairline inside the hole rather than bounding the stroke. A near-white ring on light tiles therefore reads on that hairline alone (~2.3:1). Consider a second outer edge — 0 0 0 1px rgba(255,255,255,0.9), 0 0 0 2px rgba(0,0,0,0.35) — so both bounds sit outside the stroke regardless of colour. Judge it in a browser first; this is an eyeball call, not a computed one.

The two edges are not equivalent, and the weaker one is the one a pale group colour depends on. The outer white is 1px at 0.9 alpha; the inner dark is 1px at 0.35 alpha, and being `inset` it is clipped to the padding box, so it renders as a hairline inside the hole rather than bounding the stroke. A near-white ring on light tiles therefore reads on that hairline alone (~2.3:1). Consider a second *outer* edge — `0 0 0 1px rgba(255,255,255,0.9), 0 0 0 2px rgba(0,0,0,0.35)` — so both bounds sit outside the stroke regardless of colour. Judge it in a browser first; this is an eyeball call, not a computed one.
@ -0,0 +87,4 @@
// Two separate pins, not one cluster badge — the precondition everything below depends on, and
// the thing a coordinate change in either journey step would silently take away.
await Expect(page.Locator(".placemark-marker-icon")).ToHaveCountAsync(2);
Author
Owner

This is one screen-distance away from flaking. AddPlaceAsync plots at a fixed (51.5, -0.1); AddPlaceByClickingTheMapAsync clicks a fixed (700, 300). Whether those two land more than maxClusterRadius: 80 apart depends entirely on the default centre and zoom the home map opens at — change either and this collapses into one badge. It fails loudly rather than silently, which is why it is a note, but pinning the second point relative to the first pin's own bounding box would remove the coupling.

This is one screen-distance away from flaking. `AddPlaceAsync` plots at a fixed (51.5, -0.1); `AddPlaceByClickingTheMapAsync` clicks a fixed (700, 300). Whether those two land more than `maxClusterRadius: 80` apart depends entirely on the default centre and zoom the home map opens at — change either and this collapses into one badge. It fails loudly rather than silently, which is why it is a note, but pinning the second point relative to the first pin's own bounding box would remove the coupling.
@ -0,0 +265,4 @@
const pinColour = getComputedStyle(probe).color;
probe.remove();
return [ring.borderTopColor, pinColour, ring.zIndex, ring.pointerEvents];
Author
Owner

The stacking-context premise is asserted nowhere. Every test reads only getComputedStyle(pin, '::before').zIndex, which returns -1 whether or not the icon element establishes a stacking context — so if Leaflet ever stopped writing a numeric z-index onto the marker icon, the ring would paint beneath the tile pane and all 14 tests here would stay green. That is the single load-bearing claim in leaflet-theme.css's own comment and the one thing nothing discriminates.

Add getComputedStyle(pin).zIndex to this array and assert it is not "auto" (ZIndexComputedStackingJourneyTests is the precedent for reading a computed stacking property rather than a declared one).

The stacking-context premise is asserted nowhere. Every test reads only `getComputedStyle(pin, '::before').zIndex`, which returns `-1` whether or not the icon element establishes a stacking context — so if Leaflet ever stopped writing a numeric `z-index` onto the marker icon, the ring would paint beneath the tile pane and all 14 tests here would stay green. That is the single load-bearing claim in `leaflet-theme.css`'s own comment and the one thing nothing discriminates. Add `getComputedStyle(pin).zIndex` to this array and assert it is not `"auto"` (`ZIndexComputedStackingJourneyTests` is the precedent for reading a computed stacking property rather than a declared one).
rob force-pushed selected-pin-ring from 8d473a6f0e
Some checks failed
CI / build (pull_request) Successful in 6m6s
CI / container-images (pull_request) Successful in 5s
CI / e2e (pull_request) Has been cancelled
to d3364114e3
All checks were successful
CI / build (pull_request) Successful in 9m24s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Successful in 5m44s
2026-08-20 07:27:00 +00:00
Compare
rob left a comment

Verdict: mergeable

Re-reviewed at d336411 (previous verdict was against 8d473a6, since force-pushed). Nothing to action.

The gating finding is closed correctly. ReadRingAsync now returns getComputedStyle(pin).zIndex — read from .placemark-marker-icon, which is the icon element the ::before is generated on and therefore exactly the element that has to be the containing stacking context, not the pseudo-element. ShouldNotBe("auto") is the right strength rather than a lucky one: a positioned element with any numeric z-index establishes a stacking context, 0 included, so the assertion admits every value that works and rejects only the one that does not. Both colour-scheme tests carry it through AssertRingMatchesThePinAsync.

The mutation is honest. z-index: auto !important on .leaflet-container .placemark-marker-icon touches one property on one class: it cannot reach content (so WaitForRingAsync still gets past and the test reaches the assertion rather than timing out early), nor the custom property, the border colour or pointer-events, and with a single marker on the map there is no marker-ordering side effect either. The reddening is attributable to PinZIndex and nothing else.

One thing to have on the record, not to act on: Leaflet also writes a transform onto the icon, which independently creates a stacking context, so the mutation proved the tripwire fires rather than reproducing an actual ring-under-the-tiles. That makes the assertion conservative — it would cry wolf before it would miss a regression, which is the safe direction. Its one unstated premise is that the icon stays position: absolute (leaflet.css); if it ever weren't positioned a numeric z-index would be inert, but the ring's geometry would visibly break first.

Rebase is clean and nothing regressed with it. Merge base is b3e1239; the map.js and leaflet-theme.css diffs are byte-identical to the reviewed head (same blob hashes, same hunks), and every other file's additions and deletions are unchanged — only SelectedPinRingJourneyTests.cs grew, by 12 lines. Every earlier mutation conclusion therefore still applies to code that did not move, independent of whether they were re-run. Two commits, subject-only messages, no AI attribution. CI #815 green on this head, and the e2e job sets PLACEMARK_RUN_E2E_TESTS unconditionally, so the six Playwright tests genuinely ran rather than skipping.

All three deliberate non-actions are reasonable. The inset hairline is a judgement that needs a screen, and widening it blind would be guessing; the coordinate coupling and the geometry gap are recorded where the next reader of those tests will find them.

Merge order: no reason this should not go first. It is the only one of the three that is green and already rebased on current main. Whichever follows picks up the conflicts — #198 in Home.razor, LeafletMap.razor.cs and the SetUpMapModule helpers (its map.js hunks are appended at end of file, so those are clear), and #197 more awkwardly, since it deletes the ZoomRequest wiring from the same Home.razor comment block and the same OnParametersSetAsync region this adds SelectedMarkerId to, and will want its coverage-baseline.json figure re-derived afterwards rather than carried over.

Verdict: mergeable Re-reviewed at `d336411` (previous verdict was against `8d473a6`, since force-pushed). Nothing to action. The gating finding is closed correctly. `ReadRingAsync` now returns `getComputedStyle(pin).zIndex` — read from `.placemark-marker-icon`, which is the icon element the `::before` is generated on and therefore exactly the element that has to be the containing stacking context, not the pseudo-element. `ShouldNotBe("auto")` is the right strength rather than a lucky one: a positioned element with *any* numeric `z-index` establishes a stacking context, `0` included, so the assertion admits every value that works and rejects only the one that does not. Both colour-scheme tests carry it through `AssertRingMatchesThePinAsync`. The mutation is honest. `z-index: auto !important` on `.leaflet-container .placemark-marker-icon` touches one property on one class: it cannot reach `content` (so `WaitForRingAsync` still gets past and the test reaches the assertion rather than timing out early), nor the custom property, the border colour or `pointer-events`, and with a single marker on the map there is no marker-ordering side effect either. The reddening is attributable to `PinZIndex` and nothing else. One thing to have on the record, not to act on: Leaflet also writes a `transform` onto the icon, which independently creates a stacking context, so the mutation proved the tripwire fires rather than reproducing an actual ring-under-the-tiles. That makes the assertion conservative — it would cry wolf before it would miss a regression, which is the safe direction. Its one unstated premise is that the icon stays `position: absolute` (leaflet.css); if it ever weren't positioned a numeric `z-index` would be inert, but the ring's geometry would visibly break first. Rebase is clean and nothing regressed with it. Merge base is `b3e1239`; the `map.js` and `leaflet-theme.css` diffs are byte-identical to the reviewed head (same blob hashes, same hunks), and every other file's additions and deletions are unchanged — only `SelectedPinRingJourneyTests.cs` grew, by 12 lines. Every earlier mutation conclusion therefore still applies to code that did not move, independent of whether they were re-run. Two commits, subject-only messages, no AI attribution. CI #815 green on this head, and the `e2e` job sets `PLACEMARK_RUN_E2E_TESTS` unconditionally, so the six Playwright tests genuinely ran rather than skipping. All three deliberate non-actions are reasonable. The inset hairline is a judgement that needs a screen, and widening it blind would be guessing; the coordinate coupling and the geometry gap are recorded where the next reader of those tests will find them. Merge order: no reason this should not go first. It is the only one of the three that is green and already rebased on current `main`. Whichever follows picks up the conflicts — #198 in `Home.razor`, `LeafletMap.razor.cs` and the `SetUpMapModule` helpers (its `map.js` hunks are appended at end of file, so those are clear), and #197 more awkwardly, since it deletes the `ZoomRequest` wiring from the same `Home.razor` comment block and the same `OnParametersSetAsync` region this adds `SelectedMarkerId` to, and will want its `coverage-baseline.json` figure re-derived afterwards rather than carried over.
rob merged commit 10ce6103a4 into main 2026-08-20 07:51:52 +00:00
rob deleted branch selected-pin-ring 2026-08-20 07:51:52 +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!199
No description provided.