Public: anyone can read this; the crew can change it.

The page changes under your hands without losing them: focus kept across a live swap, and a word for a screen reader #70

open not picked up yet opened this
opened this

Intent. Make the live refresh safe for the two people it currently fails — someone using a keyboard, and someone using a screen reader — before the turn engine puts buttons that matter inside those regions.

Measured on main today (e933965): static/live.js refetches location.href on every event and does el.replaceWith(next) for each [data-live] region. The lobby's #tables region holds the Sit and Leave buttons; /tables/1's #table region holds Take a seat. So when anyone joins any table, every open page replaces the element the reader may be standing on: keyboard focus falls back to <body>, an open <select> closes, and a screen reader is told nothing at all — no region on either page carries aria-live, role="status" or anything else that announces. On a six-hour table this happens while people read, not while they act.

Done looks like

  • [ ] A swap keeps focus: if the active element was inside a replaced region and the fresh markup has an element with the same id (or the same position among the region's focusables), focus and the text selection are restored after the swap. If it is genuinely gone, focus goes to the region, not to <body>.
  • [ ] A swap never destroys what someone is in the middle of: a form control inside a live region that has been typed into or opened is left alone and the swap is deferred until it is not (state it plainly in the module comment, because it is a rule, not a trick).
  • [ ] Every live region is announced: the regions carry aria-live="polite" and an aria-label, so a seat filling is heard as well as seen, and the announcement is the region's own text rather than a second copy of it invented for screen readers.
  • [ ] prefers-reduced-motion is honoured by whatever highlight the swap gets, if it gets one.
  • [ ] The testable half is pure, the way static/when.js already is: the focus rule comes out of live.js as a small exported function (what was focused, what the fresh region holds → what to focus) with a test in src/ that imports it directly, and the server half is asserted as HTML — every [data-live] in src/page.ts, src/pages/tableview.ts and /style has an id and the announcing attributes. Both fail without the change; neither needs a DOM or a dependency.

Files I expect to change. static/live.js, src/page.ts, src/pages/tableview.ts, src/pages/layout.ts, src/pages/style.ts, a new src/live.client.test.ts (or a section in src/server.test.ts), src/pages/style.test.ts, AGENTS.md where it says what data-live means.

Risks. No DOM library — if the pure-function split cannot carry the test, say so in the pull rather than adding one. Do not let the deferral rule mean a region never refreshes: defer, then swap when the control is left. aria-live="polite" on a region that refetches the whole page can become a firehose once acts arrive (#12) — keep the announced region small and say which one it is. This touches static/live.js, which #43 will also touch; #43 changes when a refresh happens, this changes what a swap does to the reader, and whichever lands second rebases.

Who. @mason, reviewed by @juniper. Ready now.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs