Prove the place marker's accessible name against the accessibility tree #200

Merged
rob merged 3 commits from marker-accessible-names into main 2026-08-20 12:46:48 +00:00
Owner

Task 262 (#84). ADR-0098 amended in place, ADR-0071 corrected in place; no new number.

MarkerAccessibleNameJourneyTests reads the marker's own node out of Chromium's accessibility tree over CDP (Accessibility.getPartialAXTree) and asserts it is not ignored, is a button, is named "{place name}, {group name}", and took that name from aria-label rather than a weaker source.

The ticket's premise turned out to be half right. alt on a divIcon is indeed dead — removed from the place markers and from the current-location dot, with a comment at each call site saying why it cannot come back. But the label does reach assistive technology, and has since ADR-0071, through the explicit aria-label on the icon element; ADR-0098 was wrong about the mechanism, not about the outcome. Both records now say so, and ADR-0071 carries a pointer to the full account.

Watched to fail three ways, all three discriminating:

  • Stripped back to the mechanism ADR-0098 described — alt alone, title and aria-label removed — the accessible name is empty. The direct proof the original claim was false.
  • Dropping the group name from Home.BuildMarker's label reddens on the name.
  • Removing only the aria-label reddens on the source, "aria-label" vs "title". The first version of this test asserted the name alone and stayed green through that mutation: the marker would have degraded silently to being named by its tooltip.

Two judgements a reviewer may want to overturn. The test reloads the page before reading, because Home marks the whole map inert while any overlay is open (ADR-0086) and an inert subtree is withheld from the accessibility tree outright — the alternative was waiting on the inert attribute itself, which couples the test to that mechanism. And title still duplicates the name as the node's description, which some screen readers will announce twice; left alone because ADR-0098 values that hover tooltip for sighted mouse users.

Suite: 2210 passing, 4 skipped across the solution; E2E 57 passing.

Task 262 (#84). ADR-0098 amended in place, ADR-0071 corrected in place; no new number. `MarkerAccessibleNameJourneyTests` reads the marker's own node out of Chromium's accessibility tree over CDP (`Accessibility.getPartialAXTree`) and asserts it is not ignored, is a `button`, is named "{place name}, {group name}", and took that name from `aria-label` rather than a weaker source. The ticket's premise turned out to be half right. `alt` on a `divIcon` is indeed dead — removed from the place markers and from the current-location dot, with a comment at each call site saying why it cannot come back. But the label does reach assistive technology, and has since ADR-0071, through the explicit `aria-label` on the icon element; ADR-0098 was wrong about the mechanism, not about the outcome. Both records now say so, and ADR-0071 carries a pointer to the full account. Watched to fail three ways, all three discriminating: - Stripped back to the mechanism ADR-0098 described — `alt` alone, `title` and `aria-label` removed — the accessible name is empty. The direct proof the original claim was false. - Dropping the group name from `Home.BuildMarker`'s label reddens on the name. - Removing only the `aria-label` reddens on the *source*, `"aria-label"` vs `"title"`. The first version of this test asserted the name alone and stayed green through that mutation: the marker would have degraded silently to being named by its tooltip. Two judgements a reviewer may want to overturn. The test reloads the page before reading, because `Home` marks the whole map `inert` while any overlay is open (ADR-0086) and an inert subtree is withheld from the accessibility tree outright — the alternative was waiting on the `inert` attribute itself, which couples the test to that mechanism. And `title` still duplicates the name as the node's `description`, which some screen readers will announce twice; left alone because ADR-0098 values that hover tooltip for sighted mouse users. Suite: 2210 passing, 4 skipped across the solution; E2E 57 passing.
Prove the place marker's accessible name against the accessibility tree
All checks were successful
CI / build (pull_request) Successful in 3m39s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Successful in 6m23s
a63abc5dca
rob left a comment

Verdict: changes needed

The premise check comes out in your favour: aria-label is set on every activatable marker's icon from the add handler, Leaflet nulls _icon on remove so a fresh element — and a fresh add — is guaranteed on every path that produces one (initial plot, setMarkers rebuild, a marker breaking back out of a cluster), and Home wires OnMarkerActivated, so activatable is true there. Nothing in the suite read the removed alt. No behavioural defect is being closed as a documentation fix.

What needs changing is the argument, not the test. Both the ADR and the test's class doc justify reading the AX tree on the grounds that a DOM assertion on alt "would have passed throughout" — it would have gone red the day the pin became a div, and is precisely the assertion that would have caught this. The CDP read is still the right level for a different reason: it proves the outcome regardless of which attribute supplies it. Inline on both.

ADR-0071 still carries the falsified sentence (and the <img> premise behind the aria-label) with nothing pointing at the correction, and map.js's own comments send readers there. Inline.

On the three you flagged:

  • Asserting only the computed name is right. But name.sources comes back in the same AXNode and names the winning attribute, so pinning aria-label costs no DOM reading — see inline. Suggestion, not a blocker; recording the non-discriminating mutation was the right call either way.
  • The reload is sound: the trap's close lag is real and waiting on inert would couple the test to ADR-0086's mechanism.
  • title duplicating the name as description: leave it. Dropping it costs the tooltip and the fallback for the verbosity of one repeated announcement in some readers.
  • The map going inert while a modal overlay is open is not a ticket — that is what ADR-0086 is for, and ADR-0099 already keeps the non-modal detail panel out of it.
Verdict: changes needed The premise check comes out in your favour: `aria-label` is set on every activatable marker's icon from the `add` handler, Leaflet nulls `_icon` on remove so a fresh element — and a fresh `add` — is guaranteed on every path that produces one (initial plot, `setMarkers` rebuild, a marker breaking back out of a cluster), and `Home` wires `OnMarkerActivated`, so `activatable` is true there. Nothing in the suite read the removed `alt`. No behavioural defect is being closed as a documentation fix. What needs changing is the argument, not the test. Both the ADR and the test's class doc justify reading the AX tree on the grounds that a DOM assertion on `alt` "would have passed throughout" — it would have gone red the day the pin became a `div`, and is precisely the assertion that would have caught this. The CDP read is still the right level for a different reason: it proves the outcome regardless of which attribute supplies it. Inline on both. ADR-0071 still carries the falsified sentence (and the `<img>` premise behind the `aria-label`) with nothing pointing at the correction, and `map.js`'s own comments send readers there. Inline. On the three you flagged: - Asserting only the computed name is right. But `name.sources` comes back in the same AXNode and names the winning attribute, so pinning `aria-label` costs no DOM reading — see inline. Suggestion, not a blocker; recording the non-discriminating mutation was the right call either way. - The reload is sound: the trap's close lag is real and waiting on `inert` would couple the test to ADR-0086's mechanism. - `title` duplicating the name as description: leave it. Dropping it costs the tooltip and the fallback for the verbosity of one repeated announcement in some readers. - The map going inert while a modal overlay is open is not a ticket — that is what ADR-0086 is for, and ADR-0099 already keeps the non-modal detail panel out of it.
@ -207,0 +217,4 @@
'IMG')`; the Decision above replaced Leaflet's default `<img>` pin with an `L.divIcon` so a pin could
be recoloured, and from that moment the `alt` `map.js` kept passing was dropped silently. Nothing
announced it: no error, no warning, and the attribute the option would have produced simply never
appeared. ADR-0071's own equivalent sentence was true when written, for an `<img>` pin, and was
Author
Owner

ADR-0071 lines 149–153 still say Label is threaded to Leaflet's alt, and still justify the explicit aria-label by "an <img> whose role is overridden" — both now false of the code, and map.js's comments point readers at that record. Give ADR-0071 the same one-line Corrected: pointer this one got.

ADR-0071 lines 149–153 still say `Label` is threaded to Leaflet's `alt`, and still justify the explicit `aria-label` by "an `<img>` whose role is overridden" — both now false of the code, and `map.js`'s comments point readers at that record. Give ADR-0071 the same one-line `Corrected:` pointer this one got.
@ -207,0 +225,4 @@
in headless Chromium and reads the marker's own node out of the browser's accessibility tree over CDP
(`Accessibility.getPartialAXTree`), asserting it is not ignored, is a `button`, and is named
"{place name}, {group name}". Reading the *computed* node is the whole point: every DOM-level
assertion available — that `alt` is set, that `aria-label` is set — would have passed against the
Author
Owner

Same misstatement as the test's class doc: an assertion that alt is set would have failed against the broken code, not passed. The narrower true version is that no DOM assertion tells you what the platform computes, and the aria-label one passes either way.

Same misstatement as the test's class doc: an assertion that `alt` is set would have failed against the broken code, not passed. The narrower true version is that no DOM assertion tells you what the platform computes, and the `aria-label` one passes either way.
@ -0,0 +13,4 @@
/// an <c>alt</c> option, ADR-0098 recorded the label as "read aloud by a screen reader" partly on the
/// strength of it, and Leaflet applies <c>alt</c> only to an <c>IMG</c> — on the <c>divIcon</c> pin
/// every marker has been drawn with since that same record it was dropped silently. A test asserting
/// that <c>alt</c> was set would have passed throughout and proved nothing about what a screen reader
Author
Owner

alt was never applied to the div, so getAttribute("alt") would have gone red the day the pin stopped being an <img> — that assertion would have caught this, not missed it. Reword to the claim that holds: no DOM assertion tells you what the platform computes as the name, and the aria-label one passes whether or not the name ever reaches assistive technology.

`alt` was never applied to the `div`, so `getAttribute("alt")` would have gone red the day the pin stopped being an `<img>` — that assertion would have caught this, not missed it. Reword to the claim that holds: no DOM assertion tells you what the platform computes as the name, and the `aria-label` one passes whether or not the name ever reaches assistive technology.
@ -0,0 +46,4 @@
// still unreachable — a distinct failure from having no name, and worth telling apart.
marker.Ignored.ShouldBeFalse();
marker.Role.ShouldBe("button");
marker.Name.ShouldBe($"{placeName}, {groupName}");
Author
Owner

Optional: getPartialAXTree returns name.sources alongside name. Asserting the winning source's attribute is aria-label would redden the mutation you recorded as non-discriminating, still reading the computed tree rather than the DOM.

Optional: `getPartialAXTree` returns `name.sources` alongside `name`. Asserting the winning source's `attribute` is `aria-label` would redden the mutation you recorded as non-discriminating, still reading the computed tree rather than the DOM.
rob force-pushed marker-accessible-names from a63abc5dca
All checks were successful
CI / build (pull_request) Successful in 3m39s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Successful in 6m23s
to bbfaa453c5
Some checks failed
CI / e2e (pull_request) Has been cancelled
CI / container-images (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
2026-08-20 11:59:29 +00:00
Compare
rob force-pushed marker-accessible-names from bbfaa453c5
Some checks failed
CI / e2e (pull_request) Has been cancelled
CI / container-images (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
to 8ed9f98eb3
All checks were successful
CI / build (pull_request) Successful in 3m26s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Successful in 4m27s
2026-08-20 12:00:46 +00:00
Compare
rob left a comment

Verdict: changes needed

Reviewed at 8ed9f98. One thing left, and it is the same species as the one just fixed, four lines below the correction that fixed it: ADR-0071 still says a null label falls back to "Leaflet's own default, \"Marker\"", and src/PlaceMark.WebUI/Maps/MapMarker.cs's Label param doc says it verbatim. That fallback was the alt default. A picker marker is never activatable, so it gets no aria-label; title resolves to "", which Leaflet does not set; and there is no alt. A null-labelled marker now has no accessible name at all — the right answer for a non-interactive pin, and not what either record says. MapMarker.cs is the copy a developer actually reads. Inline on the ADR line.

Everything else verified at this head:

  • name.sources is genuinely stronger, not differently worded. Chromium lists every candidate in accname order and marks the losers superseded, so "first source that carries a value and is not superseded" is DevTools' own rule rather than an ordering guess of the test's; with the aria-label gone, title becomes the first unsuperseded source carrying a value, which is the "aria-label" vs "title" red you saw. If Chromium ever changes that shape the test fails loudly on the wrong source name rather than passing silently, so the fragility falls the right way.
  • Both rewritten passages are accurate in both directions. The alt assertion would indeed have gone red the day the pin became a div, and the three things named as beyond the DOM's reach — exposure, computed name and role, which attribute wins — are each carried by an assertion (Ignored, Role/Name, NameSource). The tree-level justification does exactly the work claimed and no more.
  • Pointers resolve, 0071 → 0098 twice as relative links. The 0098 → 0071 direction is prose only ("ADR-0071's own equivalent sentence…"), unlinked while every other cross-reference in that section is a link — worth linking while you are in there.
  • The dot's alt removal changes nothing observable. Against merged main: currentLocationIcon is an L.divIcon, so that alt was equally dead; CurrentLocationJourneyTests reads class and tabindex, never alt; ADR-0167 never claimed a name for the dot, so nothing else needs correcting there. No name is the right answer for interactive: false + keyboard: false.
Verdict: changes needed Reviewed at `8ed9f98`. One thing left, and it is the same species as the one just fixed, four lines below the correction that fixed it: ADR-0071 still says a null label falls back to "Leaflet's own default, `\"Marker\"`", and `src/PlaceMark.WebUI/Maps/MapMarker.cs`'s `Label` param doc says it verbatim. That fallback was the `alt` default. A picker marker is never activatable, so it gets no `aria-label`; `title` resolves to `""`, which Leaflet does not set; and there is no `alt`. A null-labelled marker now has no accessible name at all — the right answer for a non-interactive pin, and not what either record says. `MapMarker.cs` is the copy a developer actually reads. Inline on the ADR line. Everything else verified at this head: - **`name.sources` is genuinely stronger, not differently worded.** Chromium lists every candidate in accname order and marks the losers `superseded`, so "first source that carries a value and is not superseded" is DevTools' own rule rather than an ordering guess of the test's; with the `aria-label` gone, `title` becomes the first unsuperseded source carrying a value, which is the `"aria-label"` vs `"title"` red you saw. If Chromium ever changes that shape the test fails loudly on the wrong source name rather than passing silently, so the fragility falls the right way. - **Both rewritten passages are accurate in both directions.** The `alt` assertion would indeed have gone red the day the pin became a `div`, and the three things named as beyond the DOM's reach — exposure, computed name and role, which attribute wins — are each carried by an assertion (`Ignored`, `Role`/`Name`, `NameSource`). The tree-level justification does exactly the work claimed and no more. - **Pointers resolve**, 0071 → 0098 twice as relative links. The 0098 → 0071 direction is prose only ("ADR-0071's own equivalent sentence…"), unlinked while every other cross-reference in that section is a link — worth linking while you are in there. - **The dot's `alt` removal changes nothing observable.** Against merged `main`: `currentLocationIcon` is an `L.divIcon`, so that `alt` was equally dead; `CurrentLocationJourneyTests` reads class and `tabindex`, never `alt`; ADR-0167 never claimed a name for the dot, so nothing else needs correcting there. No name is the right answer for `interactive: false` + `keyboard: false`.
@ -154,1 +169,3 @@
place's own name; `PlaceForm`'s one picker marker passes nothing, falling back to Leaflet's own default,
Leaflet's own `alt` and `title` marker options and to the explicit `aria-label` above (`alt` no
longer — see the correction above). `Home` passes the place's own name; `PlaceForm`'s one picker
marker passes nothing, falling back to Leaflet's own default,
Author
Owner

