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

forked from trevato/cs

A first visit is one click from a round: Play first, and the seven controls fold behind a summary that says what they are #410

Merged opened this

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

Merged

torque/cs-engine:torque/255-a-first-visit-is-one-click-from-a-round → main 3 commits fork

  • Merged. torque/255-a-first-visit-is-one-click-from-a-round was merged into main as 9757255.

Review

Sign in to review this change.
9 files changed +315 −15
README.md +11
@@ -239,6 +239,17 @@ yet.
239239 when a match starts, and put back in the menu on your next visit. A stored
240240 value that is not one of the menu's own answers is ignored, and the page's
241241 default stands.
242 - **Play is the first thing.** The menu opens on your name, one sentence that
243 says what Play gives you — the length, the side and the map, and whether it
244 is the round of a match id — the plan of that map, and Play. Every other
245 control (length, side, map, map name, sensitivity, motion, match id, today's
246 match and rooms) is in one fold under Play, a `<details>` that opens in place
247 with no script and from the keyboard. The sentence is written by one function
248 (`src/client/offer.ts`) from the values the controls hold, on the server and
249 again whenever a control changes, so your stored side and length are what it
250 says. A link that asks for anything — `?match=`, `?map=`, `?room=` — opens
251 the fold, so what it filled in is on the screen. A new setting for the menu
252 goes inside that fold, not beside Play.
242253
243254 ## A match you can play again
244255
src/client/main.test.ts +48 −1
@@ -37,7 +37,7 @@ import type { Socket } from "./session.ts";
3737 import { NO_PICTURE } from "../engine/index.ts";
3838 import { HINT } from "./buy.ts";
3939 import { DE_FORGE } from "./map.ts";
40 import { endRound, SHORT } from "./match.ts";
40 import { COMPETITIVE, endRound, SHORT } from "./match.ts";
4141 import { LAYERS, materialTextures } from "./materials.ts";
4242 import { recorder } from "./paint.test-helper.ts";
4343 import { drawPreview, previewText } from "./preview.ts";
@@ -210,6 +210,53 @@ test("the side and the match length are remembered beside the name, and junk is
210210 expect(junk.by("length").value).toBe("short");
211211 });
212212
213 test("the sentence beside Play says what the folded controls hold, and moves when they do", () => {
214 const stage = painted(start(fakeStage()));
215 stage.by("length").value = "short";
216 stage.by("side").value = "ct";
217 stage.by("length").fire("change");
218 expect(stage.by("willgive").textContent)
219 .toBe(`A short match — first to ${SHORT.winScore} — as the Counter-Terrorists on de_forge.`);
220
221 // A second set of values, every one of them different: the sentence is the
222 // controls', so it is theirs and not the first paint's.
223 stage.by("length").value = "long";
224 stage.by("length").fire("change");
225 stage.by("side").value = "t";
226 stage.by("side").fire("change");
227 stage.by("mapseed").value = MAP;
228 stage.by("mapseed").fire("input");
229 stage.by("matchid").value = "0f8fad5b-d9cb-469f-a165-70867728950e";
230 stage.by("matchid").fire("input");
231 expect(stage.by("willgive").textContent)
232 .toBe(`A competitive match — first to ${COMPETITIVE.winScore} — as the Terrorists on ${MAP}, the round of the match id below.`);
233 // And the match Play starts is the one the sentence promised.
234 stage.by("play").fire("click");
235 expect(worldOf(stage)?.me.team).toBe("t");
236 expect(worldOf(stage)?.match.rules).toBe(COMPETITIVE);
237 expect(worldOf(stage)?.level.name).toBe(MAP);
238
239 // A generated map is named by the id drawn for it, in the sentence as above Play.
240 stage.by("quit").fire("click");
241 stage.by("mapseed").value = "";
242 stage.by("matchid").value = "";
243 stage.by("map").value = "generated";
244 stage.by("map").fire("change");
245 expect(stage.by("willgive").textContent).toContain(`on ${stage.by("willplay").textContent}.`);
246 });
247
248 test("a player with settings stored reads their own in the sentence, not the defaults", () => {
249 const again = fakeStage();
250 again.store.set("cs.side", "t");
251 again.store.set("cs.length", "long");
252 start(again);
253 expect(again.by("willgive").textContent)
254 .toBe(`A competitive match — first to ${COMPETITIVE.winScore} — as the Terrorists on de_forge.`);
255 // Today's match sets the length in code, where no event fires, and says so too.
256 again.by("today").fire("click");
257 expect(again.by("willgive").textContent).toContain(", the round of the match id below.");
258 });
259
213260 test("clicking play starts a match: the menu goes, the pointer is asked for, the frame draws", () => {
214261 const stage = playing();
215262 expect(stage.by("menu").hidden).toBe(true);
src/client/main.ts +31 −3
@@ -85,13 +85,14 @@ import {
8585 badName, chosenMap, levelOf, nameMap, NOT_A_MAP, NOT_AN_ID, planDue, planLevel, playedLink, readId, type Controls,
8686 } from "./chooser.ts";
8787 import { dailyOn, DAILY_LENGTH, saidToday } from "../daily.ts";
88 import { playKey } from "../matchid.ts";
88 import { matchFrom, playKey } from "../matchid.ts";
8989 import { describe, localPlan, parsePlan, type RoundPlan } from "../plan/plan.ts";
9090 import type { Situation } from "../plan/situation.ts";
9191 import { DE_FORGE, levelNamed } from "./map.ts";
9292 import type { Level } from "./level.ts";
9393 import { FOV, fovFor, lookAt } from "./look.ts";
9494 import { COMPETITIVE, SHORT, type Rules, type Team } from "./match.ts";
95 import { offerSaid } from "./offer.ts";
9596 import { motionFor, parseMotion, REDUCED_MOTION_QUERY, type Motion } from "./motion.ts";
9697 import { parseSens, parseSound, readSettings, SENS, SETTING_KEYS, settingStrings, type Settings, withSetting } from "./settings.ts";
9798 import { LAYERS, MATERIALS, TEX_SIZE } from "./materials.ts";
@@ -430,6 +431,9 @@ export function boot(host: Host): Frame {
430431 // The name of the map Play is about to give you, the plan of it, the line
431432 // that says in words what the plan says in pixels, and the link that keeps it.
432433 const willPlay = by("willplay");
434 // The sentence beside Play that says what it gives — length, side and map —
435 // while the controls that decide it are folded away. See `sayOffer`.
436 const willGive = by("willgive");
433437 const planCanvas = by<Sheet>("plan");
434438 const planAlt = by("planalt");
435439 const copyMap = by<Toggle>("copymap");
@@ -1112,12 +1116,34 @@ export function boot(host: Host): Frame {
11121116 const next = nameMap(asked(), win.performance.now(), draw);
11131117 keeping = next.link;
11141118 willPlay.textContent = next.name;
1119 nextName = next.name;
1120 sayOffer();
11151121 copyMap.setAttribute("href", keeping);
11161122 copyMap.textContent = copyLabel;
11171123 onMap.textContent = next.bad ? NOT_A_MAP : aboutMap;
11181124 planAt = next.planAt;
11191125 }
11201126
1127 /** The map `nameNextMap` last named, which is the map in the sentence below. */
1128 let nextName = "de_forge";
1129
1130 /**
1131 * The sentence beside Play, out of the controls it summarises — the same
1132 * reading `start` gives them, through `offerSaid` in `./offer.ts`, which is
1133 * also what the server rendered it with. Called when any of them changes,
1134 * and once more after the stored side and length are put back, so a player
1135 * with settings of their own reads theirs and not the defaults.
1136 */
1137 function sayOffer(): void {
1138 willGive.textContent = offerSaid({
1139 length: lengthField.value,
1140 side: sideField.value,
1141 map: nextName,
1142 replay: matchFrom(idField.value.trim()) !== null,
1143 });
1144 }
1145 for (const field of [lengthField, sideField]) field.addEventListener("change", sayOffer);
1146
11211147 /** The plan of the map Play would start, and the sentence beside it. */
11221148 function drawMapPlan(): void {
11231149 const level = planLevel(asked(), draw);
@@ -1165,8 +1191,9 @@ export function boot(host: Host): Frame {
11651191 seedField.value = "";
11661192 lengthField.value = DAILY_LENGTH;
11671193 onToday.textContent = saidToday(daily);
1168 // The id changed without a keystroke, so the line before Play and the plan
1169 // beside it are named again here: no event fires for a value set in code.
1194 // The id and the length changed without a keystroke, so the line before
1195 // Play, the plan beside it and the sentence are named again here: no event
1196 // fires for a value set in code.
11701197 nameNextMap();
11711198 }
11721199 todayButton.addEventListener("click", pickToday);
@@ -1502,6 +1529,7 @@ export function boot(host: Host): Frame {
15021529 if (savedSide === "t" || savedSide === "ct") sideField.value = savedSide;
15031530 const savedLength = host.storage.getItem("cs.length");
15041531 if (savedLength === "short" || savedLength === "long") lengthField.value = savedLength;
1532 sayOffer();
15051533
15061534 /**
15071535 * Sound for whatever happened in the ticks we just ran, heard from `ears` —
src/client/offer.test.ts added +27
@@ -0,0 +1,27 @@
1 /**
2 * The sentence beside Play, checked against sentences written out by hand: the
3 * defaults, and a second set that changes every one of the four things it says.
4 */
5
6 import { expect, test } from "bun:test";
7 import { COMPETITIVE, SHORT } from "./match.ts";
8 import { A_GENERATED_MAP, offerSaid } from "./offer.ts";
9
10 test("the defaults say a short match as the Counter-Terrorists on de_forge", () => {
11 expect(offerSaid({ length: "short", side: "ct", map: "de_forge", replay: false }))
12 .toBe(`A short match — first to ${SHORT.winScore} — as the Counter-Terrorists on de_forge.`);
13 // Five rounds today; the sentence reads the rules rather than typing the digit.
14 expect(SHORT.winScore).toBe(5);
15 });
16
17 test("every other value says itself: competitive, the Terrorists, a named map and a given id", () => {
18 expect(offerSaid({ length: "long", side: "t", map: "gen_14u5j6g", replay: true }))
19 .toBe(`A competitive match — first to ${COMPETITIVE.winScore} — as the Terrorists on gen_14u5j6g, ` +
20 `the round of the match id below.`);
21 expect(offerSaid({ length: "short", side: "ct", map: null, replay: false })).toContain(`on ${A_GENERATED_MAP}.`);
22 });
23
24 test("a value the selects never offer reads the way the match reads it: short, Counter-Terrorists", () => {
25 expect(offerSaid({ length: "forever", side: "spectator", map: "de_forge", replay: false }))
26 .toBe(offerSaid({ length: "short", side: "ct", map: "de_forge", replay: false }));
27 });
src/client/offer.ts added +41
@@ -0,0 +1,41 @@
1 /**
2 * What pressing Play gives you, in one sentence: the length, the side and the
3 * map — and, when there is one, that it is a match somebody gave you an id for.
4 *
5 * It is the line the menu shows beside Play while every control that decides
6 * it is folded away (#255), so it has one job, which is to be true. It is
7 * written by one function out of the same values the controls hold: the page
8 * renders it from the values it renders the controls with, and
9 * `src/client/main.ts` writes it again, through `textContent`, whenever one of
10 * those controls changes. Plain data in, a string out; the caller escapes.
11 *
12 * The words for the length and the side are the words the two selects already
13 * use, and the number is `winScore` off the rules the match will be played to,
14 * so a tuning that moves the score moves the sentence.
15 */
16
17 import { COMPETITIVE, SHORT } from "./match.ts";
18
19 /** What the sentence is made of: the four things the folded controls decide. */
20 export type Offer = {
21 /** The Length select: `long` is Competitive, anything else is Short, as `start` reads it. */
22 length: string;
23 /** The Side select: `t` is the Terrorists, anything else the Counter-Terrorists. */
24 side: string;
25 /** The map by name, or null for a generated map nobody has drawn yet. */
26 map: string | null;
27 /** Whether the Match id field holds an id — the round is somebody's, not a fresh one. */
28 replay: boolean;
29 };
30
31 /** What a map the page has yet to draw is called in the sentence. */
32 export const A_GENERATED_MAP = "a generated map";
33
34 /** The sentence. `offerSaid({ length: "short", side: "ct", map: "de_forge", replay: false })` is the defaults'. */
35 export function offerSaid(offer: Offer): string {
36 const long = offer.length === "long";
37 const rules = long ? COMPETITIVE : SHORT;
38 const side = offer.side === "t" ? "the Terrorists" : "the Counter-Terrorists";
39 return `${long ? "A competitive" : "A short"} match — first to ${rules.winScore} — as ${side} on ` +
40 `${offer.map ?? A_GENERATED_MAP}${offer.replay ? ", the round of the match id below" : ""}.`;
41 }
src/client/stage.test-helper.ts +1 −1
@@ -165,7 +165,7 @@ export type FakeStage = {
165165 * contract between the page and this file, so `src/server.test.ts` checks the
166166 * served page against this same list rather than a second copy of it.
167167 */
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buycursor", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
168 export const PAGE_IDS = ["weights", "stage", "scene", "hud", "menu", "pause", "summary", "buy", "buycursor", "buyshelf", "buysays", "touch", "stats", "saved", "name", "length", "side", "sens", "touchsens", "motion", "sound", "map", "mapseed", "matchid", "onid", "onmap", "today", "ontoday", "willplay", "willgive", "options", "plan", "planalt", "copymap", "play", "room", "roomside", "joinroom", "makeroom", "copyroom", "roomsaid", "prep", "resume", "quit", "psens", "ptouchsens", "pmotion", "psound", "esc", "pauseid", "sumtitle", "sumscore", "sumyou", "sumboard", "sumsaved", "again", "leave", "playing", "mapname", "share", "fps", "full", "support", "supportline", "copysupport"];
169169
170170 /** The ones the server renders with `hidden` on them: the page starts at the menu. */
171171 const HIDDEN = ["pause", "summary", "buy", "buycursor", "touch", "full"];
src/pages/game.test.ts +93
@@ -37,7 +37,9 @@ import { GAMES } from "../games.ts";
3737 import { digitsIn, proseOf, unsourced } from "./digits.test-helper.ts";
3838 import {
3939 gamePage, planHtml, PLANNED_ID, policyHtml, shareHref, statsHtml, STYLE, BAD_ID, BAD_MAP, COPY_ROOM, NOTHING_ASKED, type PageOptions,
40 foldOpen, offerOf, OPTIONS, type Asked,
4041 } from "./game.ts";
42 import { A_GENERATED_MAP, offerSaid } from "../client/offer.ts";
4143 import { roomLink } from "../roomlink.ts";
4244 import { WEAPONS_HREF, WEAPONS_SECTION, WEAPONS_SUMMARY } from "./weapons.ts";
4345 import { escape, SUPPORT } from "./shell.ts";
@@ -487,3 +489,94 @@ test("where a model plans the rounds, the id's sentence says what an id keeps of
487489 expect(planHtml(true).replace(/\s+/g, " ")).toContain("kept with the match it was asked for");
488490 expect(planHtml(false)).not.toContain("kept with the match");
489491 });
492
493 // --- a first visit is one click from a round (#255) ------------------------
494
495 /** The menu overlay's own markup, from its opening tag to the next overlay. */
496 const menuOf = (html: string): string => html.slice(html.indexOf(`id="menu"`), html.indexOf(`id="pause"`));
497
498 /** What is inside the fold, and what is before it. */
499 const foldOf = (html: string): { fold: string; before: string } => {
500 const menu = menuOf(html);
501 const at = menu.indexOf(`<details id="options"`);
502 expect(at).toBeGreaterThan(-1);
503 return { before: menu.slice(0, at), fold: menu.slice(at, menu.indexOf("</details>", at)) };
504 };
505
506 test("Play is the first thing: the name, the sentence, the plan and the button, and the rest folded", () => {
507 const html = gamePage(db(), options);
508 const { before, fold } = foldOf(html);
509 // Before the fold: what a first visit wants, and the plan that says what it is.
510 for (const id of ["name", "willgive", "willmap", "plan", "willplay", "copymap", "play"])
511 expect(before).toContain(`id="${id}"`);
512 // In it: every other control, with nothing removed.
513 for (const id of ["length", "side", "map", "mapseed", "sens", "touchsens", "motion", "matchid", "today", "room", "roomside", "joinroom", "makeroom", "copyroom"]) {
514 expect(fold).toContain(`id="${id}"`);
515 expect(before).not.toContain(`id="${id}"`);
516 }
517 // It says what is inside it, and it is closed on a link that asked for nothing.
518 expect(fold).toContain(`<summary>${escape(OPTIONS)}</summary>`);
519 expect(fold).toStartWith(`<details id="options">`);
520 // With no stored settings the sentence is the defaults', written by hand.
521 expect(html).toContain(`<p id="willgive">A short match — first to ${SHORT.winScore} — as the Counter-Terrorists on de_forge.</p>`);
522 });
523
524 test("the sentence is rendered from what the controls are rendered with", () => {
525 const name = "gen_14u5j6g";
526 const id = "0f8fad5b-d9cb-469f-a165-70867728950e";
527 const html = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, match: id, map: name } });
528 expect(html).toContain(`<p id="willgive">${escape(offerSaid(offerOf({ ...NOTHING_ASKED, match: id, map: name })))}</p>`);
529 expect(html).toContain(`on ${name}, the round of the match id below.</p>`);
530 // The controls hold the same two values the sentence names.
531 expect(html).toContain(`value="${name}"`);
532 expect(html).toContain(`value="${id}"`);
533 // A generated map is one the page has yet to draw, and the sentence says so.
534 const generated = gamePage(db(), { ...options, asked: { ...NOTHING_ASKED, map: "generated" } });
535 expect(generated).toContain(`<option value="generated" selected>`);
536 expect(generated).toContain(`as the Counter-Terrorists on ${A_GENERATED_MAP}.</p>`);
537 });
538
539 test("a link that asked for something opens the fold, so what it filled in is on the screen", () => {
540 const id = "0f8fad5b-d9cb-469f-a165-70867728950e";
541 const asks: Asked[] = [
542 { ...NOTHING_ASKED, match: id },
543 { ...NOTHING_ASKED, map: "gen_14u5j6g" },
544 { ...NOTHING_ASKED, map: "generated" },
545 { ...NOTHING_ASKED, badMatch: true },
546 { ...NOTHING_ASKED, badMap: true },
547 { ...NOTHING_ASKED, room: "8UTZQR" },
548 ];
549 for (const asked of asks) {
550 expect(foldOpen(asked)).toBe(true);
551 expect(foldOf(gamePage(db(), { ...options, asked })).fold).toStartWith(`<details id="options" open>`);
552 }
553 for (const asked of [NOTHING_ASKED, { ...NOTHING_ASKED, room: null }]) {
554 expect(foldOpen(asked)).toBe(false);
555 expect(foldOf(gamePage(db(), { ...options, asked })).fold).toStartWith(`<details id="options">`);
556 }
557 });
558
559 test("every control in the menu is still one the keyboard reaches", () => {
560 const menu = menuOf(gamePage(db(), options));
561 // Nothing taken out of the tab order, and the fold is a native `<details>`
562 // whose `<summary>` is focusable and opens it with Enter or Space — no script.
563 expect(menu).not.toContain("tabindex");
564 expect(menu).not.toContain(`role="button"`);
565 expect(menu.match(/<summary>/g)?.length).toBe(2);
566 // The only hidden control is the room's link, which is there when a room is.
567 const hidden = [...menu.matchAll(/<(?:input|select|button|a)\b[^>]*\bhidden\b[^>]*>/g)].map((m) => m[0]);
568 expect(hidden).toEqual([`<a class="share" id="copyroom" href="/" hidden>`, `<button type="button" id="copysupport" hidden>`]);
569 // And the summary is a target a thumb can hit.
570 expect(STYLE).toContain("#options > summary { min-height:44px;");
571 });
572
573 test("the fold costs `/` nothing over the wire: the style block ships without its comments", () => {
574 const html = gamePage(db(), options);
575 const style = html.slice(html.indexOf("<style>"), html.indexOf("</style>"));
576 expect(style).not.toContain("/*");
577 // The rules the comments sat above are all still there.
578 expect(style).toContain("#willgive { min-height:3.1em;");
579 expect(style).toContain("@media not (pointer: coarse) { .thumbonly { display:none; } }");
580 // 9,562 bytes gzipped before #255; the page is held under that.
581 expect(Bun.gzipSync(html).byteLength).toBeLessThan(9562);
582 });
src/pages/game.ts +53 −9
@@ -26,6 +26,7 @@ import { counted } from "../keep.ts";
2626 import { COMPETITIVE, SHORT } from "../client/match.ts";
2727 import { DEFAULT_MOTION, MOTION_LABELS, MOTIONS } from "../client/motion.ts";
2828 import { CS_MOVE } from "../client/movement.ts";
29 import { offerSaid, type Offer } from "../client/offer.ts";
2930 import { NOTHING_POSTED } from "../client/pending.ts";
3031 import { SENS, SOUND_LABELS, SOUNDS } from "../client/settings.ts";
3132 import { FLASH, HE, SMOKE } from "../client/weapons.ts";
@@ -317,6 +318,17 @@ a.share[hidden] { display:none; }
317318 #summary .ct { color:var(--ct); font-weight:600; }
318319 #sumscore { font-size:1.1rem; color:var(--text); }
319320 #sumyou { color:var(--text); }
321 /* The sentence Play is about to keep: one line on a desktop, two on a phone,
322 and the same box whichever the browser writes over the server's. */
323 #willgive { min-height:3.1em; color:var(--text); }
324 /* The fold: everything but Name and Play, one press away. Full width so the
325 rows inside it wrap the way they did outside it; the summary is a target a
326 thumb can hit, and the marker is the browser's own. */
327 #options { width:100%; max-width:40rem; }
328 #options > summary { min-height:44px; padding:.6rem .5rem; cursor:pointer; color:var(--ct); }
329 #options[open] > summary { margin-bottom:.5rem; }
330 #options > .row, #options > p { margin-left:auto; margin-right:auto; }
331 #options > p { margin-top:.5rem; margin-bottom:.5rem; }
320332 #willtext { flex:1 1 13rem; min-width:0; max-width:22rem; text-align:left; }
321333 #willtext p { margin:.1rem 0; }
322334 /* Two lines' worth, whether it is holding the sentence the server rendered or
@@ -462,6 +474,32 @@ export const PLANNED_ID =
462474 "opinion. A plan is played from the end of the freeze; one not back by then leaves that round to " +
463475 "the local plan.";
464476
477 /** What the fold's own line says: every control inside it, by the name its label gives it. */
478 export const OPTIONS =
479 "Length, side, map, map name, sensitivity, motion, match id, today's match and rooms";
480
481 /**
482 * What a first paint says Play will give: the defaults, or what the link asked
483 * for. The select's two words and a name are the map as `?map=` gave it, and a
484 * generated one is null — the id it is carved from is drawn in the browser.
485 * Length and side have no parameter, so they are the selects' first options,
486 * and a browser that stored others (#73) writes the sentence again at boot.
487 */
488 export const offerOf = (asked: Asked): Offer => ({
489 length: "short",
490 side: "ct",
491 map: asked.map === "generated" ? null : asked.map,
492 replay: asked.match !== null,
493 });
494
495 /**
496 * Whether the fold is rendered open: whenever the link asked for anything, so
497 * a pasted `?match=`, `?map=` or `?room=` shows what it filled in — or the
498 * sentence saying it was not one — rather than filling a closed box.
499 */
500 export const foldOpen = (asked: Asked): boolean =>
501 asked.match !== null || asked.badMatch || asked.map !== "de_forge" || asked.badMap || asked.room != null;
502
465503 const menu = (visitor: Who, asked: Asked, daily: Daily, planning: boolean): string => `
466504 <h2>Defuse or detonate</h2>
467505 <p>Five against five on <b>de_forge</b>, on a map generated for the match, or on any generated
@@ -472,6 +510,20 @@ const menu = (visitor: Who, asked: Asked, daily: Daily, planning: boolean): stri
472510 <label>Name<input id="name" maxlength="24" placeholder="you" autocomplete="nickname"${
473511 visitor.role === "anon" ? "" : ` value="${escape(visitor.name)}" readonly`
474512 }></label>
513 </div>
514 <p id="willgive">${escape(offerSaid(offerOf(asked)))}</p>
515 <div id="willmap">
516 <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas>
517 <div id="willtext">
518 <p>Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b></p>
519 <p class="hint" id="planalt">${escape(UNPLANNED)}</p>
520 <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a>
521 </div>
522 </div>
523 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
524 <details id="options"${foldOpen(asked) ? " open" : ""}>
525 <summary>${escape(OPTIONS)}</summary>
526 <div class="row">
475527 <label>Length<select id="length"><option value="short">Short — first to ${num(SHORT.winScore)}</option><option value="long">Competitive — first to ${num(COMPETITIVE.winScore)}</option></select></label>
476528 <label>Side<select id="side"><option value="ct">Counter-Terrorists</option><option value="t">Terrorists</option></select></label>
477529 <label>Map<select id="map"><option value="de_forge">de_forge</option><option value="generated"${
@@ -504,15 +556,6 @@ ${settingsHtml("")}
504556 <button type="button" id="today">Pick today's match</button>
505557 </div>
506558 <p class="hint" id="ontoday">${escape(saidToday(daily))}</p>
507 <div id="willmap">
508 <canvas id="plan" width="192" height="192" aria-hidden="true"></canvas>
509 <div id="willtext">
510 <p>Map <b id="willplay">${escape(asked.map === "generated" ? UNDRAWN : asked.map)}</b></p>
511 <p class="hint" id="planalt">${escape(UNPLANNED)}</p>
512 <a class="share" id="copymap" href="${escape(mapLink(asked.map))}">Copy this map's link</a>
513 </div>
514 </div>
515 <div class="row"><button type="button" class="primary" id="play" disabled>Play</button></div>
516559 <div class="row">
517560 <label>Room code<input id="room" maxlength="6" placeholder="ABCDEF" autocapitalize="characters"
518561 autocomplete="off" spellcheck="false"${asked.room == null ? "" : ` value="${escape(asked.room)}"`}></label>
@@ -529,6 +572,7 @@ ${settingsHtml("")}
529572 When a room&#39;s match ends, the server puts it on the leaderboard for everyone
530573 still seated — leave before the end and it is not counted for you. Rooms live in this process: a deploy
531574 ends them, and an empty one is forgotten after ninety seconds.</p>
575 </details>
532576 <p class="hint" id="prep">Preparing textures…</p>
533577 <p class="hint"><kbd>WASD</kbd> move · <kbd>Space</kbd> jump · <kbd>Ctrl</kbd> crouch · <kbd>Shift</kbd> walk ·
534578 mouse to aim and fire · <kbd>R</kbd> reload · <kbd>G</kbd> throw an HE · <kbd>T</kbd> throw a flashbang · <kbd>V</kbd> throw a smoke ·
src/pages/shell.ts +10 −1
@@ -128,6 +128,15 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
128128 @media (max-width: 640px) { #stage { aspect-ratio:4/3; } h1 { font-size:1.2rem; } }
129129 `;
130130
131 /**
132 * The style block without its comments. They are this file's documentation —
133 * why a box is the size it is — and a browser reads none of them, so they stay
134 * in the source and never go over the wire: on `/` that is a few hundred bytes
135 * of every first paint (#255). No rule here writes `/*` inside a string or a
136 * `url()`, which is the one place this could cut into a rule.
137 */
138 const uncommented = (css: string): string => css.replace(/[ \t]*\/\*[\s\S]*?\*\/[ \t]*\n?/g, "");
139
131140 /**
132141 * Exactly what a page with this extra CSS puts between its `<style>` tags: the
133142 * shared block, then that page's own, escaped the way the document escapes it.
@@ -141,7 +150,7 @@ code { background:#141821; padding:.1rem .35rem; border-radius:.25rem; }
141150 * which is why `extraStyle` is a constant in every page that has one.
142151 */
143152 export const styleFor = (extraStyle?: string): string =>
144 `${STYLE}${extraStyle === undefined ? "" : cssSafe(extraStyle)}`;
153 uncommented(`${STYLE}${extraStyle === undefined ? "" : cssSafe(extraStyle)}`);
145154
146155 /**
147156 * The icon, in the document rather than beside it: a crosshair on the page's

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs