From 4bb0b54ae417f89576487e2ceb9000cd8d2daaf7 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 18 Aug 2026 22:30:56 -0400 Subject: [PATCH] feat(lobby): the pilot card waits to be pointed at, and stays on the screen The pointer has to rest on a name before the card opens, and leaving cancels it, so crossing four names on the way to a button opens none of them. Focus still opens at once, the trigger carries aria-describedby while its card is open, and a card opened low on the screen is clamped to the viewport. --- plan/lobby.md | 13 ++++++ web/scripts/pilot-hover.test.mjs | 54 +++++++++++++++++++++++ web/src/screens/pilot-hover.ts | 74 +++++++++++++++++++++++++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) diff --git a/plan/lobby.md b/plan/lobby.md index d19f745..c27185a 100644 --- a/plan/lobby.md +++ b/plan/lobby.md @@ -406,6 +406,19 @@ indefinitely because a lobby is a small room the people in it opened. sit between a cursor and the control underneath. Chat speakers are deliberately *not* focusable — the same four people are named on every line, and each of them is one tab stop in the roster. +- [x] **The card is only there when somebody is pointing.** It opened as the + lobby was built, empty, because `.pilot-hover` sets `display: flex` and + a class rule beats the browser's own rule for `[hidden]` — so `hidden` + was doing nothing, on the first paint and on every close after it. + + Pointing now has to mean pointing: the pointer rests on a name for a + moment before the card opens, so crossing four names on the way to a + button opens none of them, and leaving cancels a rest that has not + finished. Focus does not wait — arriving at a trigger with a keyboard + is already deliberate. The trigger carries `aria-describedby` while + its card is open and loses it when the card closes, and a card opened + from a name near the foot of the screen is clamped to the viewport + instead of running off it. - [x] **A host can hold a slot, and hand the lobby on.** Two controls, both the host's and both refused for anybody else the same silent way Kick already is. diff --git a/web/scripts/pilot-hover.test.mjs b/web/scripts/pilot-hover.test.mjs index 41a7b08..17e03e3 100644 --- a/web/scripts/pilot-hover.test.mjs +++ b/web/scripts/pilot-hover.test.mjs @@ -167,3 +167,57 @@ test("a closed card is closed in the stylesheet as well as in the DOM", () => { "does nothing without it, because `display: flex` overrides it", ); }); + +test("the pointer rests on a name before the card opens; the keyboard does not wait", () => { + const attach = source.match( + /attach\(trigger, facts, options\) \{[\s\S]*?\n \},/, + ); + assert.ok(attach, "attach() moved or was restructured"); + assert.match( + attach[0], + /"mouseenter",[\s\S]*?setTimeout\(show, OPEN_DELAY_MS\)/, + "the pointer opens the card immediately again - a pointer on its way to " + + "a button crosses four names and would open four cards", + ); + assert.match( + attach[0], + /addEventListener\("focus", show\)/, + "focus waits now too, which makes a keyboard user hold still on a " + + "trigger they already chose deliberately", + ); +}); + +test("a pending open does not survive the card closing", () => { + assert.match( + source, + /function close\(\): void \{[\s\S]*?stopResting\(\)/, + "closing no longer cancels a pending open - a name crossed on the way " + + "past would open a card after the pointer had left it", + ); +}); + +test("the card is announced as describing the trigger, and only while it is open", () => { + assert.match( + source, + /trigger\.setAttribute\("aria-describedby", card\.id\)/, + "the trigger no longer points at the card, which leaves a screen reader " + + "nothing to read when focus opens one", + ); + assert.match( + source, + /function close\(\): void \{[\s\S]*?described\.removeAttribute\("aria-describedby"\)/, + "the link outlives the open card - a screen reader would announce a card " + + "that is no longer on the screen", + ); +}); + +test("a card opened low on the screen still fits on it", () => { + const place = source.match(/function place\([\s\S]*?\n \}/); + assert.ok(place, "place() moved or was renamed"); + assert.match( + place[0], + /window\.innerHeight - height - margin/, + "the card is no longer clamped to the bottom of the viewport - opened " + + "from a name near the foot of a tall roster it runs off the edge", + ); +}); diff --git a/web/src/screens/pilot-hover.ts b/web/src/screens/pilot-hover.ts index 469e2e4..a0e8bdc 100644 --- a/web/src/screens/pilot-hover.ts +++ b/web/src/screens/pilot-hover.ts @@ -36,6 +36,14 @@ * well, closes on blur, and closes on Escape, and the card itself is inert * to the pointer (`pointer-events: none`) so it can never sit between a * cursor and the control underneath it. + * + * ## Pointing at something is not the same as crossing it + * + * A pointer on its way to the Ready button crosses four names, and a card + * that opened on each of them would be four cards flashing at somebody who + * asked for none. So the pointer has to rest on a name before it counts. + * Focus does not wait: arriving at a trigger with a keyboard is already the + * deliberate act the delay is waiting for. */ import { profileFor } from "../avatars"; @@ -57,6 +65,18 @@ export interface PilotFacts { * placeholder tiles: the shape is right and the number is not there. */ const UNRECORDED = "No match result is recorded yet."; +/** + * How long the pointer has to rest on a name before the card opens. + * + * Long enough that crossing a name is not pointing at one, short enough + * that pointing at one does not feel like waiting. + */ +const OPEN_DELAY_MS = 180; + +/** Cards made so far, for the `id` that `aria-describedby` needs. Two + * screens alive at once would otherwise both answer to the same id. */ +let cards = 0; + /** * One camo count per DID for the life of the page. * @@ -122,12 +142,30 @@ export function pilotHover(): PilotHover { stats, ]); card.setAttribute("role", "tooltip"); + card.id = `pilot-hover-${++cards}`; /** Which open the in-flight reads belong to. A pointer moving down a * roster starts one read per name it crosses; only the newest may write * into the card that is now showing somebody else. */ let opening = 0; + /** The trigger the card is currently describing, so its + * `aria-describedby` can be taken off again when the card closes: a + * pointer that has moved on must not leave a screen reader announcing a + * card nobody can see. */ + let described: HTMLElement | null = null; + + /** A rest that has not finished yet. One at a time — a pointer moving + * from one name to the next replaces the wait rather than queueing it. */ + let resting: ReturnType | undefined; + + function stopResting(): void { + if (resting !== undefined) { + clearTimeout(resting); + resting = undefined; + } + } + function open(trigger: HTMLElement, facts: PilotFacts): void { const mine = ++opening; name.textContent = facts.handle ? `@${facts.handle}` : "No handle"; @@ -138,6 +176,13 @@ export function pilotHover(): PilotHover { face.hidden = true; card.hidden = false; place(trigger); + // Sighted or not, the card is about the thing under the pointer. The + // link is made on open and broken on close, because the card is one + // element that every trigger takes a turn at. + if (described && described !== trigger) + described.removeAttribute("aria-describedby"); + trigger.setAttribute("aria-describedby", card.id); + described = trigger; void profileFor(facts.handle ?? facts.did).then((profile) => { if (mine !== opening) return; @@ -170,8 +215,20 @@ export function pilotHover(): PilotHover { // Above the trigger by preference, below it when there is no room — // a name near the top of the viewport is as common as one near the // bottom, and a card that runs off the screen says nothing at all. + // Below, it is clamped to the viewport too: a card opened from a name + // near the bottom of a tall roster used to run off the edge, and the + // figures are the part that went. const above = rect.top - height - margin; - const top = above >= margin ? above : rect.bottom + margin; + const top = + above >= margin + ? above + : Math.max( + margin, + Math.min( + rect.bottom + margin, + window.innerHeight - height - margin, + ), + ); const left = Math.min( Math.max(margin, rect.left), Math.max(margin, window.innerWidth - width - margin), @@ -182,7 +239,12 @@ export function pilotHover(): PilotHover { function close(): void { opening += 1; + stopResting(); card.hidden = true; + if (described) { + described.removeAttribute("aria-describedby"); + described = null; + } document.removeEventListener("keydown", onKey); window.removeEventListener("scroll", close, true); window.removeEventListener("resize", close); @@ -200,6 +262,7 @@ export function pilotHover(): PilotHover { close, attach(trigger, facts, options) { const show = () => { + stopResting(); open(trigger, facts()); document.addEventListener("keydown", onKey); // The card is placed against the viewport once, from where the @@ -211,7 +274,14 @@ export function pilotHover(): PilotHover { window.addEventListener("scroll", close, true); window.addEventListener("resize", close); }; - trigger.addEventListener("mouseenter", show); + // A pointer crossing a name is not pointing at it, so the pointer + // waits and the keyboard does not. Leaving before the rest is up + // cancels it: close() clears the pending open along with everything + // else, so a name crossed on the way past never opens at all. + trigger.addEventListener("mouseenter", () => { + stopResting(); + resting = setTimeout(show, OPEN_DELAY_MS); + }); trigger.addEventListener("mouseleave", close); // Focus rather than click: the card is a thing to read, not a thing to // operate, and a trigger that is already a button (a seat, a name in -- 2.51.2