From f5cce8cf5edd63ae3c7fdd64dd0fa624b82ec356 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Tue, 18 Aug 2026 17:59:57 -0400 Subject: [PATCH] feat(lobby): point at a player to see their card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `screens/pilot-hover.ts`: picture and display name from the appview, handle and DID, what they are to this lobby, and their camo count from their own repository. Wired to chat speakers, roster chips and claimed seats. Only what is readable about somebody who is not the person looking — home's pilot card counts the caller's own matches, and that figure under another player's name would be plausible, specific and false. `avatars.ts` gained `profileFor` so one getProfile answers both the picture and the name. Co-Authored-By: Claude Opus 5 (1M context) Change-Id: I4506355af8764f6512c2ea424d55ef5ac58734ba --- plan/lobby.md | 25 ++++ web/scripts/pilot-hover.test.mjs | 133 +++++++++++++++++ web/src/avatars.ts | 50 +++++-- web/src/screens/lobby.ts | 57 +++++++- web/src/screens/pilot-hover.ts | 244 +++++++++++++++++++++++++++++++ web/src/styles.css | 92 ++++++++++++ 6 files changed, 584 insertions(+), 17 deletions(-) create mode 100644 web/scripts/pilot-hover.test.mjs create mode 100644 web/src/screens/pilot-hover.ts 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. */ -- 2.51.2