The screen does what you tell it — three Display items, and a fourth declined

Reviewed with Jesse out of TODO.md's Display section. Stays in the
unshipped v0.7.9.

#17 (hiding the Division map) was DECLINED, and the reason is that its
premise had already died. Gitea#18 replaced the wrapped layout with a
single row, and the reason to fold the map away was that it GREW — a
horseshoe of three or a square of four pushed the board off the screen.
One row is boardH = PAD * 2 + CH + 30: 150px, fixed, at every seat
count. That is not worth a control, three states and a persisted
preference. It was a sixth member of the drawing pass that got closed
with Gitea#18 and stayed open only because it reads as a control
question rather than a drawing one — recorded in TODO.md as an explicit
decision, with the design that had already been worked out kept, and
with the one thing that would justify reopening it: the map growing
again.

#16 THE OFFICE AREA'S AUTO-HIDE COULD NOT REACH EVERY STATE. One button
cycling auto -> pinned -> auto, where the pin was `open ? 'closed' :
'open'` and `open` is what auto is doing AT THAT MOMENT. So the pin a
press offered depended on the phase, and going from always-show to
always-hide meant clicking back to auto, waiting for the phase to turn
over, and clicking again. Three controls now, one per mode. The labels
still say what pressing DOES, which was an earlier deliberate fix; what
the cycle could not do was report the state it was in, and aria-pressed
carries that now.

They are addressed by id rather than queried off the container, and
that is testability rather than style: the stub DOM the web suite runs
against only models markup the page WROTE, so a child query finds
nothing and the control would have shipped green and unexercised. The
test presses always-show to always-hide directly — the transition the
cycle could not make.

#23 THE HISTORY READS NEWEST FIRST. Jesse: "the top line is the most
recent and the further down you go, the older the entry." The phase
headings now trail the lines they announce, ruled acceptable rather
than overlooked: "stage changes will be beneath (prior to / older than)
the following events. That is OK." Reading down is reading backwards.
Grouping by phase and reversing the groups was offered and declined as
more machinery than the complaint needs. replays.ts keeps its
oldest-first log deliberately — it is paired with a frame stepper,
where newest-first would fight the stepping. The slice(-60) cap is
untouched and stays open.

#28 THE SETTINGS MOVED INTO A CARD. The top line carried six things and
now carries four: Revenue, the objective, the collision counts and the
game code. The rest is a This Game card at the foot of the right-hand
column, folded by default. Nothing new travels for it — configFromFrame
already existed and main.ts already called it three times, so
rulesListHtml(configFromFrame(f), ...) needed no refactor, and the card
draws from the same renderer as the lobby's join preview so the two
cannot drift.

