From d2b03e18b59b9d8da7a69abdf6b66b30df5bf3fc Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 7 Aug 2026 16:07:22 -0400 Subject: [PATCH] fix(web): keep the keyboard when a screen change destroys the focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of the app moves between screens on a button inside the screen — "Play against a bot", launching a match, going back from the waiting screen, "Go home" on the not-found screen, "Try again" on the error screen. Each render() removes the button that was just pressed, and a removed element's focus goes to : the next Tab restarts from the top of the document, and a screen reader hears nothing about the page having changed. render() now moves focus to the new screen's heading, which is also what a screen reader reads on arrival, so the announcement and the focus target are the same element. #app is the fallback for a screen with no heading. Only when the render took the focus away. A masthead link is outside #app, so clicking Home leaves focus on Home. Neither landing element is a control the player aimed at, so tabindex="-1" gets no ring. --- web/src/dom.ts | 26 ++++++++++++++++++++++++++ web/src/styles.css | 9 +++++++++ 2 files changed, 35 insertions(+) diff --git a/web/src/dom.ts b/web/src/dom.ts index 83b830e..25b35f8 100644 --- a/web/src/dom.ts +++ b/web/src/dom.ts @@ -59,9 +59,35 @@ export function onTeardown(fn: () => void): void { export function render(...children: Node[]): void { root ??= document.querySelector("#app")!; + + // Whether this render is about to destroy the element holding focus. Read + // before the swap, because afterwards the answer is always no. + // + // Most of the app moves between screens on a button inside the screen: + // "Play against a bot", launching a match, going back, "Go home" on the + // not-found screen, "Try again" on the error screen. Every one of those + // removes the button that was just pressed, and the browser drops focus to + // , so the next Tab restarts from the top of the document and a screen + // reader is told nothing at all about the page having changed. + const stealingFocus = root.contains(document.activeElement); + activeTeardown?.(); activeTeardown = pendingTeardown; pendingTeardown = null; root.replaceChildren(...children); root.removeAttribute("aria-busy"); + + if (!stealingFocus) return; + + // The new screen's heading, which is also what a screen reader reads on + // arrival — the announcement and the focus target are the same thing. Every + // screen names itself, the camo editor with a visually-hidden h1; #app is + // the fallback for one that does not. + // + // Nothing here runs when the player clicked a masthead link: that focus is + // outside #app and stays where they put it. + const landing = root.querySelector("h1") ?? root; + landing.setAttribute("tabindex", "-1"); + // start() has already scrolled to the top, and focusing must not undo it. + landing.focus({ preventScroll: true }); } diff --git a/web/src/styles.css b/web/src/styles.css index 632d0d8..dee7a8d 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -102,6 +102,15 @@ body:has(.hero) { color: var(--muted); } +/* render() parks focus here when a screen change destroyed whatever held it, + so a screen reader names the new page and the next Tab carries on from it. + Neither is a control the player aimed at, so neither gets a ring. Only + tabindex="-1" is quietened: anything reachable by Tab keeps its own. */ +#app[tabindex="-1"]:focus, +#app h1[tabindex="-1"]:focus { + outline: none; +} + /* --- masthead ------------------------------------------------------------ Insignia, wordmark and nav on one line, sticky, about 3.5rem tall. It is the only chrome on the page, so it carries the brand on its own: the mark, the -- 2.51.2