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();
};
}
}
/**