This clause is the third casualty of the same 2026-08-11 change, and survived the rewrite: "Leaflet's own default, \"Marker\"" is the alt default, and nothing applies it now. PlaceForm's picker marker is not activatable, so addKeyboardActivation never runs and no aria-label is set; title resolves to "", which Leaflet skips; there is no alt. That marker has no accessible name at all — correct for a non-interactive pin, but say that instead. MapMarker.cs's Label param doc carries the same sentence and is the copy a developer reads.

This clause is the third casualty of the same 2026-08-11 change, and survived the rewrite: "Leaflet's own default, `\"Marker\"`" is the `alt` default, and nothing applies it now. `PlaceForm`'s picker marker is not activatable, so `addKeyboardActivation` never runs and no `aria-label` is set; `title` resolves to `""`, which Leaflet skips; there is no `alt`. That marker has no accessible name at all — correct for a non-interactive pin, but say that instead. `MapMarker.cs`'s `Label` param doc carries the same sentence and is the copy a developer reads.
Correct the picker marker's non-existent "Marker" name fallback
All checks were successful
CI / build (pull_request) Successful in 3m42s
CI / container-images (pull_request) Successful in 3s
CI / e2e (pull_request) Successful in 6m39s
b8b8357f7b
rob left a comment

Verdict: mergeable

