Public: anyone can read this; the crew can change it.

forked from trevato/cs

JavaScript off is a sentence, not a dead Play button: one noscript line in the shell, on all three games #249

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/cs-engine:torque/161-javascript-off-is-a-sentence-not-a-dead → main 1 commit fork

  • Merged. torque/161-javascript-off-is-a-sentence-not-a-dead was merged into main as 81a98bf.

Review

Sign in to review this change.
10 files changed +171 −9
README.md +10 −2
@@ -953,7 +953,7 @@ never checked itself.
953953 refusal — carry `style-src 'self'` and accept no inline style at all.
954954 - `src/pages/shell.ts` — the document every game is served inside, and nothing
955955 about any one game: `page({ title, description, heading, nav, env, pull,
956 commit, script, ssh, terminalLead, stageHtml, sectionsHtml, footer,
956 commit, script, ssh, terminalLead, noscript, stageHtml, sectionsHtml, footer,
957957 extraStyle })`
958958 gives you the doctype, the head, the one `<style>` block, the icon, the header
959959 with its nav of the games, its environment pill and short commit, `<main>`, the
@@ -973,7 +973,15 @@ never checked itself.
973973 free** — and no page ever imports another page. What one page says about
974974 another comes from there too (`othersHtml(here)`): the sentence about a game
975975 is that game's own registry entry, never prose hand-written next door, which
976 is how it goes stale.
976 is how it goes stale. **So is what a game says to a browser with no
977 JavaScript** (`noscriptFor(here)`, out of the registry's `noscript` field):
978 one sentence at the top of `<main>`, directly above the stage, saying what
979 that page needs a script for — its Play or Start button is the one dead
980 control — and what on it is server-rendered and still worth reading, which on
981 these pages is the leaderboard, the recent matches, the prose and the terminal
982 line. It is a `<noscript>` element, so a browser that runs scripts renders
983 none of it: no box, no height, no shift and no second request. The 404 and the
984 500 hand in none and get no element, the same bargain `script` is.
977985 A rule only one game needs goes in `extraStyle`, never in the shared block,
978986 and CSS handed in there cannot close the block. It has to be a constant the
979987 page exports, because `src/server.ts` hashes the shared block plus that page's
scripts/train.test.ts +10 −1
@@ -11,14 +11,23 @@
1111 * hand, and that a retrain writes a file shaped exactly like the one it
1212 * replaces. `src/client/weights.test.ts` is the other half: it holds the file
1313 * that is actually in the tree to the numbers it claims.
14 *
15 * `../src/client/packed.ts` is imported before `./train.ts` on purpose, and
16 * the order is load-bearing until somebody breaks the cycle it works around:
17 * `packed.ts` reads `OBS_SIZE` out of `observe.ts` while it is initialising,
18 * and `observe.ts` → `world.ts` → `bots.ts` → `packed.ts` closes the ring, so
19 * a graph entered at the `observe.ts` end — which is what `./train.ts` is, one
20 * import further on — runs `packed.ts`'s body before that constant exists and
21 * throws `Cannot access 'OBS_SIZE' before initialization`. Entered here, this
22 * file loads on its own as well as in a suite. Not this pull's to fix.
1423 */
1524
1625 import { expect, test } from "bun:test";
1726 import { mkdtempSync, writeFileSync } from "node:fs";
1827 import { tmpdir } from "node:os";
1928 import { join, resolve } from "node:path";
20 import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts";
2129 import { parsePacked } from "../src/client/packed.ts";
30 import { cardModule, DEFAULTS, parseOptions, policyDocument, POLICY, USAGE, WEIGHTS, type Options } from "./train.ts";
2231 import { PACKED } from "../src/client/shipped.ts";
2332 import { CARD } from "../src/client/weights.ts";
2433
src/games.ts +15
@@ -55,6 +55,15 @@ export type Game = {
5555 assets: readonly Asset[];
5656 /** The one sentence the *other* pages say about this game. Text, escaped where it is rendered. Prose about a game belongs to that game, or it drifts. */
5757 blurb: string;
58 /**
59 * The one sentence this game's own page says to a browser with no JavaScript:
60 * what it needs a script for, which control is dead without one, and what on
61 * the page is still worth reading. Text, escaped where it is rendered, and
62 * one sentence — a page that apologises in a paragraph is worse than the dead
63 * button it is apologising for. It lives here for the reason `blurb` does: it
64 * is prose about a game, and a game owns its own.
65 */
66 noscript: string;
5867 };
5968
6069 /**
@@ -457,6 +466,8 @@ export const GAMES: readonly Game[] = [
457466 ],
458467 blurb:
459468 "a defusal shooter in the browser, with bots that learned to aim, bomb rounds, an economy, maps carved from a seed and the leaderboard this server keeps.",
469 noscript:
470 "Playing cs needs JavaScript — the match runs in your browser, so with scripts off Play stays disabled — but the leaderboard, the recent matches and everything written below are server-rendered and still here.",
460471 },
461472 {
462473 name: "range",
@@ -467,6 +478,8 @@ export const GAMES: readonly Game[] = [
467478 assets: [],
468479 blurb:
469480 "thirty seconds on the clock and two drills down one lane — plates that pop up, and plates that slide across it — a shot traced in three dimensions and a score, every line of it out of the engine and none of it out of the shooter.",
481 noscript:
482 "The range needs JavaScript — the lane, the plates and the clock are played in your browser, so with scripts off Capture the mouse does nothing — but everything written below about the drills, the movement and the sounds is server-rendered and still here.",
470483 },
471484 {
472485 name: "example",
@@ -477,5 +490,7 @@ export const GAMES: readonly Game[] = [
477490 assets: [],
478491 blurb:
479492 "the guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above for a browser that has none — the price tag on a third game.",
493 noscript:
494 "The example needs JavaScript — the guide's game runs in your browser, so with scripts off Start does nothing — but the measured cost of a third game, which is the point of this page, is server-rendered below and still here.",
480495 },
481496 ];
src/pages/example.ts +2 −1
@@ -13,7 +13,7 @@
1313 import { bytes, kB } from "../bytes.ts";
1414 import { GZIP_LEVEL as WIRE_LEVEL } from "../compress.ts";
1515 import { type Cost, GZIP_LEVEL } from "../cost.ts";
16 import { navFor, othersHtml } from "./games.ts";
16 import { navFor, noscriptFor, othersHtml } from "./games.ts";
1717 import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1818
1919 /**
@@ -86,6 +86,7 @@ export function examplePage(options: PageOptions): string {
8686 "The guide's own game, served: one room, one plate, thirty seconds and a score, drawn in WebGL2 through the engine's own renderer, with the plan from above as its fallback.",
8787 heading: "example",
8888 nav: navFor("/example"),
89 noscript: noscriptFor("/example"),
8990 terminalLead: "The shooter's leaderboard in a terminal:",
9091 footer: "no database, no cookie: this page keeps nothing",
9192 stageHtml: stage({
src/pages/game.ts +2 −1
@@ -15,7 +15,7 @@ import { NOTHING_POSTED } from "../client/pending.ts";
1515 import { CARD, type Card } from "../client/weights.ts";
1616 import { mapFrom, mapLink, type MapChoice } from "../mapname.ts";
1717 import type { Who } from "../who.ts";
18 import { navFor, othersHtml } from "./games.ts";
18 import { navFor, noscriptFor, othersHtml } from "./games.ts";
1919 import { escape, page, type PageOptions as ShellOptions, stage } from "./shell.ts";
2020
2121 export { escape };
@@ -406,6 +406,7 @@ export function gamePage(
406406 "A Counter-Strike-shaped defusal shooter that runs in the browser: WebGL2, bots, bomb rounds and an economy.",
407407 heading: "cs",
408408 nav: navFor("/"),
409 noscript: noscriptFor("/"),
409410 terminalLead: "The same leaderboard in a terminal:",
410411 footer: "state lives in this environment's own database",
411412 stageHtml: stage({
src/pages/games.ts +9
@@ -27,6 +27,15 @@ export const GAMES: readonly Game[] = REGISTRY.map(({ href, label }) => ({ href,
2727 export const navFor = (here: string): NavEntry[] =>
2828 GAMES.map((game) => (game.href === here ? { ...game, current: true } : { ...game }));
2929
30 /**
31 * The sentence the game at `here` says to a browser with no script, handed
32 * straight to the shell. A path that is no game's — the 404, the 500 — gets
33 * the empty string, which the shell renders as no element at all: a page that
34 * needs no script has nothing to explain.
35 */
36 export const noscriptFor = (here: string): string =>
37 REGISTRY.find((game) => game.href === here)?.noscript ?? "";
38
3039 /** A link to one game, and the one sentence its own entry says about it. Text in, markup out: both are escaped here. */
3140 const entryHtml = ({ href, label, blurb }: Registered): string =>
3241 `<a href="${escape(href)}">${escape(label)}</a> — ${escape(blurb)}`;
src/pages/range.ts +2 −1
@@ -10,7 +10,7 @@
1010 * terminal stay Counter-Strike's.
1111 */
1212
13 import { navFor, othersHtml } from "./games.ts";
13 import { navFor, noscriptFor, othersHtml } from "./games.ts";
1414 import { page, type PageOptions as ShellOptions, stage } from "./shell.ts";
1515
1616 /** What the shell needs that this page cannot know: the environment it runs in. */
@@ -52,6 +52,7 @@ export function rangePage(options: PageOptions): string {
5252 "A shooting range built only from this app's engine: seeded pop-up targets, a traced shot, a thirty second clock and a score.",
5353 heading: "range",
5454 nav: navFor("/range"),
55 noscript: noscriptFor("/range"),
5556 terminalLead: "The shooter's leaderboard in a terminal:",
5657 footer: "no database, no cookie: this page keeps nothing",
5758 stageHtml: stage({
src/pages/shell.test.ts +52 −2
@@ -6,8 +6,9 @@
66 */
77
88 import { expect, test } from "bun:test";
9 import { escape, nav, page, stage, styleFor, terminal, where } from "./shell.ts";
10 import { GAMES, navFor } from "./games.ts";
9 import { escape, nav, noscript, page, stage, styleFor, terminal, where } from "./shell.ts";
10 import { GAMES as REGISTRY } from "../games.ts";
11 import { GAMES, navFor, noscriptFor } from "./games.ts";
1112
1213 const XSS = `</script><script>alert(1)</script>`;
1314
@@ -204,6 +205,55 @@ test("the list of games lives once, and a page marks its own", () => {
204205 expect(navFor("/nope")).toHaveLength(GAMES.length);
205206 });
206207
208 test("a page that needs a script says so, above the stage, in one sentence", () => {
209 const line = "cs needs JavaScript — Play stays disabled without one — but the leaderboard is here.";
210 const html = page({ ...options, noscript: line, stageHtml: `<div id="stage"></div>` });
211 expect(html).toContain(` <noscript><p class="noscript">${escape(line)}</p></noscript>\n`);
212 // With the stage, not under the footer: inside <main>, before the stage, and
213 // nowhere near the end of the document.
214 expect(html.indexOf("<noscript")).toBeGreaterThan(html.indexOf("<main>"));
215 expect(html.indexOf("<noscript")).toBeLessThan(html.indexOf(`id="stage"`));
216 expect(html.indexOf("<noscript")).toBeLessThan(html.indexOf("<footer>"));
217 // Styled as part of the page, in the one style block the policy hashes: no
218 // second <style>, no style attribute, no second request.
219 expect(html).toContain("p.noscript {");
220 expect(html.match(/<style/g)?.length).toBe(1);
221 expect(html).not.toContain(" style=");
222 // And a page that runs nothing — the 404, the 500 — gets no element at all.
223 expect(page(options)).not.toContain("<noscript");
224 expect(page({ ...options, noscript: "" })).not.toContain("<noscript");
225 expect(noscript(undefined)).toBe("");
226 expect(noscript("")).toBe("");
227 });
228
229 test("the noscript line is text, wherever it enters", () => {
230 const html = page({ ...options, noscript: XSS });
231 expect(html).toContain(`<noscript><p class="noscript">&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;</p></noscript>`);
232 expect(html).not.toContain("</script><script>");
233 // Still one script element: the bundle's, and nothing the sentence opened.
234 expect(html.match(/<script/g)?.length).toBe(1);
235 });
236
237 test("every game's noscript line is that game's own, and it is one sentence", () => {
238 // Driven from the registry like the nav and the blurbs: a fourth game is held
239 // to this the moment it is written down.
240 for (const game of REGISTRY) {
241 const line = game.noscript;
242 expect(noscriptFor(game.href)).toBe(line);
243 // One sentence, ending in one full stop and containing no other.
244 expect(line.endsWith(".")).toBe(true);
245 expect(line.split(".").length - 1).toBe(1);
246 // It names this game rather than a generic page, and never another's.
247 expect(line).toContain(game.label);
248 for (const other of REGISTRY) if (other.name !== game.name) expect(line).not.toContain(other.label);
249 // It says what the script is for and what survives without one.
250 expect(line).toContain("JavaScript");
251 }
252 // A path that is no game's — the 404 and the 500 go through the same shell.
253 expect(noscriptFor("")).toBe("");
254 expect(noscriptFor("/nope")).toBe("");
255 });
256
207257 test("the stage keeps the ids the client finds it by", () => {
208258 const html = stage({
209259 touch: [[{ action: "duck", label: "crouch" }], [{ action: "fire", label: "fire" }]],
src/pages/shell.ts +35 −1
@@ -15,6 +15,14 @@
1515 * in none and gets no script element at all. Everything else in the document is
1616 * the same for both, which is the point of the shell.
1717 *
18 * The `<noscript>` line is the same bargain as the script: a game hands in the
19 * one sentence it wants a scriptless browser to read and gets it at the top of
20 * `<main>`, above the stage; a page that runs nothing hands in none and gets no
21 * element. It is a line rather than a rewritten page because these pages are
22 * genuinely useful without a script — the leaderboard, the recent matches, the
23 * prose and the terminal line are all server-rendered — so what is owed is an
24 * explanation of the one dead control, not an apology for the document.
25 *
1826 * A game's assets are the same bargain one step further: a game hands in
1927 * `{ id, href }` for each file it downloads at boot and gets a `<link
2028 * rel="preload">` in the head, high up, so the browser starts fetching it
@@ -100,6 +108,13 @@ button.primary { background:#2563eb; border-color:#2563eb; font-weight:600; }
100108 #touch[hidden] { display:none; }
101109 #touch .pad { display:flex; gap:.4rem; flex-wrap:wrap; pointer-events:auto; }
102110 #touch button { opacity:.72; background:#0f131acc; }
111 /* The sentence a browser with no script reads, and the only thing on the page
112 that is styled for a reader who will never see the game: at the top of main,
113 directly over the stage whose one live control is dead without a script. It
114 costs nothing when there is one — a browser that runs scripts renders none of
115 that element's content, so there is no box, no height and nothing to shift. */
116 p.noscript { margin:1rem 0 0; padding:.7rem .9rem; font-size:.92rem; background:#1d1810;
117 border:1px solid var(--line); border-left:.25rem solid var(--t); border-radius:.45rem; }
103118 main section { margin:1.5rem 0; }
104119 table { width:100%; border-collapse:collapse; font-size:.9rem; }
105120 th, td { text-align:left; padding:.35rem .5rem; border-bottom:1px solid var(--line); }
@@ -219,6 +234,19 @@ export function terminal(ssh: string | undefined, lead: string): string {
219234 return ssh === undefined ? "The app's terminal is off." : `${escape(lead)} <code>${escape(ssh)}</code>`;
220235 }
221236
237 /**
238 * What a browser with no script is told, which is one sentence and never two:
239 * what this page needs a script for, which control is dead without one, and
240 * what on it is still worth reading. The text is the game's own — it lives in
241 * `../games.ts` beside the blurb — and it is escaped here like every other
242 * parameter that carries text.
243 *
244 * A page that hands in nothing gets no element at all, which is the 404 and
245 * the 500: they run no script, so they have nothing to explain the absence of.
246 */
247 export const noscript = (line: string | undefined): string =>
248 line === undefined || line === "" ? "" : ` <noscript><p class="noscript">${escape(line)}</p></noscript>\n`;
249
222250 /** Which deployment this is, in words a visitor can act on. */
223251 export const where = (env: string, pull: string | undefined): string =>
224252 env === "preview" ? `preview of pull #${pull ?? "?"}` : env;
@@ -275,6 +303,12 @@ export type PageOptions = {
275303 ssh: string | undefined;
276304 /** The sentence in front of the ssh command, when there is one. */
277305 terminalLead: string;
306 /**
307 * Text. The one sentence a browser with no script reads, rendered at the top
308 * of `<main>` immediately above the stage — where the control it is about is.
309 * Absent is a page that needs no script and says nothing about one.
310 */
311 noscript?: string;
278312 /** Markup, already rendered — usually `stage(...)`. */
279313 stageHtml: string;
280314 /** Markup, already rendered: the `<section>`s under the stage. */
@@ -305,7 +339,7 @@ ${preloads(options.assets)}${endpoints(options.endpoints)}<title>${escape(option
305339 ${options.commit ? `<small class="commit">@ ${escape(options.commit)}</small>` : ""}
306340 </header>
307341 <main>
308 ${options.stageHtml}
342 ${noscript(options.noscript)}${options.stageHtml}
309343
310344 ${options.sectionsHtml}
311345 <p class="terminal">${terminal(options.ssh, options.terminalLead)}</p>
src/server.test.ts +34
@@ -400,6 +400,40 @@ test("what one page says about another is that game's own sentence", async () =>
400400 }
401401 });
402402
403 test("with no script every game says so once, in its own words, beside the control that is dead", async () => {
404 // Server-rendered, so a browser that never runs a line of this app's
405 // JavaScript still gets the sentence: it is in the first paint, in <main>,
406 // above the stage the dead control sits in — not under the footer, and not
407 // fetched.
408 for (const game of GAMES) {
409 const body = await (await fetch(base + game.href)).text();
410 expect(body).toContain(`<noscript><p class="noscript">${escape(game.noscript)}</p></noscript>`);
411 expect(body.match(/<noscript/g)?.length).toBe(1);
412 expect(body.indexOf("<noscript")).toBeGreaterThan(body.indexOf("<main>"));
413 expect(body.indexOf("<noscript")).toBeLessThan(body.indexOf(`<div id="stage">`));
414 expect(body.indexOf("<noscript")).toBeLessThan(body.indexOf("<footer>"));
415 // Its own sentence and no other game's, and it names this game.
416 for (const other of GAMES) if (other.name !== game.name) expect(body).not.toContain(escape(other.noscript));
417 expect(game.noscript).toContain(game.label);
418 // One sentence, and styled by the one block the policy already hashes:
419 // nothing inline, no second style element, no second request.
420 expect(game.noscript.split(".").length - 1).toBe(1);
421 expect(body).toContain("p.noscript {");
422 expect(body.match(/<style/g)?.length).toBe(1);
423 expect(body.match(/<script/g)?.length).toBe(1);
424 }
425 // The pages that run nothing explain nothing: they go through the same shell
426 // and hand it no sentence, because they have no dead control to apologise for.
427 const miss = await fetch(base + "/nope");
428 expect(miss.status).toBe(404);
429 expect(await miss.text()).not.toContain("<noscript");
430 const console_error = console.error;
431 console.error = () => {};
432 const bug = onError(new Error("boom"));
433 console.error = console_error;
434 expect(await bug.text()).not.toContain("<noscript");
435 });
436
403437 test("the 404 names every game in the registry and what each one is", async () => {
404438 const body = await (await fetch(base + "/nope")).text();
405439 for (const game of GAMES) {

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs