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;