Working… page: each poll replaces the live region itself, so the finished state is not reliably announced #500

Closed
opened 2026-10-01 10:47:08 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the errand templates against the vendored htmx 2.0.10 swap code.

Checked again on 2026-10-01 against main at 0a5cad587: still present.

The page a person lands on after sending slow work off (#247) promises, in its own comment, that the change from "working" to "done" or "failed" is announced by a live region. But the live region is the very element htmx replaces on every poll, so the announcement depends on how each screen reader treats a live region that is removed and inserted again with its text already in place. Screen readers track changes inside a live region that already exists; one inserted whole is announced inconsistently, and commonly not at all for role="status".

  • Where: templates/core/partials/errand_state.html:15-20 (the div#errand-state carries role="status" aria-live="polite" aria-atomic="true" and is also its own hx-target with hx-swap="outerHTML"); templates/core/errand.html:18 includes it with no wrapper.
  • What happens: every {{ poll_seconds }} seconds htmx's outerHTML swap inserts the new div and removes the old one (a new DOM node, not a text change in the existing one). When the errand finishes, the fragment that says "done" or "That did not work." arrives as a fresh live region. Depending on the browser and reader, nothing is spoken; with some combinations the unchanged "Working…" sentence may instead be re-read on each poll.
  • Why it matters: the page exists so that somebody can wait without watching. A screen-reader user is not told when the export or render has finished, or that it failed, and has to explore the page repeatedly to find out, which is the failure the comment at lines 4-7 says the region prevents.
  • Fix: keep the live region static and swap only what is inside it. In errand.html, wrap the include in <div role="status" aria-live="polite" aria-atomic="true">, and drop those three attributes from div#errand-state in the partial, which keeps its polling attributes and its outerHTML swap. The same shape appears where an out-of-band swap (hx-swap-oob="true", which htmx performs as outerHTML) replaces the live element itself: templates/applications/partials/timeline.html:20 and the count in templates/applications/application_list.html:9-10 (likewise jobs/listing_list.html and jobs/company_list.html); those deserve the same check.

Done when

  • A test in tests/test_errands.py renders the errand page and the core:errand_state fragment and asserts that the element carrying aria-live is an ancestor of #errand-state in the page and is absent from the polled fragment; it fails today.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the errand templates against the vendored htmx 2.0.10 swap code. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The page a person lands on after sending slow work off (#247) promises, in its own comment, that the change from "working" to "done" or "failed" is announced by a live region. But the live region is the very element htmx replaces on every poll, so the announcement depends on how each screen reader treats a live region that is removed and inserted again with its text already in place. Screen readers track changes inside a live region that already exists; one inserted whole is announced inconsistently, and commonly not at all for `role="status"`. - **Where:** `templates/core/partials/errand_state.html:15-20` (the `div#errand-state` carries `role="status" aria-live="polite" aria-atomic="true"` and is also its own `hx-target` with `hx-swap="outerHTML"`); `templates/core/errand.html:18` includes it with no wrapper. - **What happens:** every `{{ poll_seconds }}` seconds htmx's `outerHTML` swap inserts the new `div` and removes the old one (a new DOM node, not a text change in the existing one). When the errand finishes, the fragment that says "done" or "That did not work." arrives as a fresh live region. Depending on the browser and reader, nothing is spoken; with some combinations the unchanged "Working…" sentence may instead be re-read on each poll. - **Why it matters:** the page exists so that somebody can wait without watching. A screen-reader user is not told when the export or render has finished, or that it failed, and has to explore the page repeatedly to find out, which is the failure the comment at lines 4-7 says the region prevents. - **Fix:** keep the live region static and swap only what is inside it. In `errand.html`, wrap the include in `<div role="status" aria-live="polite" aria-atomic="true">`, and drop those three attributes from `div#errand-state` in the partial, which keeps its polling attributes and its `outerHTML` swap. The same shape appears where an out-of-band swap (`hx-swap-oob="true"`, which htmx performs as `outerHTML`) replaces the live element itself: `templates/applications/partials/timeline.html:20` and the count in `templates/applications/application_list.html:9-10` (likewise `jobs/listing_list.html` and `jobs/company_list.html`); those deserve the same check. ## Done when - A test in `tests/test_errands.py` renders the errand page and the `core:errand_state` fragment and asserts that the element carrying `aria-live` is an ancestor of `#errand-state` in the page and is absent from the polled fragment; it fails today.
tiagoagueda added this to the 0.6.0 milestone 2026-10-01 10:47:08 +00:00
Sign in to join this conversation.
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
Postulo/postulo#500
No description provided.