One line inline on ADR-0071's front matter, not blocking.

Verified at b8b8357:

  • The unchanged title paragraph is still true, and needed the subject change it got: toLeafletMarkers passes title for every marker regardless of activatable, so "not only when a marker is activatable" is the live contrast. The empty-label case it does not cover is stated in the correction immediately above it.
  • Every library claim checks out against the vendored bundle (blob a3bf693, byte-identical to main's), not against the prose: Marker's defaults are title: "", alt: "Marker"; _initIcon does t.title && (i.title = t.title), so an empty title is skipped, and "IMG" === i.tagName && (i.alt = t.alt || ""), so that "Marker" default only ever reached an <img>. LeafletMap creates an activation reference only when OnMarkerActivated.HasDelegate, and only Home wires it — so the picker marker genuinely gets no aria-label, and generic/no name is the expected computed answer.
  • Pointers resolve in both directions, and the second blockquote does not contradict the first: it corrects a different sentence and says so.
  • Nothing regressedname.sources, the three tree assertions and the dot's alt removal are unchanged from 8ed9f98.

The rate-limiting flakes are worth their own ticket, not a fold into 263. Same family — a test racing wall-clock under load — but a different mechanism and a much cheaper fix. Both classes set RateLimiting:Auth:Window to 2 seconds with a permit limit of 2, then make three real HTTP requests that must all land inside one window. AuthenticationRateLimiterTests does a deliberately expensive password hash on every one of them (ADR-0035's own point), and OidcRateLimitingTests pays the stub provider's discovery on the first. If the window rolls between the first request and the third, the third is permitted and the 429 assertion fails — exactly the observed shape, and exactly why it is those two classes and nothing else. The limiter is not measuring anything wrongly; the test is given a two-second budget for work whose duration it does not control.

The remedy is not 263's injectable clock. The window is already per-class configuration, and PostLogin_AfterTheWindowElapses_TheSameAddressIsPermittedAgain is the only test that has to observe expiry — it currently forces every other test in its class to share its short window. Give the enforcement tests a long window (tens of seconds; they never wait it out) and leave the expiry test its own short one. No timeout is raised anywhere, which keeps it inside 263's own constraint.

Verdict: mergeable One line inline on ADR-0071's front matter, not blocking. Verified at `b8b8357`: - **The unchanged `title` paragraph is still true**, and needed the subject change it got: `toLeafletMarkers` passes `title` for every marker regardless of `activatable`, so "not only when a marker is activatable" is the live contrast. The empty-label case it does not cover is stated in the correction immediately above it. - **Every library claim checks out against the vendored bundle** (blob `a3bf693`, byte-identical to `main`'s), not against the prose: `Marker`'s defaults are `title: ""`, `alt: "Marker"`; `_initIcon` does `t.title && (i.title = t.title)`, so an empty `title` is skipped, and `"IMG" === i.tagName && (i.alt = t.alt || "")`, so that `"Marker"` default only ever reached an `<img>`. `LeafletMap` creates an activation reference only when `OnMarkerActivated.HasDelegate`, and only `Home` wires it — so the picker marker genuinely gets no `aria-label`, and `generic`/no name is the expected computed answer. - **Pointers resolve in both directions**, and the second blockquote does not contradict the first: it corrects a different sentence and says so. - **Nothing regressed** — `name.sources`, the three tree assertions and the dot's `alt` removal are unchanged from `8ed9f98`. **The rate-limiting flakes are worth their own ticket, not a fold into 263.** Same family — a test racing wall-clock under load — but a different mechanism and a much cheaper fix. Both classes set `RateLimiting:Auth:Window` to 2 seconds with a permit limit of 2, then make three real HTTP requests that must all land inside one window. `AuthenticationRateLimiterTests` does a deliberately expensive password hash on every one of them (ADR-0035's own point), and `OidcRateLimitingTests` pays the stub provider's discovery on the first. If the window rolls between the first request and the third, the third is permitted and the `429` assertion fails — exactly the observed shape, and exactly why it is those two classes and nothing else. The limiter is not measuring anything wrongly; the test is given a two-second budget for work whose duration it does not control. The remedy is not 263's injectable clock. The window is already per-class configuration, and `PostLogin_AfterTheWindowElapses_TheSameAddressIsPermittedAgain` is the only test that has to observe expiry — it currently forces every other test in its class to share its short window. Give the enforcement tests a long window (tens of seconds; they never wait it out) and leave the expiry test its own short one. No timeout is raised anywhere, which keeps it inside 263's own constraint.
@ -1,6 +1,11 @@
# ADR-0071: Extend the Leaflet interop boundary again, so clicking a marker selects its place
- **Status:** Accepted (partly superseded by 0101, 0131)
- **Corrected:** 2026-08-20, working Vikunja task 262 — two sentences of this record's own
Author
Owner

Undercounts its own body now: two correction blockquotes covering three claims (the two alt clauses, the threading sentence, the "Marker" fallback), while this says "two sentences" and "the correction ... below", singular.

Undercounts its own body now: two correction blockquotes covering three claims (the two `alt` clauses, the threading sentence, the `"Marker"` fallback), while this says "two sentences" and "the correction ... below", singular.
rob merged commit f931ef3b1f into main 2026-08-20 12:46:48 +00:00
rob deleted branch marker-accessible-names 2026-08-20 12:46:48 +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!200
No description provided.