From 3ae02e1f95937fbdc8dff6238d3ff78e63718963 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Fri, 14 Aug 2026 17:38:02 -0400 Subject: [PATCH] feat(match-lifecycle): say Resume on the bar when a match is still going MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The masthead's filled link says Resume, on a green fill instead of the site's blue, when the signed-in player has a match that is not over or failed. account.ts decides it: one HTML file is behind every address, so the layout writes Play and the flip happens once the session has been read. Signed out nothing is asked, and off the app's own page nothing is asked either — the bar is on the blog too, where every link is a fresh page load. An unreachable API leaves the label alone and warns, the same answer this file already gives for a session it could not read. `isFinished` moves from screens/match-list.ts to api.ts, beside the type it reads: account.ts is mounted by every page including the blog's, and importing the screen would drag the waiting screen and the scenario catalog behind one predicate. The colour is a token pairing, --on-resume on --resume, and contrast.test.mjs covers it beside the accent one at 5.4:1 light and 9.6:1 dark. The label change is announced as well as painted: the link is in the HTML the page arrived with, so a reader has heard "Play" before anything asked the API, and an anchor is not a live region. The masthead ships an empty role="status" for it. Co-Authored-By: Claude Opus 5 (1M context) --- plan/match-lifecycle.md | 13 +++++ plan/site-a11y.md | 14 +++++ web/scripts/contrast.test.mjs | 10 ++++ web/scripts/nav.test.mjs | 98 +++++++++++++++++++++++++++++++++++ web/src/account.ts | 96 +++++++++++++++++++++++++++++++++- web/src/api.ts | 16 ++++++ web/src/destinations.ts | 30 ++++++++++- web/src/layouts/Base.astro | 7 +++ web/src/screens/match-list.ts | 10 ---- web/src/screens/matches.ts | 4 +- web/src/screens/operations.ts | 4 +- web/src/styles.css | 27 ++++++++++ 12 files changed, 312 insertions(+), 17 deletions(-) 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. -- 2.51.2