diff --git a/plan/match-lifecycle.md b/plan/match-lifecycle.md index 6ca806b..30ac9e1 100644 --- a/plan/match-lifecycle.md +++ b/plan/match-lifecycle.md @@ -74,6 +74,19 @@ outside those two. The in-container watcher and the entrypoint's SIGTERM handling are both written and neither has been observed doing its job on a real task. +## A human's view of a live match + +The bar says when there is one: `account.ts` asks for the matches after it has +read the session, and the filled link says Resume instead of Play when any of +them is not `over` or `failed`. It is the same address either way — Play is the +screen that lists the live ones — so this is a signpost, not a route. Signed +out, and on the blog's pages, nothing is asked and nothing changes; an +unreachable API leaves the label alone. + +That makes the list's own idea of "still going" visible on every page of the +site, which raises the cost of the stale rows above: a match the registry +thinks is `ready` because nobody loaded it puts Resume on the bar for good. + ## A human's view of an ended match - [ ] **Only an operator script ends a match.** `infra/scripts/matches/killall.sh` diff --git a/plan/site-a11y.md b/plan/site-a11y.md index 6a364e4..b12e5f0 100644 --- a/plan/site-a11y.md +++ b/plan/site-a11y.md @@ -41,6 +41,20 @@ the specific mistake the second item above records. ## Done +- [x] **The bar's second filled colour is pinned like the first.** The filled + link now says Resume on a green fill when the player has a match still + going, which is a second ink-and-fill pairing — `--on-resume` on + `--resume`, 5.4:1 in light and 9.6:1 in dark. `contrast.test.mjs` covers + it as a case beside the accent one, so a colour nothing else on the page + uses cannot go under 4.5:1 unnoticed. + + The label change is announced as well as painted. The link is in the HTML + the page was served with, so by the time `account.ts` has read the + session and asked for the matches a screen reader has been past it and + heard "Play"; an anchor is not a live region and nothing would say the + name had changed. The masthead ships an empty `role="status"` beside the + nav for it, written once when the label flips and emptied silently when + it flips back. - [x] **White on `--accent` was 3.2:1 in dark mode.** Every filled button on the site — Save, Randomize, Sign in — was `#fff` on `#4c8dff`, under AA's 4.5:1. Light mode was already fine at 5.8:1, because `--accent` is a diff --git a/web/scripts/contrast.test.mjs b/web/scripts/contrast.test.mjs index c265729..c952647 100644 --- a/web/scripts/contrast.test.mjs +++ b/web/scripts/contrast.test.mjs @@ -110,6 +110,16 @@ const CASES = [ color: "--on-accent", background: "--accent", }, + // The bar's filled link when there is a match to go back into. A second + // filled pairing is a second way the ink and the fill can be moved apart, + // and it is a colour nothing else on the page uses — so nothing else would + // notice if it went under. + { + what: "the label on the bar's Resume link", + selector: ".nav a.prominent.resume", + color: "--on-resume", + background: "--resume", + }, ]; for (const { what, selector, color, background } of CASES) { diff --git a/web/scripts/nav.test.mjs b/web/scripts/nav.test.mjs index 1ad911d..e0692d7 100644 --- a/web/scripts/nav.test.mjs +++ b/web/scripts/nav.test.mjs @@ -15,13 +15,17 @@ import { ABOUT_PAGES, DESTINATIONS, NAV_GROUPS, + PLAY_LABEL, + RESUME_LABEL, aboutHref, isAllowed, + isAppPage, isKnownAddress, lobbyIdFromHash, movedRouteForHash, routeForHash, } from "../src/destinations.ts"; +import { isFinished } from "../src/api.ts"; const src = fileURLToPath(new URL("../src/", import.meta.url)); @@ -361,3 +365,97 @@ test("the masthead ships a signed-in link hidden, and something reveals it", asy "styles.css lets .nav a's own display beat [hidden]", ); }); + +// --- the way back into a match that is still going ------------------------- +// +// The filled link says Resume instead of Play when the player has a match +// still going, and the ways that goes wrong are the ways the hidden links can +// go wrong plus two of its own: a request nobody asked for, on every page of a +// blog and for readers who are not signed in at all, and a change of label +// that only a sighted reader is told about. + +test("a match is finished only at the two statuses that are an end", () => { + for (const status of ["over", "failed"]) { + assert.equal(isFinished({ status }), true, `${status} is still going`); + } + // Everything else is in progress, an unknown status included: a status this + // side has never heard of keeps its way back in rather than being filed away. + for (const status of ["ready", "starting", "running", "something-new", ""]) { + assert.equal(isFinished({ status }), false, `${status} counts as over`); + } +}); + +test("only the app's own addresses are the app's page", () => { + assert.equal(isAppPage("/"), true); + assert.equal(isAppPage("/index.html"), true); + // Pages the build wrote. The masthead is on them too, and this is what keeps + // it from spending a request there. + assert.equal(isAppPage("/blog"), false); + assert.equal(isAppPage("/blog/some-post/"), false); +}); + +test("the filled link ships as Play and has one other label", () => { + const play = DESTINATIONS.find((d) => d.prominent); + assert.equal( + play.label, + PLAY_LABEL, + "the layout writes a label account.ts cannot put back", + ); + assert.notEqual(RESUME_LABEL, PLAY_LABEL, "the flip changes nothing"); +}); + +test("the flip costs a stranger and the blog nothing", async () => { + const account = await readFile(`${src}account.ts`, "utf8"); + + const guard = account.indexOf("if (!session || !isAppPage("); + assert.notEqual( + guard, + -1, + "account.ts asks the API for the matches without checking who is here " + + "and what page this is, so a signed-out reader and every blog page pay " + + "for a masthead label", + ); + assert.ok( + guard < account.indexOf("await listMatches()"), + "the matches are read before that check, which is the same request", + ); + assert.match( + account, + /console\.warn\("account: the matches could not be read"/, + "an unreachable API is not a warning here, so the bar breaks with it", + ); +}); + +test("the label change is said out loud, not only painted", async () => { + const layout = await readFile(`${src}layouts/Base.astro`, "utf8"); + const account = await readFile(`${src}account.ts`, "utf8"); + const styles = await readFile(`${src}styles.css`, "utf8"); + + // The region has to be in the HTML: one created and written in the same + // breath announces nothing, and the link is the thing that changed. + assert.match( + layout, + /role="status" id="nav-status"/, + "the layout ships no live region, so the label changes silently under a " + + "screen reader that has already read Play", + ); + assert.match( + account, + /querySelector\(NAV_STATUS\)/, + "nothing writes to the live region", + ); + // The word carries the state for a reader; the colour carries it for the + // eye. Neither may be the only one. + assert.match(account, /link\.textContent = resumable \? RESUME_LABEL/); + + const rule = styles.slice(styles.indexOf("\n.nav a.prominent.resume {")); + assert.ok(rule.length, "styles.css draws the Resume link like the Play one"); + const block = rule.slice(0, rule.indexOf("\n}")); + assert.match(block, /var\(--resume\)/); + assert.match(block, /var\(--on-resume\)/); + assert.doesNotMatch( + block, + /#[0-9a-f]{3,8}/i, + "the Resume colour is an inline hex, which contrast.test.mjs cannot read", + ); +}); diff --git a/web/src/account.ts b/web/src/account.ts index cbd3e96..4090381 100644 --- a/web/src/account.ts +++ b/web/src/account.ts @@ -11,12 +11,24 @@ * sign-in dialog and the typeahead inside it are loaded on the click that * needs them. * + * It is also the only module that reads the session on every page, so the + * parts of the masthead that depend on who is here are settled from it: the + * links a stranger is not shown, and whether the filled link says Play or + * Resume. + * * All account text goes in through textContent. A handle is a domain name * someone else chose. */ import { noteSession } from "./analytics"; -import { currentSession, logout, type Session } from "./api"; +import { + currentSession, + isFinished, + listMatches, + logout, + type Session, +} from "./api"; +import { PLAY_LABEL, RESUME_LABEL, isAppPage } from "./destinations"; import { el } from "./dom"; import { attachMenu } from "./menu"; import { nav } from "./nav"; @@ -28,6 +40,15 @@ const SLOT = "#account"; /** The masthead links the layout shipped hidden. See destinations.ts. */ const SIGNED_IN_LINKS = "#masthead a[data-signed-in]"; +/** The one filled link on the bar, the way into a game. See destinations.ts. */ +const PLAY_LINK = "#masthead a.prominent"; + +/** Where a change to that link is said out loud. Rendered by the layout. */ +const NAV_STATUS = "#nav-status"; + +/** What the flip is called in the markup and in the stylesheet. */ +const RESUME_CLASS = "resume"; + /** Called by the layout's script, on every page. */ export function mountAccount(): void { void refreshAccount(); @@ -54,6 +75,7 @@ export async function refreshAccount(): Promise { } slot.replaceChildren(accountControl(session)); revealSignedInLinks(session !== null); + void markResumable(session); // The masthead is on every page and reads the session once, so this is the // one place that learns who is here without asking a second time. @@ -72,6 +94,78 @@ function revealSignedInLinks(signedIn: boolean): void { for (const link of links) link.hidden = !signedIn; } +/** + * Say Resume when there is a match to go back into. + * + * The one question on the bar nobody can answer at build time and nobody can + * answer without asking the API, so it is asked here, where the session has + * just been read and where the answer costs one request at most: + * + * - Signed out, nothing is asked. A reader who never signs in pays nothing for + * this, which is the whole reason it is not in chrome.ts. + * - Off the app's own page, nothing is asked either. The masthead is on the + * blog too, where every link is a fresh page load, and the blog is not where + * a player goes back into a match. The bar there stays Play, which is the + * label it has always had. + * - When the API cannot be reached, the bar keeps the label it was built with + * and the failure is a line in the console, the same answer this file gives + * for a session it could not read. The screen that is actually about matches + * is the one that reports an outage. + */ +async function markResumable(session: Session | null): Promise { + const link = document.querySelector(PLAY_LINK); + if (!link) return; + + if (!session || !isAppPage(window.location.pathname)) { + // Not a no-op: this is also the sign-out, on a page already saying Resume. + setResumable(link, false); + return; + } + + try { + const matches = await listMatches(); + setResumable( + link, + matches.some((match) => !isFinished(match)), + ); + } catch (err) { + console.warn("account: the matches could not be read", err); + } +} + +/** + * Flip the label, the colour and the announcement together. + * + * Nothing happens when the state has not changed, which is what keeps the + * announcement below to the one time it is news. + */ +function setResumable(link: HTMLAnchorElement, resumable: boolean): void { + if (link.classList.contains(RESUME_CLASS) === resumable) return; + link.classList.toggle(RESUME_CLASS, resumable); + link.textContent = resumable ? RESUME_LABEL : PLAY_LABEL; + announce(resumable); +} + +/** + * Tell a screen reader the link changed. + * + * The colour is half the message and the word is the other half, but both + * arrive late: the link is in the HTML the page was served with, so by the time + * this file has read the session and asked for the matches the reader has been + * past it and heard "Play". A name that changes under a reader is announced by + * nothing — an anchor is not a live region, and marking one would make every + * ordinary re-render of the bar speak. + * + * So the change is said once, out of a region the layout ships empty. Emptying + * it says nothing, which is right: going back to Play is not news. + */ +function announce(resumable: boolean): void { + const status = document.querySelector(NAV_STATUS); + if (status) { + status.textContent = resumable ? "You have a match in progress." : ""; + } +} + /** The control itself. Exported so it can be built without a live session. */ export function accountControl(session: Session | null): HTMLElement { return session ? signedIn(session) : signedOut(); diff --git a/web/src/api.ts b/web/src/api.ts index ba8121d..6911c1a 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -539,6 +539,22 @@ export interface MatchSummary { players?: MatchPlayer[]; } +/** + * A match nobody is going back into. Named by the two statuses that are an end + * rather than by the ones that are not: a status this file has never heard of + * is something in progress until it says otherwise, which keeps an unknown one + * on the screen with a way in rather than filed away as history. + * + * Here rather than in screens/match-list.ts, where it was written: the + * masthead needs the same rule now, and account.ts is mounted by every page of + * the site including the blog's. Importing the screen from there would drag + * the waiting screen, the scenario catalog and the DOM helpers onto every blog + * page behind one predicate. This module is one both already import. + */ +export function isFinished(match: MatchSummary): boolean { + return match.status === "over" || match.status === "failed"; +} + /** The signed-in player's matches, newest first. */ export async function listMatches(): Promise { const response = await call("/api/matches"); diff --git a/web/src/destinations.ts b/web/src/destinations.ts index c370913..e870653 100644 --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -77,6 +77,20 @@ export type NavGroup = { readonly destinations: readonly Destination[]; }; +/** + * The two labels the prominent link can carry. + * + * "Play" is what the layout writes into the HTML, because that is the only + * one a build can know: one file is behind every address, and whether the + * reader has a match still going is a question about the reader. account.ts + * swaps in "Resume" once it has read the session and asked. + * + * Both spellings live here rather than in whichever file wrote one first, so + * the label the layout renders and the label the flip restores cannot drift. + */ +export const PLAY_LABEL = "Play"; +export const RESUME_LABEL = "Resume"; + export const NAV_GROUPS: readonly NavGroup[] = [ { id: "play", @@ -84,7 +98,7 @@ export const NAV_GROUPS: readonly NavGroup[] = [ { id: "matches", href: "/#matches", - label: "Play", + label: PLAY_LABEL, signedIn: true, prominent: true, }, @@ -212,6 +226,18 @@ const HASH_ROUTES = new Set([ /** The addresses the app itself is served at. Everything else is a page. */ const APP_PATHS = ["/", "/index.html"]; +/** + * Whether this address is the app's own page rather than one the build wrote. + * + * account.ts asks before it spends a request on anything: it mounts on every + * page of the site, the blog's included, and there every link is a fresh page + * load. The blog is not where a player goes back into a match, so it does not + * pay for the masthead to know. + */ +export function isAppPage(pathname: string): boolean { + return APP_PATHS.includes(pathname); +} + /** A leading slash is tolerated, so "#/camo" reads the same as "#camo". */ function hashId(hash: string): string { return hash.replace(/^#\/?/, ""); @@ -277,7 +303,7 @@ export function lobbyIdFromHash(hash: string): string | null { * player their link is dead. */ export function isKnownAddress(pathname: string, hash: string): boolean { - if (!APP_PATHS.includes(pathname)) return false; + if (!isAppPage(pathname)) return false; if (hash === "" || hash === "#") return true; if (movedRouteForHash(hash)) return true; return routeForHash(hash) !== null || lobbyIdFromHash(hash) !== null; diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 838e2ac..c2a49cb 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -207,6 +207,13 @@ const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null; )) } + { + /* Where account.ts says that the filled link changed from Play to + Resume. Shipped empty and on every page, because a live region + has to be on the page before the text it is to announce arrives — + one added and written in the same breath announces nothing. */ + } + { /* Filled in by theme.ts. Nothing here waits on the session: the colour scheme is a setting about this browser, and a reader who diff --git a/web/src/screens/match-list.ts b/web/src/screens/match-list.ts index f436b28..fe633a2 100644 --- a/web/src/screens/match-list.ts +++ b/web/src/screens/match-list.ts @@ -29,16 +29,6 @@ const LABEL: Record = { failed: "failed", }; -/** - * A match nobody is going back into. Named by the two statuses that are an - * end rather than by the ones that are not: a status this file has never heard - * of is something in progress until it says otherwise, which keeps an unknown - * one on the screen with a way in rather than filed away as history. - */ -export function isFinished(match: MatchSummary): boolean { - return match.status === "over" || match.status === "failed"; -} - /** * Who a match was against, from its stored seats: everyone but the viewer, * named by the handle they fought under. Empty for matches that predate diff --git a/web/src/screens/matches.ts b/web/src/screens/matches.ts index 59b290d..d1b0562 100644 --- a/web/src/screens/matches.ts +++ b/web/src/screens/matches.ts @@ -11,12 +11,12 @@ * ledger of every match they have ever played underneath it. */ -import { type Session } from "../api"; +import { isFinished, type Session } from "../api"; import { footer, pageHead } from "../chrome"; import { el, render } from "../dom"; import { nav } from "../nav"; import { lobbyScreen, type ChallengeMode } from "./lobby"; -import { isFinished, matchList } from "./match-list"; +import { matchList } from "./match-list"; /** One clickable entry into the challenge flow. */ function modeCard( diff --git a/web/src/screens/operations.ts b/web/src/screens/operations.ts index ab1e48c..495f5ee 100644 --- a/web/src/screens/operations.ts +++ b/web/src/screens/operations.ts @@ -6,10 +6,10 @@ * the router will not put it up without a session. */ -import { type Session } from "../api"; +import { isFinished, type Session } from "../api"; import { footer, pageHead } from "../chrome"; import { el } from "../dom"; -import { isFinished, matchList } from "./match-list"; +import { matchList } from "./match-list"; export function operationsScreen(session: Session): Node[] { return [ diff --git a/web/src/styles.css b/web/src/styles.css index 3ef3cf9..6886749 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -31,6 +31,20 @@ unchanged, and so is the blue itself. */ --on-accent: light-dark(#ffffff, #0b1017); + /* The way back into a match that is still going: the same filled link on the + bar, in the one colour on the page that is not the site's blue. Green + rather than a second blue, because the whole point of it is that a player + can tell at a glance that the button is not the one they left. --danger's + red is what a failed sign-in and a lost match are written in, and + --stamp's amber belongs to the alpha stamp. + + --on-resume is --on-accent's trick for --on-accent's reason: white reads on + the dark green of light mode and not on the bright one of dark mode, where + the page's own background does. contrast.test.mjs pins both pairings, so + neither half of either can move on its own. */ + --resume: light-dark(#0f7a4a, #35d08a); + --on-resume: light-dark(#ffffff, #0b1017); + /* The insignia's teal. Reserved for the masthead, so the brand mark and the site chrome are visibly the same object. */ --brand: light-dark(#0e7a8c, #4fc1d6); @@ -372,6 +386,19 @@ body:has(.hero) { outline-color: var(--on-accent); } +/* The same link when the player has a match still going. account.ts adds the + class and swaps the label with it, so the colour and the word change + together: the fill is what says at a glance that the bar is not what it was, + and "Resume" is what says it to a screen reader. */ +.nav a.prominent.resume { + color: var(--on-resume); + background: var(--resume); +} + +.nav a.prominent.resume:focus-visible { + outline-color: var(--on-resume); +} + /* Narrow: everything shrinks a step, but every label stays written out. Seven destinations — eight once Operations appears for a signed-in player — still do not need a menu behind a button.