From ef9a37e0b46911e4d4e5d8346326bc2886bbe167 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 13 Aug 2026 16:17:50 -0400 Subject: [PATCH] feat(web): name Leaderboards and Observe on the bar, under construction Two destinations with nothing behind them yet, in a community group between what a player manages and the site's own pages. A destination may now carry `soon` instead of an href: the masthead renders it as text rather than a link, dimmed and dash-underlined the way the future mode cards on Play are, with "(not ready yet)" in the label for a screen reader. There is no address to type, nothing to focus and nothing to click. Two more labels no longer fit, so the bar wraps at 68rem rather than 44rem and the destinations wrap among themselves below that. Ten of them fit at 320px now, and the bar itself fits at a 200% text size on a 390px phone, which it never has; the front page still scrolls sideways there, but that is the hero insignia and it always was. Co-Authored-By: Claude Opus 5 (1M context) --- plan/appview.md | 5 ++++ plan/leaderboard.md | 5 ++++ plan/site-a11y.md | 37 +++++++++++++++--------- web/scripts/nav.test.mjs | 59 ++++++++++++++++++++++++++++++++++++-- web/src/destinations.ts | 35 ++++++++++++++++++---- web/src/layouts/Base.astro | 48 ++++++++++++++++++++----------- web/src/styles.css | 52 +++++++++++++++++++++++++++------ 7 files changed, 195 insertions(+), 46 deletions(-) diff --git a/plan/appview.md b/plan/appview.md index 110a936..7284c1b 100644 --- a/plan/appview.md +++ b/plan/appview.md @@ -14,6 +14,11 @@ lexicons' stage 6. Everything up to here reads from our own database; this reads from the network. It is the first thing in the plan that is a new service rather than a schema, and the first with an operational cost of its own. +Observe is the masthead label for what a reader does with it — matches other +people played, ours or not. It is on the bar already, in the community group +beside [Leaderboards](leaderboard.md), in the under-construction state: named, +dimmed, not a link, and with no address behind it. + ## Ingest - [ ] **Filter a stream to our collections.** Jetstream takes a diff --git a/plan/leaderboard.md b/plan/leaderboard.md index dce8004..5a7afc0 100644 --- a/plan/leaderboard.md +++ b/plan/leaderboard.md @@ -15,6 +15,11 @@ lexicons' stage 7. Not the same thing as [daily-challenge](daily-challenge.md)'s board, which ranks one day's results out of our own read model and should not wait for any of this. +Leaderboards is on the masthead already, in the community group beside +[Observe](appview.md), drawn in the under-construction state: named, dimmed, +not a link, and with no address behind it. Whatever this epic builds is what +that label stops being a promise about. + ## Pick an order-independent algorithm - [ ] **Rule out anything sequential.** Elo, TrueSkill and Glicko across rating diff --git a/plan/site-a11y.md b/plan/site-a11y.md index 428c1dd..1ba520b 100644 --- a/plan/site-a11y.md +++ b/plan/site-a11y.md @@ -16,19 +16,15 @@ it ran. ## Open -- [ ] **The masthead does not collapse far enough.** The bar wraps onto two - lines below 44rem now — the mark and the account control on the first, - the destinations on the second — which is what keeps eight labels on a - 360px phone at an ordinary text size, where six used to run off the - right-hand edge with no scrollbar and no menu to say they had. At a 200% - browser text size on a 390px phone it still scrolls sideways: the nav - line alone needs 714px, down from 848px before the wrap. Not caused by - anything recent: the nav has always been sized in rem, so it has always - grown with the reader's setting while the bar had nowhere to put it. - What is left is a decision about what the bar drops first, which is why - this is written down rather than guessed at. - - A docs destination in the menu makes this worse, not better. +- [ ] **The hero insignia is what scrolls the front page sideways now.** At a + 200% text size on a 390px phone the page is 419px wide and the masthead + is not the reason: `.hero-mark`'s SVG reaches 417px. It is positioned to + be cropped by the hero's own right edge, and at that text size the hero + is wider than the screen. Pre-existing, and unrelated to the bar — it + measured the same 417px before the masthead was fixed. + + A docs destination in the menu is cheap now, where it used to make the + bar worse. - [ ] **Lighthouse scores a page 100 with an unlabelled field.** A placeholder counts as an accessible name, so `select-name` and `label` both pass on a field whose name vanishes as soon as anyone types. The sign-in handle @@ -60,3 +56,18 @@ the specific mistake the second item above records. `contrast.test.mjs` covers the pairing now, so neither half can move on its own. +- [x] **The masthead ran off the right-hand edge, silently.** Six labels and + the account control were wider than a phone, and the ones that did not + fit were simply gone — no scrollbar, no menu, nothing to say a + destination was missing. + + The bar wraps now: onto two lines below 68rem, the mark and the account + control on the first and the destinations on the second, and the + destinations wrap again among themselves when one line of them is too + many. Ten labels fit at 320px at an ordinary text size, and the bar fits + at a 200% text size on a 390px phone — 390px, against 449px for six + labels before. + + A destination can be added without costing a phone a label. It is a wrap + and not a menu, so a signed-in player on a phone at 200% text gets a + four-line bar. diff --git a/web/scripts/nav.test.mjs b/web/scripts/nav.test.mjs index cd8166a..1ad911d 100644 --- a/web/scripts/nav.test.mjs +++ b/web/scripts/nav.test.mjs @@ -26,7 +26,11 @@ import { const src = fileURLToPath(new URL("../src/", import.meta.url)); test("every destination is a hash on the app page or a page of its own", () => { - for (const { id, href } of DESTINATIONS) { + for (const { id, href, soon } of DESTINATIONS) { + // A destination under construction has no address, which is the point of + // it. The pair of them is checked below. + if (soon) continue; + assert.ok( href.startsWith("/"), `${id}: href must be absolute, got ${href}`, @@ -68,6 +72,57 @@ test("the bar is groups, and every destination is in exactly one", () => { } }); +// --- destinations under construction -------------------------------------- +// +// A named section with nothing behind it is a promise on the bar, and the ways +// it can go wrong are all silent: an href that 404s through the not-found +// screen, a link a keyboard can still reach, or a label that reads as an +// ordinary destination to a screen reader because the only thing marking it is +// how dim it looks. + +test("a destination under construction has no address, and one with an address is not", () => { + const soon = DESTINATIONS.filter((d) => d.soon); + assert.ok(soon.length, "nothing on the bar is under construction any more"); + + for (const d of soon) { + assert.equal(d.href, undefined, `${d.id} is under construction and links`); + assert.equal(routeForHash(`#${d.id}`), null, `${d.id} answers as a route`); + assert.equal(isKnownAddress("/", `#${d.id}`), false, `#${d.id} is live`); + // Nothing to guard, so nothing may claim to be guarding it. + assert.ok(!d.signedIn, `${d.id} is closed to a stranger and has no screen`); + } + + for (const d of DESTINATIONS.filter((d) => !d.soon)) { + assert.ok( + d.href, + `${d.id} has no href and is not marked under construction`, + ); + } +}); + +test("the masthead renders an unbuilt destination as text, and says so", async () => { + const layout = await readFile(`${src}layouts/Base.astro`, "utf8"); + const styles = await readFile(`${src}styles.css`, "utf8"); + + assert.match( + layout, + /d\.soon \?/, + "Base.astro renders every destination as a link, so the ones with nothing " + + "behind them are clickable", + ); + assert.match( + layout, + /not ready yet/, + "the unbuilt destinations are marked by styling alone, which is nothing " + + "to a screen reader", + ); + assert.match( + styles, + /\.nav \.soon \{/, + "styles.css draws an unbuilt destination exactly like a link", + ); +}); + // --- the signed-in half of the nav ---------------------------------------- // // A destination behind sign-in is four files agreeing, and every way they can @@ -121,7 +176,7 @@ test("a link is hidden only where showing it would say nothing", () => { ); }); -const hashDestinations = DESTINATIONS.filter((d) => d.href.startsWith("/#")); +const hashDestinations = DESTINATIONS.filter((d) => d.href?.startsWith("/#")); test("every page under About is an address of its own, under About", () => { for (const { id } of ABOUT_PAGES) { diff --git a/web/src/destinations.ts b/web/src/destinations.ts index 92d3dbd..381391e 100644 --- a/web/src/destinations.ts +++ b/web/src/destinations.ts @@ -16,8 +16,24 @@ export type Destination = { /** Names the link for markCurrent, and is the app's screen where it has one. */ readonly id: string; - readonly href: string; + /** + * Where the link goes. Absent exactly when `soon` is set: a destination + * nothing has been built behind has nowhere honest to point, and an href to + * an address that answers with the not-found screen is worse than no link at + * all. + */ + readonly href?: string; readonly label: string; + /** + * On the bar, and not a link: under construction. + * + * The bar is where a player finds out what this is going to be, so a section + * that is coming is named rather than hidden until the day it works. It is + * rendered as text, not as a disabled anchor — there is no address behind it + * to disable, nothing to focus and nothing to click — and it says so to a + * screen reader as well as to the eye. + */ + readonly soon?: boolean; /** * A destination the router will not put up without a session. * @@ -50,9 +66,10 @@ export type Destination = { * A run of destinations that belong together, and how it is set off from the * run before it. * - * The bar reads as three things rather than a row of equal links: the way into - * a game, the things a player manages between games, and the site's own pages. - * `space` is a plain gap; `rule` is the thin vertical line. + * The bar reads as four things rather than a row of equal links: the way into + * a game, the things a player manages between games, what everybody else is + * doing, and the site's own pages. `space` is a plain gap; `rule` is the thin + * vertical line. */ export type NavGroup = { readonly id: string; @@ -94,6 +111,14 @@ export const NAV_GROUPS: readonly NavGroup[] = [ }, ], }, + { + id: "community", + separator: "space", + destinations: [ + { id: "leaderboards", label: "Leaderboards", soon: true }, + { id: "observe", label: "Observe", soon: true }, + ], + }, { id: "site", separator: "rule", @@ -174,7 +199,7 @@ export function isAllowed(route: string, signedIn: boolean): boolean { * never runs on them. */ const HASH_ROUTES = new Set([ - ...DESTINATIONS.filter((d) => d.href.startsWith("/#")).map((d) => d.id), + ...DESTINATIONS.filter((d) => d.href?.startsWith("/#")).map((d) => d.id), ...ABOUT_ROUTES, ]); diff --git a/web/src/layouts/Base.astro b/web/src/layouts/Base.astro index 3cdcfe7..c0b0263 100644 --- a/web/src/layouts/Base.astro +++ b/web/src/layouts/Base.astro @@ -152,31 +152,45 @@ const cardSize = image === DEFAULT_CARD ? { width: 1200, height: 630 } : null;