diff --git a/plan/lobby.md b/plan/lobby.md index 0b9bd55..d0f2083 100644 --- a/plan/lobby.md +++ b/plan/lobby.md @@ -31,6 +31,10 @@ mechanism for getting N humans seated and launched. Asking them first is reason. - [ ] **The in-memory lobby channel map is never cleaned up.** Accepted for now. +- [ ] **The card's placement can cover the panel it was opened from.** It + prefers to sit above its trigger, and a chat line near the bottom of + the screen puts it over the scrollback. Correct for a tooltip and + still worth a better rule than "above, then below". - [ ] **The roster says who is here, not what they are.** A spectator and a seated player read the same in it; nothing marks who holds a force, who owns the lobby, or who is ready. The Force cards carry the first @@ -371,3 +375,24 @@ indefinitely because a lobby is a small room the people in it opened. late joiner is replayed. The first snapshot announces nobody, since arriving to a room of four and being told four people just joined is a lie about when they got here. +- [x] **A name opens into a card.** Pointing at anybody — a chat line's + speaker, a roster chip, whoever holds a force — opens + `screens/pilot-hover.ts`: their picture and display name from the + appview, the handle and DID under it, what they are to this lobby + ("Holds TraineeB · ready", or watching), and their camo count read + from their own repository. + + The card only ever says what is readable about somebody who is *not* + the person looking, which is what keeps it honest: home's own pilot + card counts the caller's matches through a session-scoped route, and + showing that figure under another player's name would be a lie of the + worst kind — plausible, specific, and about somebody else. Wins and + Kills are em dashes with the same reason `pilot.ts` gives, because + nothing records a match result yet ([match-records](match-records.md)). + + One card per screen, refilled on each hover, rather than one per name: + a lobby has a hundred places a name appears. Opens on focus as well as + hover, closes on Escape, and is `pointer-events: none` so it can never + 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. diff --git a/web/scripts/pilot-hover.test.mjs b/web/scripts/pilot-hover.test.mjs new file mode 100644 index 0000000..553f305 --- /dev/null +++ b/web/scripts/pilot-hover.test.mjs @@ -0,0 +1,133 @@ +/** + * The card that opens when you point at another player. + * + * Structural, like the lobby screen's own checks: there is no DOM under + * `npm test`, and none of what these assert fails at runtime. What they hold + * in place is the part that is only wrong in ways a person would have to + * notice — a card that reports the caller's own numbers under somebody + * else's name, a listener that outlives the screen that added it, and a + * slow read landing in a card that has moved on to another player. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const web = fileURLToPath(new URL("../", import.meta.url)); +const source = await readFile(join(web, "src/screens/pilot-hover.ts"), "utf8"); +const lobby = await readFile(join(web, "src/screens/lobby.ts"), "utf8"); + +test("the card reads nothing that is scoped to the caller's own session", () => { + // pilot.ts counts the caller's matches through a session-scoped route. + // Showing that figure under another player's name would be a lie of the + // worst kind here: plausible, specific, and about somebody else. + assert.doesNotMatch( + source, + /listMatches|from "\.\.\/api"/, + "the hover card imports the session-scoped API - every figure on it " + + "must be readable about a player who is not the one looking", + ); + assert.match( + source, + /camo\/repo/, + "the camo count is gone - it was the one real figure the card had, and " + + "it is real because a player's camo lives in their own repository", + ); +}); + +test("a figure nothing records yet is an em dash that says why", () => { + assert.match( + source, + /const UNRECORDED = "No match result is recorded yet\.";/, + "the placeholder reason moved or was renamed - it must match the one " + + "pilot.ts gives for the same two figures", + ); + const placeholder = source.match(/function dash\(\)[\s\S]*?\n\}/); + assert.ok(placeholder, "the placeholder figure moved or was renamed"); + assert.match(placeholder[0], /—/, "the placeholder is no longer an em dash"); + assert.match( + placeholder[0], + /title: UNRECORDED/, + "the placeholder no longer says why it is a dash", + ); +}); + +test("a read that lands late may not write into a card about somebody else", () => { + const open = source.match(/function open\([\s\S]*?\n \}/); + assert.ok(open, "open() moved or was renamed"); + assert.match( + open[0], + /const mine = \+\+opening;/, + "the open counter is gone - a pointer moving down a roster starts one " + + "read per name it crosses", + ); + const guards = [...open[0].matchAll(/if \(mine !== opening\) return;/g)]; + assert.equal( + guards.length, + 2, + "every asynchronous write into the card needs the guard: expected one " + + "for the profile read and one for the camo count", + ); +}); + +test("Escape closes it, and the listener does not outlive the card being open", () => { + assert.match( + source, + /document\.addEventListener\("keydown", onKey\)/, + "the Escape listener moved or was renamed", + ); + assert.match( + source, + /function close\(\): void \{[\s\S]*?document\.removeEventListener\("keydown", onKey\)/, + "closing no longer removes the Escape listener - a screen visited five " + + "times would leave five listeners on the document", + ); +}); + +test("the lobby closes the card when the screen goes", () => { + const teardown = lobby.match(/onTeardown\(\(\) => \{[\s\S]*?\n \}\);/); + assert.ok(teardown, "the lobby screen's teardown moved or was restructured"); + assert.match( + teardown[0], + /hover\.close\(\)/, + "a card left open would be a card about somebody from a screen that is " + + "no longer on the page", + ); +}); + +test("a name that repeats is not a tab stop; a name that does not is", () => { + assert.match( + lobby, + /hover\.attach\(who, \(\) => pilotFacts\(line\.did, line\.handle\), \{\s*focusable: false,?\s*\}\)/, + "chat speakers are back in the tab order - the same four people are " + + "named on every line, and the roster is one stop each", + ); + assert.match( + lobby, + /hover\.attach\(chip, \(\) => pilotFacts\(watcher\.did, watcher\.handle\)\)/, + "the roster chips are no longer focusable, which leaves a keyboard no " + + "way to open a card at all", + ); +}); + +test("what a player is to this lobby is read when the card opens, not when it is wired", () => { + // A chat line said ten minutes ago still points at where that player + // ended up, because the facts are a callback rather than a snapshot. + const facts = lobby.match( + /function pilotFacts\(did: string, handle: string \| null\): PilotFacts \{[\s\S]*?\n \}/, + ); + assert.ok(facts, "pilotFacts moved or was renamed"); + assert.match( + facts[0], + /seats\.find/, + "the note no longer reads the current seat map", + ); + assert.match( + facts[0], + /readyDids\.includes\(did\)/, + "the note no longer says whether they are ready", + ); +}); diff --git a/web/src/avatars.ts b/web/src/avatars.ts index 6515fbc..241f35c 100644 --- a/web/src/avatars.ts +++ b/web/src/avatars.ts @@ -16,28 +16,60 @@ const APPVIEW = "https://public.api.bsky.app"; +/** + * What the appview knows about an actor that is worth showing next to their + * name. Every field is optional in the answer and null here when it is + * missing, an unindexed account included — a caller shows what there is. + */ +export interface ActorProfile { + avatar: string | null; + displayName: string | null; +} + /** One answer per actor for the life of the page, misses included. */ -const cache = new Map>(); +const cache = new Map>(); -/** The avatar URL for a handle or DID, or null where there is no picture. */ -export function avatarFor(actor: string): Promise { +const NOBODY: ActorProfile = { avatar: null, displayName: null }; + +/** + * The appview's profile for a handle or DID. One lookup per actor per page, + * shared by every caller: the avatar and the display name come from the + * same `getProfile`, and asking twice for two fields of one answer is a + * round trip nobody needs. + */ +export function profileFor(actor: string): Promise { let kept = cache.get(actor); if (!kept) { kept = (async () => { const url = new URL(`${APPVIEW}/xrpc/app.bsky.actor.getProfile`); url.searchParams.set("actor", actor); const response = await fetch(url); - if (!response.ok) return null; - const body = (await response.json()) as { avatar?: unknown }; - return typeof body.avatar === "string" && body.avatar !== "" - ? body.avatar - : null; - })().catch(() => null); + if (!response.ok) return NOBODY; + const body = (await response.json()) as { + avatar?: unknown; + displayName?: unknown; + }; + return { + avatar: text(body.avatar), + displayName: text(body.displayName), + }; + })().catch(() => NOBODY); cache.set(actor, kept); } return kept; } +/** A non-empty string, or null. An absent field and an empty one mean the + * same thing to a caller: there is nothing to show. */ +function text(value: unknown): string | null { + return typeof value === "string" && value !== "" ? value : null; +} + +/** The avatar URL for a handle or DID, or null where there is no picture. */ +export function avatarFor(actor: string): Promise { + return profileFor(actor).then((profile) => profile.avatar); +} + /** * Wire an avatar `` to an actor, hidden until a picture arrives. * diff --git a/web/src/screens/lobby.ts b/web/src/screens/lobby.ts index 8b36aa9..36800c6 100644 --- a/web/src/screens/lobby.ts +++ b/web/src/screens/lobby.ts @@ -22,6 +22,7 @@ import { el, onTeardown, render } from "../dom"; import { withHandleTypeahead } from "../handle-typeahead"; import { nav } from "../nav"; import { camoModal, camoPicker, remoteCamoPixels } from "./camo-picker"; +import { pilotHover, type PilotFacts } from "./pilot-hover"; import { challengeBySlug, dailyScenario, @@ -242,6 +243,10 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // spectators by construction — connecting needs only a session — so this // is not the seat map with a different coat of paint: it is the people // arguing about who takes which force, some of whom hold no seat at all. + // One card for the whole screen, pointed at whichever name is under the + // cursor or the focus ring — see pilot-hover.ts for why it is one and not + // one per name. + const hover = pilotHover(); const rosterList = el("div", { className: "lobby-roster" }); const rosterCount = el("span", { className: "hint lobby-roster-count" }); @@ -657,7 +662,7 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { textContent: "Kick", }); kick.addEventListener("click", () => socket?.kickSeat(seat.slot)); - return el("span", { className: "pilot-tile-face" }, [ + const tile = el("span", { className: "pilot-tile-face" }, [ face, el("span", { className: "pilot-tile-info" }, [ el("span", { @@ -674,6 +679,13 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { // for anyone else to be in, and no socket to send it on. ...(daily ? [] : [kick]), ]); + // A human seat is a person; a bot seat is a setting. Only one of them + // has a card to open. + if (!isBot && seat.did) { + const seatDid = seat.did; + hover.attach(tile, () => pilotFacts(seatDid, seat.handle)); + } + return tile; } /** @@ -1080,12 +1092,18 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { * re-reading the whole history, since the API never sends one back. */ function appendChat(line: ChatLine): void { - chatScroll.append( - el("p", { className: "chat-line" }, [ - el("strong", { textContent: speaker(line) }), - line.text, - ]), - ); + const who = el("strong", { + className: "chat-speaker", + textContent: speaker(line), + }); + // The name a line is attributed to is the most-pointed-at thing in a + // lobby, which is the whole reason the card exists. + // Not focusable: the same four people are named on every line, and the + // roster is one stop each for a keyboard. + hover.attach(who, () => pilotFacts(line.did, line.handle), { + focusable: false, + }); + chatScroll.append(el("p", { className: "chat-line" }, [who, line.text])); chatScroll.scrollTop = chatScroll.scrollHeight; } @@ -1152,6 +1170,21 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { return watcher.handle ? at(watcher.handle) : `${watcher.did.slice(0, 16)}…`; } + /** + * Who somebody is to this lobby, for the hover card's note: the force + * they hold, or that they are only watching. + * + * Read at open time rather than baked into the trigger, so a name in a + * chat line said ten minutes ago still says where that player ended up. + */ + function pilotFacts(did: string, handle: string | null): PilotFacts { + const seat = seats.find((s) => s.did === did); + const note = seat + ? `Holds ${seat.slot}${readyDids.includes(did) ? " · ready" : ""}` + : "Watching"; + return { did, handle, note }; + } + /** * The room, and the arrivals and departures since the last snapshot. * @@ -1189,13 +1222,15 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { ...next.map((watcher) => { const face = el("img", { className: "seat-avatar", alt: "" }); showAvatar(face, watcher.did); - return el("span", { className: "lobby-watcher" }, [ + const chip = el("span", { className: "lobby-watcher" }, [ face, el("span", { className: "lobby-watcher-name", textContent: watcherName(watcher), }), ]); + hover.attach(chip, () => pilotFacts(watcher.did, watcher.handle)); + return chip; }), ); } @@ -1387,6 +1422,9 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { onTeardown(() => { left = true; socket?.close(); + // A card left open would be a card about somebody from a screen that is + // no longer on the page. + hover.close(); }); /** At least one live event has arrived, so onClosed below can tell "never @@ -1631,6 +1669,9 @@ export function lobbyScreen(session: Session, entry: LobbyEntry): Node[] { status, ]), ...(daily ? [] : [chatPanel]), + // Last, and outside every card: it is positioned against the viewport, + // so what it sits next to in the tree decides nothing but its stacking. + hover.card, footer(), ]; } diff --git a/web/src/screens/pilot-hover.ts b/web/src/screens/pilot-hover.ts new file mode 100644 index 0000000..50f728c --- /dev/null +++ b/web/src/screens/pilot-hover.ts @@ -0,0 +1,244 @@ +/** + * The card that appears when you point at another player. + * + * A lobby names people constantly — a chat line, a roster chip, whoever + * holds a force — and every one of those names is a handle and nothing + * else. This is what a handle opens into: the picture, the name they chose, + * the DID underneath it, what they have to show, and what they are doing in + * this room right now. + * + * ## What it may say + * + * Only what is readable about somebody who is not the person looking. That + * rules out most of `screens/pilot.ts`'s figures: its Matches tile counts + * the *caller's* own matches through a session-scoped route, and Wins and + * Kills are placeholders there because nothing records a match result yet + * (see [match-records](../../plan/match-records.md)). Camo survives the + * change of subject, because a player's camo lives in their own repository + * and listing a public collection needs no session. + * + * So the card is honest in the same way that one is: a real figure where + * there is one, an em dash and a reason where there is not. It does not + * infer, and it never shows the caller's own numbers under somebody else's + * name. + * + * ## One card, many triggers + * + * There is exactly one card element per screen, moved and refilled on each + * hover, rather than one per name. A lobby with eight seats, a roster and a + * scrollback has a hundred places a name appears, and a hundred detached + * cards is a hundred subscriptions to keep in sync with a room that changes + * under them. + * + * ## Pointing is not the only way to point + * + * Hover alone would make this mouse-only. Every trigger opens on focus as + * 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. + */ + +import { profileFor } from "../avatars"; +import { el } from "../dom"; + +/** Who the card is about, and what they are doing here. */ +export interface PilotFacts { + did: string; + /** Their verified handle, or null for an account with none to show. */ + handle: string | null; + /** + * What this player is to this screen — "Holds TraineeA", "Spectating". + * The caller's to decide: the card knows about people, not about lobbies. + */ + note?: string; +} + +/** A figure with no source yet, and why. Same posture as `pilot.ts`'s + * placeholder tiles: the shape is right and the number is not there. */ +const UNRECORDED = "No match result is recorded yet."; + +/** + * One camo count per DID for the life of the page. + * + * A hover is a cheap gesture and people repeat it; a listing walks every + * page of a collection in somebody's PDS. Misses are cached too — an + * account whose PDS will not answer is not going to start answering because + * the pointer moved away and came back. + */ +const camoCounts = new Map>(); + +function camoCount(did: string): Promise { + let kept = camoCounts.get(did); + if (!kept) { + kept = (async () => { + const repo = await import("../camo/repo"); + return (await repo.list(did)).length; + })().catch(() => null); + camoCounts.set(did, kept); + } + return kept; +} + +export interface PilotHover { + /** Append this once, anywhere: it positions itself against the viewport. */ + card: HTMLElement; + /** + * Point `trigger` at a player. `facts` is read on each open, so a trigger + * that outlives a change of seat still says the current thing. + * + * `focusable` puts the trigger in the tab order, and defaults to true: a + * card only a mouse can open is a card a keyboard cannot read. Pass false + * for a name that repeats — every line of a scrollback naming the same + * four people would otherwise be forty tab stops to get past, and those + * people are all in the roster, which is one stop each. + */ + attach( + trigger: HTMLElement, + facts: () => PilotFacts, + options?: { focusable?: boolean }, + ): void; + /** Close it now — a screen tearing down, or a list being replaced. */ + close(): void; +} + +export function pilotHover(): PilotHover { + const face = el("img", { className: "pilot-hover-avatar", alt: "" }); + const name = el("p", { className: "pilot-hover-name" }); + const did = el("p", { className: "pilot-hover-did" }); + const note = el("p", { className: "hint pilot-hover-note" }); + const camo = el("span", { className: "pilot-hover-figure" }); + const stats = el("div", { className: "pilot-hover-stats" }, [ + stat("Camo", camo), + stat("Wins", dash()), + stat("Kills", dash()), + ]); + + const card = el("div", { className: "pilot-hover", hidden: true }, [ + el("div", { className: "pilot-hover-identity" }, [ + face, + el("div", {}, [name, did]), + ]), + note, + stats, + ]); + card.setAttribute("role", "tooltip"); + + /** 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; + + function open(trigger: HTMLElement, facts: PilotFacts): void { + const mine = ++opening; + name.textContent = facts.handle ? `@${facts.handle}` : "No handle"; + did.textContent = facts.did; + note.textContent = facts.note ?? ""; + note.hidden = !facts.note; + camo.textContent = "—"; + face.hidden = true; + card.hidden = false; + place(trigger); + + void profileFor(facts.handle ?? facts.did).then((profile) => { + if (mine !== opening) return; + if (profile.displayName) name.textContent = profile.displayName; + if (profile.avatar) { + face.src = profile.avatar; + face.hidden = false; + } + // The handle is the address; a display name is a label somebody chose + // for themselves. When both exist the label goes on top and the + // address moves down beside the DID, so the card never shows a name + // with nothing verifiable under it. + did.textContent = + profile.displayName && facts.handle + ? `@${facts.handle} · ${facts.did}` + : facts.did; + }); + + void camoCount(facts.did).then((count) => { + if (mine !== opening) return; + camo.textContent = count === null ? "—" : String(count); + }); + } + + function place(trigger: HTMLElement): void { + const rect = trigger.getBoundingClientRect(); + const width = card.offsetWidth; + const height = card.offsetHeight; + const margin = 8; + // 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. + const above = rect.top - height - margin; + const top = above >= margin ? above : rect.bottom + margin; + const left = Math.min( + Math.max(margin, rect.left), + Math.max(margin, window.innerWidth - width - margin), + ); + card.style.top = `${Math.round(top)}px`; + card.style.left = `${Math.round(left)}px`; + } + + function close(): void { + opening += 1; + card.hidden = true; + document.removeEventListener("keydown", onKey); + } + + /** Escape closes it, per WAI-ARIA's tooltip pattern. Bound only while the + * card is open and removed when it closes, so a screen visited five times + * leaves no listeners behind — `close()` runs on teardown. */ + function onKey(event: KeyboardEvent): void { + if (event.key === "Escape") close(); + } + + return { + card, + close, + attach(trigger, facts, options) { + const show = () => { + open(trigger, facts()); + document.addEventListener("keydown", onKey); + }; + trigger.addEventListener("mouseenter", show); + 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 + // the roster) keeps whatever its click already did. + trigger.addEventListener("focus", show); + trigger.addEventListener("blur", close); + const focusable = options?.focusable ?? true; + if ( + focusable && + !trigger.hasAttribute("tabindex") && + !isFocusable(trigger) + ) { + trigger.tabIndex = 0; + } + }, + }; +} + +/** Whether the browser already puts this element in the tab order, so + * `attach` does not make a button focusable twice or a span never. */ +function isFocusable(node: HTMLElement): boolean { + return ["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA"].includes(node.tagName); +} + +function stat(label: string, value: HTMLElement): HTMLElement { + return el("div", { className: "pilot-hover-stat" }, [ + value, + el("span", { className: "pilot-hover-label", textContent: label }), + ]); +} + +/** A figure nothing records yet: an em dash that says why it is one. */ +function dash(): HTMLElement { + const value = el("span", { + className: "pilot-hover-figure pilot-hover-soon", + textContent: "—", + title: UNRECORDED, + }); + return value; +} diff --git a/web/src/styles.css b/web/src/styles.css index 5786cf6..1be2eb0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -401,6 +401,7 @@ body { .daily-value:not(.daily-era), .pilot-value, .pilot-did, +.pilot-hover-did, .match-id, .camo-value, .camo-unit-kind, @@ -3387,6 +3388,97 @@ footer .debug { box-shadow: 0 6px 20px light-dark(rgb(15 23 34 / 18%), rgb(0 0 0 / 45%)); } +/* The card that opens when you point at another player. Positioned against + the viewport by pilot-hover.ts, above the chat panel (20) it is usually + opened from, and never under the pointer: it is a thing to read, and a + card that intercepted the cursor would close itself the moment it + appeared. */ +.pilot-hover { + position: fixed; + z-index: 25; + width: 15rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem; + pointer-events: none; + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--radius); + box-shadow: 0 6px 20px light-dark(rgb(15 23 34 / 18%), rgb(0 0 0 / 45%)); +} + +.pilot-hover-identity { + display: flex; + gap: 0.5rem; + align-items: center; + min-width: 0; +} + +.pilot-hover-avatar { + width: 2.4rem; + height: 2.4rem; + border-radius: 50%; + object-fit: cover; +} + +.pilot-hover-name { + margin: 0; + font-weight: 600; + overflow-wrap: anywhere; +} + +/* The DID is the part that is not rented, and it is long. Small, dimmed and + allowed to wrap rather than truncated: a truncated DID identifies nobody. */ +.pilot-hover-did { + margin: 0; + font-size: 0.7rem; + color: var(--muted); + overflow-wrap: anywhere; +} + +.pilot-hover-note { + margin: 0; +} + +.pilot-hover-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.4rem; + text-align: center; +} + +.pilot-hover-stat { + display: flex; + flex-direction: column; +} + +.pilot-hover-figure { + font-size: 1.1rem; + font-weight: 600; +} + +/* A figure nothing records yet. Dimmed so the card reads as three tiles, + one of which is real, rather than as three numbers one of which is odd. */ +.pilot-hover-soon { + color: var(--muted); +} + +.pilot-hover-label { + font-size: 0.7rem; + color: var(--muted); +} + +/* A name you can point at. Underlined on hover rather than always, so the + scrollback does not read as a page of links. */ +.chat-speaker:hover, +.chat-speaker:focus-visible, +.lobby-watcher:hover .lobby-watcher-name, +.lobby-watcher:focus-visible .lobby-watcher-name { + text-decoration: underline dotted; + cursor: help; +} + /* Who is in the room, above what is being said in it. Wraps rather than scrolls: eight seats plus spectators is a few rows, and a row of names that scrolls sideways is a row of names nobody reads. */