THE COLLISION COUNTS ARE NEW ON THE BOARD, NOT MOVED. The Frame has
carried collisionsToday and collisionsTotal since v0.7.0 and nothing
drew them, so the one victory condition that ends a game EARLY ran
invisibly — the second time this release that the Frame had the answer
and the view never asked (see #43's actingPlayer). They stay on the top
line while the limits go in the card: a limit is agreed to once, "2 of
3 today" changes how you play the next Stage.

One stub gap closed to get here: none of the five element factories in
test/web.test.ts had setAttribute, so the first render threw and any
control reporting state through ARIA was untestable.

WHAT IS NOT VERIFIED: the layout. There is no browser on this box, so
nothing has confirmed the segmented control, the card or the reversed
panel look right on screen. The logic is tested; the appearance is not,
and wants the next play session.

881 tests pass, fourteen new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTaNBL1jVxNqgFdjHkHoo3
This commit is contained in:
Jesse.Markowitz
2026-08-30 18:16:08 -04:00
co-authored by Claude Opus 5
parent 31b942cc38
commit d267f89a82
5 changed files with 576 additions and 80 deletions
+168 -41
View File
@@ -36,7 +36,7 @@ import {
settingsOf,
} from './presets.ts';
import type { GameType, PresetName } from './presets.ts';
import { settingsForm } from './settings-form.ts';
import { rulesListHtml, settingsForm } from './settings-form.ts';
import type { SettingsForm } from './settings-form.ts';
const SAVE_KEY = 'station-master.save.v1';
@@ -62,9 +62,14 @@ type Settings = {
districtMode: 'auto' | 'open' | 'closed';
soundOn: boolean;
zoom: number;
/**
* Is the This Game card open? (TODO #28.) Folded by default: it answers "what did we set that
* to?", which Jesse's own framing says is "not something they're likely to need all the time".
*/
gameCardOpen: boolean;
};
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1 };
const DEFAULT_SETTINGS: Settings = { districtMode: 'auto', soundOn: false, zoom: 1, gameCardOpen: false };
function loadSettings(): Settings {
try {
@@ -80,6 +85,8 @@ function loadSettings(): Settings {
typeof parsed.zoom === 'number' && (ZOOM_LEVELS as readonly number[]).includes(parsed.zoom)
? parsed.zoom
: DEFAULT_SETTINGS.zoom,
gameCardOpen:
typeof parsed.gameCardOpen === 'boolean' ? parsed.gameCardOpen : DEFAULT_SETTINGS.gameCardOpen,
};
} catch {
// A full or disabled localStorage must not take the game down with it — same guard as the save.
@@ -141,6 +148,7 @@ let pendingAt: string | null = null;
* save, for exactly that reason.
*/
let districtMode: 'auto' | 'open' | 'closed' = settings.districtMode;
let gameCardOpen = settings.gameCardOpen;
/**
* Sound, OFF by default until a player asks for it once — then remembered via `settings`.
*
@@ -257,17 +265,88 @@ function renderTurnChart(f: Frame): void {
* tooltip. Written down at all because a playtest note is worthless without it: "scored 4" means one
* thing at 1 Revenue per transit and another at 5.
*/
function renderHouseRules(rules: HouseRules): void {
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = rules.revenue;
function gameCardSummary(f: Frame): string {
const { passengerPerCoach: pax, freightPerLoad: frt, trainPerTransit: trn } = f.houseRules.revenue;
const short = { threeRandom: '3 cards', sixRandom: '6 cards', threeTrackThreeOther: '3+3 cards' };
const el = $('houserules');
el.textContent = `· ${short[rules.startingHand]} · ${pax}/${frt}/${trn}`;
const handWords = STARTING_HAND_LABELS.find((o) => o.value === rules.startingHand)?.label ?? '';
const config = configFromFrame(f);
const type = gameTypeLabel(presetOf(config, f.players.length, f.days), f.mode);
const floor = f.minCombinedRevenue === 0 ? 'no floor' : `floor ${f.minCombinedRevenue}`;
return `${type} · ${f.days} Days · ${floor} · ${short[f.houseRules.startingHand]} · ${pax}/${frt}/${trn}`;
}
/**
* THIS GAME — every setting it was dealt under, in a card rather than along the top line (TODO #28).
*
* Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card
* like Facilities, timetable or blocked. Off on the side, we can give complete information about all
* the game options and not take up valuable real estate at the top of the screen." And on when it is
* read: "To go, 'Oh wait, what did we set that to?' They should be able to look that up, but it does
* not need to be at the top every moment."
*
* NOTHING NEW TRAVELS FOR THIS. `configFromFrame` already turns the Frame's copy of the config back
* into a `GameConfig`, and `rulesListHtml` is the renderer the lobby's join preview and seating
* screen already draw — so what a player agreed to before the deal and what they can read mid-game
* come from ONE implementation and cannot drift. The identity block above it is the half
* `rulesListHtml` has no notion of: which seed or seat this is, and what the game is called.
*
* THE SEED IS SOLITAIRE-ONLY, and that is a redaction rule rather than a layout one: it is never
* sent to a remote client at all, because it would leak every future shuffle and roll
* (`multiplayer.md` §7). `RemoteSession` has no `.seed()` to call. A seated player gets their seat
* instead, which is the thing they actually need to know.
*/
function renderGameCard(f: Frame): void {
const sec = $('gamecard');
sec.classList.toggle('folded', !gameCardOpen);
$('gamecardsummary').textContent = gameCardSummary(f);
const btn = $('gamecardtoggle');
btn.textContent = gameCardOpen ? 'hide' : 'show';
btn.onclick = () => {
gameCardOpen = !gameCardOpen;
saveSettings({ gameCardOpen });
render();
};
if (!gameCardOpen) {
// Folded: the body is display:none anyway, and rebuilding it every frame is work nobody sees.
$('gamecardbody').innerHTML = '';
return;
}
const config = configFromFrame(f);
const players = f.players.length;
const type = presetOf(config, players, f.days);
const who = isLocal(session)
? `<dt>Seed</dt><dd>${esc(String(session.seed()))}</dd>`
: `<dt>Seat</dt><dd>${esc(String(seatLabel(session.seat())))}</dd>`;
const code = gameCode === '' ? '' : `<dt>Game code</dt><dd>${esc(gameCode)}</dd>`;
$('gamecardbody').innerHTML =
`<dl>${who}${code}<dt>Type</dt><dd>${esc(gameTypeLabel(type, f.mode))}</dd></dl>` +
rulesListHtml(config, players, f.days);
}
/**
* THE COLLISION COUNTS, WHICH ARE A LIVE SCORE (TODO #28, Jesse's call 2026-08-30).
*
* They stay on the top line while the limits themselves move into the card, because the two are
* different kinds of thing: `maxCollisionsPerDay` is a setting you agreed to once, and "2 of 3
* today" is a number that changes how you play the next Stage. The Frame has carried both counts
* since v0.7.0 and nothing drew them, so the one victory condition that ends a game EARLY ran
* invisibly — v0.7.9 made it reachable in solitaire too, which is what made this worth having.
*
* `0` means the limit is off (the engine's convention), and a half that is off is left out rather
* than shown as "1 of 0". With both off the chip is empty, and an empty span collapses.
*/
function renderCollisions(f: Frame): void {
const parts: string[] = [];
if (f.maxCollisionsPerDay > 0) parts.push(`${f.collisionsToday} of ${f.maxCollisionsPerDay} today`);
if (f.maxCollisionsTotal > 0) parts.push(`${f.collisionsTotal} of ${f.maxCollisionsTotal} total`);
const el = $('collisions');
el.textContent = parts.length === 0 ? '' : `collisions ${parts.join(' · ')}`;
el.title =
`Opening hand: ${handWords.toLowerCase()}.\n` +
`Passenger revenue per coach: ${pax} (paid on boarding and again on detraining).\n` +
`Freight revenue per load: ${frt} (paid on loading and again on unloading).\n` +
`Train revenue per transit: ${trn} (paid to every player when a train leaves the Division).`;
parts.length === 0
? ''
: 'Reaching either limit ends the game immediately and results in a loss. Both limits are in ' +
'the This Game card; these are the running counts.';
}
/**
@@ -930,21 +1009,32 @@ function renderPresence(f: Frame): void {
*/
function renderGameIdentity(f: Frame): void {
const codeEl = document.getElementById('gamecode');
if (codeEl) codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
const el = document.getElementById('gametype');
if (!el) return;
if (!codeEl) return;
codeEl.textContent = gameCode === '' ? '' : `game ${gameCode}`;
/**
* THE CODE KEEPS ITS TOOLTIP, AND THE TOOLTIP KEEPS THE RULES. The game type and the house rules
* moved into the This Game card (TODO #28), but the code is the thing a player reads out to say
* WHICH game they are in — so it is worth being able to hover it and get the whole answer without
* opening the card.
*
* IN SOLITAIRE THERE IS NO CODE, so the span is empty and this tooltip is unreachable. That is not
* a hole: the card's summary line is always on screen whether the card is folded or not, and it
* opens with the type — "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1". A lone player has no
* game to name to anybody, and the one thing this tooltip adds over that line is the blurb.
*/
const config = configFromFrame(f);
const players = f.players.length;
const type = presetOf(config, players, f.days);
const near = closestPreset(config, players, f.days);
el.textContent = gameTypeLabel(type, f.mode);
el.title =
const blurb =
type === 'custom'
? `A custom game, scored as ${preset(near.name).scoring === 'coop' ? 'Co-op' : 'Competitive'}. ` +
`${near.differing.length} ${near.differing.length === 1 ? 'setting differs' : 'settings differ'} ` +
`from ${preset(near.name).label}.\n\n${rulesSummary(f)}`
: `${preset(type).blurb}\n\n${rulesSummary(f)}`;
`from ${preset(near.name).label}.`
: preset(type).blurb;
codeEl.title =
`${gameTypeLabel(type, f.mode)}. ${blurb}\n\n${rulesSummary(f)}\n\n` +
'The full settings are in the This Game card, at the foot of the right-hand column.';
}
/** The victory conditions in force, spelled out for the header's tooltip. */
@@ -1004,11 +1094,9 @@ function render(): void {
? `${f.revenue} · Day ${f.day} — ${f.extraDays} beyond the timetable`
: `${f.revenue} of ${f.objective.target} · ${left}`;
obj.className = 'pace';
// The seed is never sent to a remote client at all (it would leak every future shuffle and roll,
// `multiplayer.md` §7) — `RemoteSession` has no `.seed()` because there is nothing to return.
$('seed').textContent = isLocal(session) ? String(session.seed()) : `Seat ${seatLabel(session.seat())}`;
renderCollisions(f);
renderGameIdentity(f);
renderHouseRules(f.houseRules);
renderGameCard(f);
// -- division
$('division').innerHTML = divisionSvg(f.division, {
@@ -1288,15 +1376,40 @@ function render(): void {
* WHERE THE GAME BEGAN. In a multiplayer game the bots move the instant the host presses Start, so
* by the time the board paints the log already has several turns in it and nothing says which of
* them are yours to have missed. Only drawn while the whole log is on screen: past sixty lines the
* top of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
* end of the panel is no longer the start of the game, and a marker claiming otherwise would lie.
*/
const startMarker =
!isLocal(session) && allLines.length === shownLines.length
? '<div class="line t-phase">— the game began —</div>'
: '';
/**
* NEWEST FIRST (TODO #23). Jesse, 2026-08-30: "it should be reversed so the top line is the most
* recent and the further down you go, the older the entry."
*
* The panel used to run oldest-first and scroll itself to the bottom, so the thing that had just
* happened was the one line you had to go and find. A glance at the top is now always the most
* recent thing, and `scrollTop = 0` keeps it there as lines arrive rather than chasing the end.
*
* THE PHASE HEADINGS NOW TRAIL THEIR LINES, and that is accepted rather than overlooked. A
* `t-phase` line reads forwards — it introduces what follows it — so reversing puts each one
* BELOW the events it announced. Jesse ruled on it directly: "stage changes will be beneath
* (prior to / older than) the following events. That is OK." Reading down the panel is reading
* backwards in time, and a heading sitting under its own lines is what backwards looks like.
* Grouping by phase and reversing the groups was the alternative, and it was declined as more
* machinery than the complaint needs.
*
* The start marker moves with the same logic: it is the OLDEST thing on screen, so it goes last.
*
* `replays.ts` keeps its own oldest-first log deliberately — it is paired with a frame stepper,
* where "what just happened" is the step you have this moment clicked, so newest-first would
* fight the stepping rather than help it.
*/
log.innerHTML =
startMarker + shownLines.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`).join('');
log.scrollTop = log.scrollHeight;
shownLines
.map((l) => `<div class="line t-${l.tone}">${esc(l.text)}</div>`)
.reverse()
.join('') + startMarker;
log.scrollTop = 0;
/**
* SAY WHEN THE PHASE TURNS OVER.
@@ -1408,21 +1521,35 @@ function renderDistrict(f: Frame): void {
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : '');
// Say what pressing it DOES, not what the panel is currently doing. "auto · folded" reads as a
// status line and was missed entirely; "always show" is an instruction.
const btn = $('districttoggle');
btn.textContent =
districtMode === 'auto'
? (open ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
: districtMode === 'open'
? 'always showing — click for auto-hide'
: 'always hidden — click for auto-hide';
btn.onclick = () => {
// auto -> pin it to the opposite of what auto is doing -> back to auto.
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
saveSettings({ districtMode });
render();
};
/**
* THREE CONTROLS, ONE PER MODE (TODO #16) — not one control that cycles.
*
* The cycle was `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing
* AT THAT MOMENT — `FOCUS_PHASES.has(f.phaseKey)`. So which pin a press reached depended on the
* phase: during Local Operations or Cargo it offered "always hidden", and in every other phase
* "always showing". Getting from one pin to the other meant clicking back to auto, waiting for
* the phase to turn over, and clicking again — which is why it never read as a setting.
*
* The labels still say what pressing DOES rather than what the panel is doing. That was a
* deliberate earlier fix ("auto · folded" read as a status line and was missed entirely) and it
* survives the change; what the CYCLE could not do was be honest about the state it was in, which
* is now carried by `aria-pressed` and the lit button instead of by the label.
*/
/**
* Addressed by id, one lookup each, rather than by querying the container's children. Everything
* else on this page is reached with `$('...')`, and it is what makes the control testable at all:
* the page never writes this markup, so a child query finds nothing in a stubbed DOM and the
* whole control would ship green and unexercised.
*/
for (const mode of ['auto', 'open', 'closed'] as const) {
const b = $(`dm-${mode}`);
b.setAttribute('aria-pressed', String(mode === districtMode));
b.onclick = () => {
districtMode = mode;
saveSettings({ districtMode });
render();
};
}
}
/**
+45 -10
View File
@@ -215,6 +215,17 @@ button.act{display:inline-block}
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
/* A SEGMENTED CONTROL, BECAUSE A CYCLE COULD NOT REACH EVERY STATE (TODO #16).
One button that steps auto -> pinned -> auto can only ever offer the pin OPPOSITE to whatever
auto is doing at that moment, which depends on the phase — so "always hidden" was unreachable
from "always showing" without waiting for the right phase in between. Three controls, one per
mode, and the current one is lit. The buttons still say what they DO rather than what the panel
is doing, which was the earlier fix and is worth keeping. */
.seg{display:inline-flex;margin-left:10px;vertical-align:middle;border-radius:4px;overflow:hidden;
border:1px solid #4a5361}
.seg button.ghost{margin:0;border:0;border-radius:0;border-left:1px solid #4a5361}
.seg button.ghost:first-child{border-left:0}
.seg button.ghost[aria-pressed="true"]{background:#2f3a4a;color:var(--fg);font-weight:600}
#district.folded #grid{display:none}
#district.folded .districtrule{display:none}
/* Said once, quietly, beside the thing it governs — a rule a player needs on their first district
@@ -224,6 +235,16 @@ button.ghost:hover{border-color:#4d6fa8;color:var(--fg)}
.districtrule b{color:#cfd6e0}
#district.folded #districtsummary{display:block;padding:2px 0 1px;font-size:12px}
#districtsummary{display:none}
/* The This Game card folds the same way the district does, and for the same reason: a panel that
vanishes entirely reads as broken, so the summary line is what a folded card still says. */
#gamecard.folded #gamecardbody{display:none}
#gamecard #gamecardsummary{display:none}
#gamecard.folded #gamecardsummary{display:block;padding:2px 0 1px;font-size:12px}
#gamecardbody dl{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:4px 0 10px}
#gamecardbody dt{color:#8b94a3;font-size:11px}
#gamecardbody dd{margin:0;font-size:12px;color:#cfd6e0}
#gamecardbody dd.changed{color:#f0b64a}
#gamecardbody h4{margin:8px 0 0;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:#8b94a3}
/* An action you cannot take yet keeps its place but drops its light — the amber means "press me",
so a disabled button must not wear it. */
#actions button.blocked,#actions button:disabled{background:#232830;border:1px dashed #4a5361;
@@ -816,20 +837,23 @@ ul.blocked li{padding:2px 0}
<!-- The objective, and nothing else: score, target, Days left. The "behind the pace" chip and the
engine's guess at what your score ought to be were noise on the one line that must not wrap. -->
<span id="objective" class="pace">—</span>
<span class="dim">seed <span id="seed">—</span></span>
<!-- THE COLLISION COUNTS, WHICH ARE A LIVE SCORE AND NOT A SETTING (TODO #28). The Frame has
carried `collisionsToday` and `collisionsTotal` since v0.7.0 and nothing on the board drew
them, so the one victory condition that can end a game early was invisible while it ran.
Empty and collapsed when both limits are 0 — a game that cannot end this way should not be
counting towards it. The limits themselves live in the This Game card; this is progress. -->
<span class="dim" id="collisions" title=""></span>
<!-- THE GAME CODE SURVIVES THE LOBBY. It used to end at `Lobby.Start` — the code was never carried
into `LobbyReady` — so a seated player could not say which game they were in, could not match
it against the administrator's Games in Progress list, and could not pass it to a latecomer.
Empty (and collapsed) in solitaire, where there is no code. -->
<span class="dim" id="gamecode" title="The code this game was created under. The administrator's Games in Progress list uses it, and it is how you say which game you mean."></span>
<!-- Co-op, Competitive, Cutthroat or Custom, derived from the config the Frame carries
(`presets.ts`). A Cutthroat game used to look exactly like a Co-op one from the board. -->
<span class="dim" id="gametype" title=""></span>
<!-- WHICH RULES THIS GAME IS BEING PLAYED UNDER. The settings are chosen when the game is dealt
and then never mentioned again, which makes a playtest note ("scored 4") unreadable a week
later: at 0 revenue per transit that is a different game from the same seed at 5. Short enough
to keep the header on one line; the tooltip spells it out. -->
<span class="dim" id="houserules" title="">—</span>
<!-- The seed, the seat, the game type and every house rule MOVED TO THE THIS GAME CARD, 2026-08-30
(TODO #28). Jesse: "we can give complete information about all the game options and not take
up valuable real estate at the top of the screen… it is not something that they're likely to
need all the time." What stays here is what is glanced at every turn — Revenue, the objective,
the collision counts — plus the game code, which is identity rather than settings: it is how
you say WHICH game you are in, out loud, without opening anything. -->
<button id="sound" title="Whistle at the end of each Stage, the crossing bell at the end of each Day, and the conductor when a train is built. Currently synthesised, not recorded.">🔇 muted</button>
<!-- BOARD ZOOM. Applies to the Division map and the Office Area grid alike — both already scroll
horizontally (`#division`, `#grid`) when they run wide, so this only ever needs to resize the
@@ -873,7 +897,7 @@ ul.blocked li{padding:2px 0}
<section id="district">
<h2>Your Office Area
<span class="dim" style="text-transform:none;letter-spacing:0">— hover any card for the full explanation</span>
<button id="districttoggle" class="ghost" title="Auto-hide keeps the district open during Local Operations and Cargo — the phases that change it — and folds it otherwise. Click to pin it open or hidden instead.">auto-hide: on</button>
<span id="districttoggle" class="seg" role="group" aria-label="When to show your Office Area"><button id="dm-auto" class="ghost" type="button" title="Open during Local Operations and Cargo — the phases that change the district — and folded otherwise.">Auto-hide</button><button id="dm-open" class="ghost" type="button" title="Keep the Office Area open in every phase.">Always show</button><button id="dm-closed" class="ghost" type="button" title="Keep the Office Area folded in every phase. The summary line stays, so it reads as folded rather than missing.">Always hide</button></span>
</h2>
<div id="districtsummary" class="dim"></div>
<!-- THE RULE THAT SHAPES EVERY DISTRICT, said once where the district is.
@@ -917,6 +941,17 @@ ul.blocked li{padding:2px 0}
</section>
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
<section><h2>Facilities</h2><div id="facs"></div></section>
<!-- THIS GAME — the settings it was dealt under, off the top line and out of the way (TODO #28).
Last in the column and folded by default because it is looked up, not watched: "Oh wait,
what did we set that to?" The body is `rulesListHtml`, the same renderer the join preview
and the seating screen draw, so what you agreed to in the lobby and what you can read
mid-game cannot drift apart. -->
<section id="gamecard" class="folded"><h2>This Game
<button id="gamecardtoggle" class="ghost" type="button" title="The seed, the seat, the game type and every rule this game was dealt under.">show</button>
</h2>
<div id="gamecardsummary" class="dim"></div>
<div id="gamecardbody"></div>
</section>
</div>
</main>