diff --git a/web/scripts/phone-gate.fixture.ts b/web/scripts/phone-gate.fixture.ts new file mode 100644 index 0000000..454122a --- /dev/null +++ b/web/scripts/phone-gate.fixture.ts @@ -0,0 +1,9 @@ +/** + * The device question the play path asks. + * + * Bundled by phone-gate.test.mjs, the way outage-panel.fixture.ts is, because + * the module under test is TypeScript with extensionless imports. Only the + * predicate: the screen beside it reaches chrome.ts, which asks the API for + * its version at import time. + */ +export { isPhone } from "../src/phone"; diff --git a/web/scripts/phone-gate.test.mjs b/web/scripts/phone-gate.test.mjs new file mode 100644 index 0000000..e46e09d --- /dev/null +++ b/web/scripts/phone-gate.test.mjs @@ -0,0 +1,107 @@ +/** + * A phone is turned away from a match, and nothing else is. + * + * The gate is a hard stop with no "play anyway" behind it, so the cost of it + * answering yes to a machine that can play is a player who cannot play at + * all. That is what these cases are: the two shapes a phone takes, and the + * three things next to it that have to stay out — a desktop window dragged + * narrow, a tablet in landscape, and a browser with no matchMedia at all. + * + * Run with `npm test`. + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const run = promisify(execFile); +const web = join(new URL(".", import.meta.url).pathname, ".."); + +const dir = await mkdtemp(join(tmpdir(), "phone-gate-")); +const bundle = join(dir, "fixture.mjs"); +await run( + join(web, "node_modules/.bin/rolldown"), + [ + join(web, "scripts/phone-gate.fixture.ts"), + "--format", + "esm", + "--platform", + "node", + "--file", + bundle, + ], + { cwd: web }, +); + +const { isPhone } = await import(bundle); + +const ROOT_PX = 16; + +/** + * A browser of the given size and pointer, answering the three queries the + * predicate asks the way a real one would. + * + * Only the four features it uses are parsed. A query it does not know is a + * mistake in the module rather than something to answer with false, so this + * throws on one. + */ +function browser({ width, height, pointer }) { + globalThis.window = { + matchMedia(query) { + const [, feature, value] = /\((.+):\s*(.+)\)/.exec(query) ?? []; + const px = + Number.parseFloat(value) * (value.endsWith("rem") ? ROOT_PX : 1); + switch (feature) { + case "pointer": + return { matches: pointer === value }; + case "max-width": + return { matches: width <= px }; + case "max-height": + return { matches: height <= px }; + default: + throw new Error(`the gate asked an unhandled query: ${query}`); + } + }, + }; +} + +test("a phone held either way is a phone", () => { + browser({ width: 390, height: 844, pointer: "coarse" }); + assert.equal(isPhone(), true, "a phone upright was let through to a match"); + + browser({ width: 844, height: 390, pointer: "coarse" }); + assert.equal( + isPhone(), + true, + "a phone on its side is wide enough to pass the width check, and the " + + "height check is the only thing that catches it", + ); +}); + +test("a narrow desktop window is not a phone", () => { + browser({ width: 500, height: 900, pointer: "fine" }); + assert.equal( + isPhone(), + false, + "a mouse in front of a small viewport was locked out of a match it can play", + ); +}); + +test("a tablet in landscape is not a phone", () => { + browser({ width: 1024, height: 768, pointer: "coarse" }); + assert.equal(isPhone(), false); +}); + +test("a browser without matchMedia is not a phone", () => { + globalThis.window = {}; + assert.equal( + isPhone(), + false, + "an unanswerable question turned a player away; the gate must fail open", + ); +}); + +test.after(() => rm(dir, { recursive: true, force: true })); diff --git a/web/src/phone.ts b/web/src/phone.ts new file mode 100644 index 0000000..7da43d7 --- /dev/null +++ b/web/src/phone.ts @@ -0,0 +1,41 @@ +/** + * Whether the browser reading this is a phone. + * + * Its own module, importing nothing, for two reasons. The screens that ask + * are the router and the front page's daily card, and neither should have to + * pull a screen in to ask a question about the device. And it is the half of the gate worth a test: screens/phone-gate.ts + * reaches chrome.ts, which asks the API for its version at import time, and a + * test cannot bundle that. + */ + +// What a phone is, for this purpose. Two questions, both of which have to say +// yes. +// +// A coarse pointer is the half that matters: it is a device whose primary +// input is a finger, which is what MegaMek's client has no answer for. It is +// also what keeps a desktop window dragged narrow out of this — that is a +// mouse in front of a small viewport, and it can play. +// +// The size is the half that lets a tablet through. A phone is small in one +// dimension whichever way it is held, so both are asked: 390x844 is narrow +// upright and short on its side, while an iPad in landscape is neither. +const COARSE = "(pointer: coarse)"; +const NARROW = "(max-width: 48rem)"; +const SHORT = "(max-height: 30rem)"; + +/** + * The answer, at the moment it is asked. + * + * Read at the moment a screen is chosen rather than watched: rotating a phone + * does not stop it being one, and there is no size a phone can be resized to + * that would make a match playable on it. + * + * Guarded for a missing matchMedia, because this runs before anything else + * the play path does and the wrong answer to fail with is "no phones may + * play". + */ +export function isPhone(): boolean { + if (!window.matchMedia) return false; + if (!window.matchMedia(COARSE).matches) return false; + return window.matchMedia(NARROW).matches || window.matchMedia(SHORT).matches; +} diff --git a/web/src/screens/phone-gate.ts b/web/src/screens/phone-gate.ts new file mode 100644 index 0000000..795ac76 --- /dev/null +++ b/web/src/screens/phone-gate.ts @@ -0,0 +1,56 @@ +import { footer, pageHead } from "../chrome"; +import { el } from "../dom"; +import { nav } from "../nav"; + +/** + * A phone cannot play a match, so it is told that instead of being let into + * one. + * + * A match is MegaMek's own desktop client drawn into a canvas — a fixed + * layout meant for a mouse, a keyboard and a screen with room for a hex map + * and four panels beside it. None of that degrades on a phone: it renders, + * it takes taps, and it is unplayable. A player who finds that out from a + * lobby they have already sat somebody else down in has spent an opponent's + * time as well as their own. + * + * So the gate is on the way in rather than a warning inside. It stands in for + * Play and for a lobby, which are the two screens that lead to a match, and + * for the daily fight, which is the third way into one. Everything else the + * site does — reading About, designing a camo, looking at a pilot page, the + * blog — is unaffected: those pages work at a phone's width and are the + * reason site-a11y's rules exist. + * + * Not dismissible, and no "play anyway". The support answer is no, and a gate + * that can be clicked through is a warning wearing a gate's clothes. + */ + +/** + * The screen a phone gets where a match would have been. + * + * Home rather than Back: the address that got here is a match address, and + * every one of them ends at this same card. Home is a page the phone can + * actually use. + */ +export function phoneGateScreen(): Node[] { + const home = el("button", { type: "button", textContent: "Go home" }); + home.addEventListener("click", () => { + window.history.replaceState({}, "", "/"); + void nav.start!(); + }); + + return [ + pageHead( + "Phones are not supported", + "A match runs MegaMek's desktop interface, which needs a mouse and a screen to fit it.", + ), + el("section", { className: "card" }, [ + el("p", { + className: "hint", + textContent: + "Open lance.blue on a desktop or a laptop to play. The rest of the site works here.", + }), + el("div", { className: "row" }, [home]), + ]), + footer(), + ]; +}