diff --git a/CHANGELOG.md b/CHANGELOG.md index de8dfab..77ff1d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -254,6 +254,93 @@ and the line reads "resumed" when it is. Both halves are pinned, including that a brand-new game does *not* claim to be a resume — an announcement that fires either way says nothing. +### The screen does what you tell it — three items off `TODO.md` + +Reviewed with Jesse 2026-08-30 out of the Display section. A fourth, #17 (hiding the Division map), +was **declined** in the same pass: its premise died with Gitea#18 and nobody had connected the two. +The map used to grow a row at a time and was worth folding away at three or four seats; a single row +is `boardH = PAD * 2 + CH + 30` — 150px, fixed, at every seat count — and that is not worth a +control, three states and a persisted preference. + +#### The Office Area's auto-hide could not reach every state (#16) + +One button cycling `auto -> pinned -> auto`, where the pin it reached was +`open ? 'closed' : 'open'` — and `open` is what auto is doing **at that moment**, +`FOCUS_PHASES.has(f.phaseKey)`. So which pin a press offered depended on the phase: "always hidden" +during Local Operations and Cargo, "always showing" everywhere else. Getting from one pin to the +other meant clicking back to auto, waiting for the phase to turn over, and clicking again. That is +why it never read as a setting — it was not one. + +Three controls now, one per mode, and every mode is one press from every other. The labels still say +what pressing **does** rather than what the panel is doing, which was an earlier deliberate fix; what +the cycle could not do was report the state it was in, and `aria-pressed` on the lit button carries +that instead of the label. + +**Addressed by id (`#dm-auto`/`#dm-open`/`#dm-closed`) rather than by querying the container's +children**, and that is a testability decision rather than a style one. The page never writes this +markup, so a child query finds nothing in the stubbed DOM the web suite runs against — the control +would have shipped green and completely unexercised. The test that now pins it presses +always-show → always-hide directly, which is precisely the transition the cycle could not make. + +#### The history reads newest first (#23) + +Jesse: "it should be reversed so the top line is the most recent and the further down you go, the +older the entry." The panel ran oldest-first and scrolled itself to the bottom, so the thing that had +just happened was the one line you had to go and find. + +**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 under its own lines is what backwards looks like. Grouping by phase and reversing the groups +was the alternative and was declined as more machinery than the complaint needs. The +"— the game began —" marker moves by 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. + +#### The settings moved into a card, and the collision counts came out of hiding (#28) + +Jesse, 2026-08-23: "the game-specific information in the very top line should probably be a card like +Facilities, timetable or blocked… 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?'" + +The top line carried six things and now carries four: **Revenue, the objective, the collision counts, +and the game code**. The seed or seat, the game type and the abbreviated house rules moved into a +**This Game** card at the foot of the right-hand column, folded by default and persisted with the +other display preferences. + +**Nothing new travels for it.** `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 read mid-game come from one +implementation and cannot drift. The card adds only the half `rulesListHtml` has no notion of: which +seed or seat this is, and what the game is called. + +**The collision counts are new on the board, not merely 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 — which v0.7.9 made reachable in solitaire too, and +that is what made them worth having. They stay on the top line while the limits go in the card, +because the two are different kinds of thing: a limit is a setting agreed to once, "2 of 3 today" is +a number that changes how you play the next Stage. A limit of `0` is off, and an off half is left out +rather than shown as "1 of 0"; with both off the chip is empty and collapses. + +In solitaire the game-code span is empty, so its tooltip — which still carries the type, the blurb and +the victory conditions — is unreachable. Not a hole: the card's summary line is on screen whether the +card is open or shut and opens with the type, "Solitaire · 5 Days · floor 15 · 3 cards · 4/2/1". + +**One gap in the test stubs was closed to get here.** None of the five element factories in the web +suite had `setAttribute`, so the first render threw `b.setAttribute is not a function` — meaning any +control that reports its state through ARIA could not be tested at all. They carry an attribute bag +now, and the segmented control's test reads `aria-pressed` through it. + +**What is verified and what is not.** The logic is covered by three new tests and the two that used to +read `#houserules`/`#gametype` were rewritten to open the card rather than deleted. **The layout is +not verified** — there is no browser on this box, so nothing has confirmed the segmented control, the +card, or the reversed panel actually look right on screen. That wants a play session. + ### Three wording and layout fixes - **The collision entries** on all three screens now read "The game ends immediately and results in @@ -268,7 +355,7 @@ announcement that fires either way says nothing. cost, not a danger, and red would outrank the actual rules above it. The buttons say **Continue Existing Saved Game** and **Deal New Game** rather than "Continue saved game" and "Deal". -878 tests pass, eleven of them new; one existing test asserted the opposite of the collision ruling +881 tests pass, fourteen of them new; one existing test asserted the opposite of the collision ruling above and says so where it was reversed. --- diff --git a/TODO.md b/TODO.md index d67a907..d323877 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,14 @@ Display were still open `- [ ]` although Next already recorded them as supersede shipped in v0.7.9. **An item closed in one place and left open in another is worse than no entry at all**, because it reads as live work. So: Next is now grouped by what each item is WAITING ON, the closed entries moved to a "Settled" block at the end of it, and the five dead Division-map drawing -items collapsed into one that keeps only the two ideas which outlived them. 65 open items became 57. +items collapsed into one that keeps only the two ideas which outlived them. + +**Gitea#18 turned out to have answered a sixth item nobody had connected to it** — #17, hiding the +Division map — found by reviewing the queue with Jesse rather than by reading the file. Worth the +general lesson: **when one change supersedes a batch of items, the batch is rarely the whole list.** +#17 survived because it reads as a control question while the other five read as drawing questions, +so it was not in the group that got closed with them. 65 open items became 56, and 53 once #16, #23 +and #28 shipped the same day. Earlier passes, kept: **Display** was split out of Other on 2026-08-22, when a session at the board produced seven items about the screen rather than the rules. Reorganized 2026-08-20 from a flat list. @@ -93,25 +100,39 @@ ever meeting them. ### The screen — v0.8.0 candidates -All six were raised by Jesse from play and all six live in **Display** below, where the reasoning is. -Listed here in rough increasing cost. Two of them entangle with Gitea#20; see the note under each. +Raised by Jesse from play; the reasoning for each lives in **Display** below. **Three shipped +2026-08-30 in v0.7.9** — 28, 16 and 23, each with the decision that settled it recorded under it. +**One was declined** — 17. **Two remain, and both entangle with Gitea#20** rather than being display +work at all; see 15 and 18, which is why v0.8.0 is not finished by this batch. -28. **The game's settings belong in a card, not along the top line** — and show ALL of them, not the - four that fit. **Cheapest of the six**: nothing new has to be sent (`Frame` gained `mode` and - `optionalRules` in v0.7.0) and the renderer already exists — `rulesListHtml` (`settings-form.ts`) - draws exactly this list for the join preview and the seating screen. +28. ~~**The game's settings belong in a card, not along the top line.**~~ — done 2026-08-30 in + v0.7.9. A **This Game** card at the foot of the right-hand column, folded by default; the top + line keeps Revenue, the objective, the collision counts and the game code (Jesse's call). It + turned out cheaper than this entry said: `configFromFrame` already existed and `main.ts` already + called it three times, so `rulesListHtml(configFromFrame(f), …)` needed no refactor at all. + **The collision counts are new on the board rather than moved** — the Frame has carried them + since v0.7.0 and nothing drew them. -16. **Three explicit display options for the Office map** — always hidden, always on, auto-hide. All - three modes already exist (`districtMode`, persisted); only the BUTTON is a cycle, and it cannot - reach every one of them. +16. ~~**Three explicit display options for the Office map.**~~ — done 2026-08-30 in v0.7.9. A + segmented control, one button per mode, replacing the cycle. **The buttons are addressed by id + rather than queried off the container**, because the web suite's stub DOM only models markup the + page writes — a child query finds nothing there, and the control would have shipped green and + unexercised. -17. **The same three options for the Division map**, which today cannot be hidden at all. `auto` - there means something different and useful — hide it now, bring it back at the end of the phase — - so it is a one-shot with an expiry rather than a standing rule, and probably should not share an - implementation with #16. +17. ~~**The same three options for the Division map.**~~ — **DECLINED 2026-08-30 (Jesse): "we are + not going to hide the division map anymore."** Its premise died with Gitea#18 and nobody noticed, + because it reads as a control question rather than a drawing one and so was not on the list of + five that were closed with it. Reasoning in Display below. -23. **History: newest at the top?** Reversing is nearly free; the cap (`slice(-60)`) and the forward- - reading phase headings are not. Carries the timestamps question from #14. **Weaker if #15 lands.** +23. ~~**History: newest at the top.**~~ — done 2026-08-30 in v0.7.9. Flat reversal, `scrollTop = 0`, + the start marker last. **The phase headings now trail their lines and Jesse ruled that acceptable** + — "stage changes will be beneath (prior to / older than) the following events. That is OK." + Grouping by phase and reversing the groups was declined as more machinery than the complaint + needs. `replays.ts` deliberately keeps its oldest-first log: it is paired with a frame stepper, + where newest-first would fight the stepping. + + **Two things this did NOT do**, both still open: the `slice(-60)` cap still ends scrollback at + sixty lines whichever way the panel runs, and the timestamps question from #14 is untouched. 15. **INVESTIGATE: a "most recent action" line under the status block.** The text already exists and is already correct — this is placement, not content. **Overlaps #13 and should be decided with @@ -1430,8 +1451,22 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el your own last turn ended) answers that better. They may both be right, and one may make the other pointless; deciding them separately risks building both and needing neither. -- [ ] **INVESTIGATE: three explicit display options for the Office map — always hidden, always on, - auto-hide.** Raised by Jesse 2026-08-22. +- [x] **~~INVESTIGATE: three explicit display options for the Office map — always hidden, always on, + auto-hide.~~** — done 2026-08-30 in v0.7.9 (`Next` #16). A segmented control, one button per + mode, `aria-pressed` on the lit one. The investigation below was right about the cause and + right about the fix; what it did not foresee is the testing trap. + + **THE BUTTONS ARE ADDRESSED BY ID (`#dm-auto` / `#dm-open` / `#dm-closed`), NOT QUERIED OFF + THE CONTAINER — and that is worth knowing before the next control like this.** The web suite + runs against a stub DOM whose `querySelectorAll` reads the element's own `innerHTML`, so it + only ever sees markup THE PAGE WROTE. This control lives in `play.html`, so a child query + returns nothing there, the wiring loop does nothing, and the whole control ships green and + completely unexercised. Addressing by id also puts every button under the "asks the page for + no element its page lacks" check. + + **A second gap closed on the way:** none of the five element factories in `test/web.test.ts` + had `setAttribute`, so the first render threw. Any control reporting its state through ARIA + was untestable until they got an attribute bag. Original note below. **All three modes already exist.** `districtMode` is `'auto' | 'open' | 'closed'`, persisted to `localStorage` with the sound and zoom settings (`main.ts`). Nothing needs adding to the model. @@ -1448,8 +1483,34 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el ("always showing — click for auto-hide") rather than what the panel is currently doing, which was a deliberate fix and should survive whatever replaces the cycle. -- [ ] **INVESTIGATE: the same three options for the Division map, where `auto` means something - different.** Raised by Jesse 2026-08-22. +- [x] **~~INVESTIGATE: the same three options for the Division map, where `auto` means something + different.~~** — **DECLINED 2026-08-30.** Jesse: "we are not going to hide the division map + anymore." + + **Gitea#18 answered this one too, and it was missed.** It was raised 2026-08-22, four days + before that issue closed, and it is really a sixth member of the drawing pass settled below — + it just reads as a control question rather than a drawing one, so it stayed open when the other + five were closed. **The reason to fold the map away was that it grew.** A horseshoe of three or + a square of four was tall enough to push the board off the screen, which is what made "hide it + while I switch" worth a control. A single row is `boardH = PAD * 2 + CH + 30` — **150px, fixed, + at every seat count** (`board-svg.ts`) — and a 150px strip is not worth a control, three states + and a persisted preference. It also scrolls and zooms rather than reflowing, so it costs the + same at one seat as at four. + + **What it would have cost, recorded because the design was worked out before it was declined:** + the Division's `auto` is not a third MODE at all. It is two sticky pins plus a transient — "hide + for now", expiring when `f.phaseKey` moves — so it needed `divisionMode: 'open' | 'closed'` + persisted beside `districtMode`, plus a `hiddenDuringPhase` that clears itself. Deliberately not + shared with `districtMode`, whose `auto` is a standing rule keyed on `FOCUS_PHASES`. **And a + folded Division needed a summary line written for it** (Jesse's call, 2026-08-30, before the + decline): `.folded` on `#district` hides `#grid` and `.districtrule` but keeps + `#districtsummary`, so the house idiom is that a folded panel still says something, and the + Division has no such line today. + + **If it ever comes back, this is what to check first:** whether the map has started growing + again. That, not the control, is the thing that would justify it. + + Original note below. **Today it cannot be hidden at all.** `#division` is a plain `
` in an unnamed `
` in `play.html` with no toggle and no fold rule — `#district` has `.folded` styling and a button, @@ -1491,7 +1552,28 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el will want it off. Whatever this becomes probably needs a speed control, or to scale with whether anything actually happened in the phase. -- [ ] **INVESTIGATE: the game's settings belong in a card, not along the top line.** Raised by Jesse +- [x] **~~INVESTIGATE: the game's settings belong in a card, not along the top line.~~** — done + 2026-08-30 in v0.7.9 (`Next` #28). A **This Game** card at the foot of the right-hand column, + folded by default and persisted with the other display preferences. + + **The open questions below were answered by Jesse, 2026-08-30.** Which of the six stay on the + top line: Revenue, the objective and the game code — *plus the collision counts*, which were + not on it at all. Whether the card folds: yes, like `#district`, with a summary line that + survives folding. Where the collision counts belong: the top line, because a limit is a setting + agreed to once and "2 of 3 today" is a number that changes how you play the next Stage. + + **Cheaper than this entry estimated.** It says the renderer already exists; what it misses is + that `configFromFrame` also already exists, is exported, and `main.ts` already called it three + times — so `rulesListHtml(configFromFrame(f), f.players.length, f.days)` needed no refactor + whatsoever. The card is markup plus one call. + + **The collision counts were the real find.** The Frame has carried `collisionsToday` and + `collisionsTotal` since v0.7.0 and NOTHING ON THE BOARD DREW THEM, so the one victory condition + that ends a game early ran invisibly — the same shape as #43's `actingPlayer`, and the second + time in one release that the Frame had the answer and the view never asked. Original note + below. + + **The original note.** Raised by Jesse 2026-08-23, playing the v0.7.0 build: "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 @@ -1574,7 +1656,24 @@ What is on the screen and where. Split out of Other 2026-08-22; the rules are el must not go. Jesse called it definitively superseded, 2026-08-26. -- [ ] **INVESTIGATE: history newest-at-the-top, and timestamps on it.** Raised by Jesse 2026-08-22. +- [x] **~~INVESTIGATE: history newest-at-the-top~~, and timestamps on it.** — the ORDER is done + 2026-08-30 in v0.7.9 (`Next` #23); **the timestamps half is still open and still #14.** + + Flat reversal, `scrollTop = 0`, the start marker last. Jesse ruled directly on the phase + headings, which the note below calls the thing that is not free: they now trail the lines they + announce, and "stage changes will be beneath (prior to / older than) the following events. + That is OK." Reading down the panel is reading backwards in time. Grouping by phase and + reversing the groups was offered and declined as more machinery than the complaint needs. + + **The `slice(-60)` cap is untouched and is the other half of this note** — scrollback still + ends at sixty lines whichever way the panel runs. Worth reopening on its own if anyone ever + tries to scroll back and cannot. + + `replays.ts` deliberately keeps its oldest-first log: 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. Original note below. + + **Raised by Jesse 2026-08-22.** **Timestamps** are item 14 above — the same question, and it should be answered once. Whether the history DISPLAYS a time is downstream of whether one is recorded at all, and of the sidecar diff --git a/src/web/main.ts b/src/web/main.ts index 07f3196..ba4498b 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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) + ? `
Seed
${esc(String(session.seed()))}
` + : `
Seat
${esc(String(seatLabel(session.seat())))}
`; + const code = gameCode === '' ? '' : `
Game code
${esc(gameCode)}
`; + $('gamecardbody').innerHTML = + `
${who}${code}
Type
${esc(gameTypeLabel(type, f.mode))}
` + + 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 ? '
— the game began —
' : ''; + /** + * 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) => `
${esc(l.text)}
`).join(''); - log.scrollTop = log.scrollHeight; + shownLines + .map((l) => `
${esc(l.text)}
`) + .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(); + }; + } } /** diff --git a/src/web/play.html b/src/web/play.html index a18cd7a..e208cee 100644 --- a/src/web/play.html +++ b/src/web/play.html @@ -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} — - seed — + + - - - - — + +

This Game + +

+
+
+
diff --git a/test/web.test.ts b/test/web.test.ts index 28a8d02..920d980 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -1462,7 +1462,17 @@ describe('the static build', () => { // opens it with `showModal`. Without these the page throws before it draws anything — which // is a page that never starts, exactly what this stub exists to catch. const listeners = new Map void)[]>(); + /** + * ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the + * Office Area's segmented control (TODO #16): without it every render threw + * `b.setAttribute is not a function`, so a stub that cannot model an attribute means any + * accessible control ships green and unexercised. + */ + const attrs: Record = {}; const node: Record = { + attrs, + setAttribute: (k: string, v: string) => void (attrs[k] = v), + getAttribute: (k: string) => attrs[k] ?? null, textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0, title: '', returnValue: '', open: false, addEventListener: (type: string, fn: (e?: unknown) => void) => @@ -1707,7 +1717,17 @@ describe('the static build', () => { let html = ''; const listeners = new Map void)[]>(); const ownClasses = new Set(); + /** + * ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the + * Office Area's segmented control (TODO #16): without it every render threw + * `b.setAttribute is not a function`, so a stub that cannot model an attribute means any + * accessible control ships green and unexercised. + */ + const attrs: Record = {}; const node: Record = { + attrs, + setAttribute: (k: string, v: string) => void (attrs[k] = v), + getAttribute: (k: string) => attrs[k] ?? null, textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, title: '', returnValue: '', open: false, addEventListener: (type: string, fn: (e?: unknown) => void) => @@ -1812,7 +1832,17 @@ describe('the static build', () => { it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => { const els = new Map>(); const make = (): Record => { + /** + * ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the + * Office Area's segmented control (TODO #16): without it every render threw + * `b.setAttribute is not a function`, so a stub that cannot model an attribute means any + * accessible control ships green and unexercised. + */ + const attrs: Record = {}; const node: Record = { + attrs, + setAttribute: (k: string, v: string) => void (attrs[k] = v), + getAttribute: (k: string) => attrs[k] ?? null, textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, title: '', returnValue: '', open: false, addEventListener: () => {}, @@ -3792,7 +3822,17 @@ describe('the lobby screen', () => { const make = (id: string): Record => { const classes = new Set(); let html = ''; + /** + * ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the + * Office Area's segmented control (TODO #16): without it every render threw + * `b.setAttribute is not a function`, so a stub that cannot model an attribute means any + * accessible control ships green and unexercised. + */ + const attrs: Record = {}; const node: Record = { + attrs, + setAttribute: (k: string, v: string) => void (attrs[k] = v), + getAttribute: (k: string) => attrs[k] ?? null, id, value: '', textContent: '', title: '', placeholder: '', className: '', style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, checked: false, disabled: false, hidden: false, open: false, returnValue: '', @@ -4165,7 +4205,17 @@ describe('the solitaire setup screen', () => { const make = (id: string): Record => { const listeners = new Map void)[]>(); let html = ''; + /** + * ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the + * Office Area's segmented control (TODO #16): without it every render threw + * `b.setAttribute is not a function`, so a stub that cannot model an attribute means any + * accessible control ships green and unexercised. + */ + const attrs: Record = {}; const node: Record = { + attrs, + setAttribute: (k: string, v: string) => void (attrs[k] = v), + getAttribute: (k: string) => attrs[k] ?? null, id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '', style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0, checked: false, disabled: false, hidden: false, className: '', @@ -4403,18 +4453,116 @@ describe('the solitaire setup screen', () => { assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all'); }); - it('deals the game the URL describes, and says so in the header', async () => { + it('deals the game the URL describes, and says so in the This Game card', async () => { // The other half of the round trip: the dialog wrote those parameters, and this is the page // reading them back. Without this the two halves can drift and each still pass its own test. + // + // MOVED OFF THE HEADER 2026-08-30 (TODO #28). It used to read `#houserules`, an abbreviation + // along the top line. The card is the whole of it now, so this checks both halves: the summary + // line a folded card still shows, and the full list behind it. const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1'); - assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/); - assert.match(String(els.get('houserules')!['title']), /six random cards/i); + assert.match( + String(els.get('gamecardsummary')!['textContent']), + /6 cards · 4\/2\/1/, + 'the folded card does not say what this game was dealt under', + ); + + // Folded, the body is not built at all — there is no point rendering what display:none hides. + assert.equal(String(els.get('gamecardbody')!['innerHTML']), '', 'a folded card built its body anyway'); + + (els.get('gamecardtoggle')!['onclick'] as () => void)(); + const body = String(els.get('gamecardbody')!['innerHTML']); + assert.match(body, /Starting hand<\/dt>]*>six random/, 'the opened card does not name the opening hand'); + assert.match(body, /Passenger per coach<\/dt>]*>4/, 'the opened card does not carry the revenue rates'); + assert.match(body, /Seed<\/dt>]*>430/, 'the opened card does not say which seed this is'); }); - it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => { + it('reaches every Office Area mode directly, including the one the cycle could not', async () => { + /** + * TODO #16, AND THIS IS THE DEFECT ITSELF. The control used to be ONE button stepping + * `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing at that + * moment — `FOCUS_PHASES.has(f.phaseKey)`. So the pin a press reached depended on the phase, + * and going from "always showing" to "always hidden" meant clicking back to auto, waiting for + * the phase to turn over, and clicking again. Three controls, one per mode, and every mode is + * one press away from every other. + */ const { els } = await load('?seed=430'); - assert.equal(els.get('gametype')!['textContent'], 'Solitaire'); - assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions'); + const press = (m: string): void => (els.get(`dm-${m}`)!['onclick'] as () => void)(); + const pressed = (m: string): string => + String((els.get(`dm-${m}`)!['attrs'] as Record)['aria-pressed']); + // Which mode is LIT is what this test is about. Whether the panel then folds is `renderDistrict`'s + // existing behaviour, covered where the fold rules are — this suite's stub has a no-op classList. + + assert.equal(pressed('auto'), 'true', 'the page did not start on auto'); + + press('open'); + assert.equal(pressed('open'), 'true', 'pressing "always show" did not light it'); + assert.equal(pressed('auto'), 'false', 'auto stayed lit after pinning the panel open'); + + // THE PRESS THE CYCLE COULD NOT MAKE: straight from one pin to the other, in one click, with + // no trip through auto and no waiting for a phase. + press('closed'); + assert.equal(pressed('closed'), 'true', 'could not go from always-show to always-hide'); + assert.equal(pressed('open'), 'false', 'two modes were lit at once'); + + press('auto'); + assert.equal(pressed('auto'), 'true', 'could not get back to auto'); + assert.equal(pressed('closed'), 'false', 'the previous mode stayed lit'); + }); + + it('puts the newest line at the top of the history', async () => { + /** + * 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 what had just happened was the line you had to go and find. + */ + const { els } = await load('?seed=430&hand=sixRandom'); + const lines = [...String(els.get('log')!['innerHTML']).matchAll(/
([^<]*)<\/div>/g)].map( + (m) => m[1]!, + ); + assert.ok(lines.length > 1, 'the log was too short to have an order at all'); + + // The deal is the OLDEST thing that has happened, so it must now be LAST rather than first. + const dealtAt = lines.findIndex((l) => /dealt|begins|opening/i.test(l)); + assert.notEqual(dealtAt, -1, 'nothing in the log looks like the opening line'); + assert.equal( + dealtAt, + lines.length - 1, + `the opening line is at ${dealtAt} of ${lines.length} — the log is still oldest-first`, + ); + assert.equal(els.get('log')!['scrollTop'], 0, 'the panel still scrolls itself to the bottom'); + }); + + it('shows the collision counts only while a collision limit is switched on', async () => { + /** + * TODO #28. The limits moved into the This Game card; the running counts stay on the top line, + * because a setting agreed to once and a number that changes how you play the next Stage are + * different kinds of thing. `0` means the limit is off, and a half that is off is left out + * rather than shown as "1 of 0". + */ + const on = await load('?seed=430&colday=3&coltotal=5'); + assert.match(String(on.els.get('collisions')!['textContent']), /0 of 3 today · 0 of 5 total/); + + const dayOnly = await load('?seed=430&colday=3&coltotal=0'); + const text = String(dayOnly.els.get('collisions')!['textContent']); + assert.match(text, /0 of 3 today/); + assert.doesNotMatch(text, /total/, 'a switched-off limit was counted towards anyway'); + + const off = await load('?seed=430&colday=0&coltotal=0'); + assert.equal(off.els.get('collisions')!['textContent'], '', 'a game that cannot end this way still counted'); + }); + + it('names the game type in the card, so a Cutthroat game does not look like a Co-op one', async () => { + const { els } = await load('?seed=430'); + // The summary line carries it whether the card is open or shut — which is what replaces the + // header's `#gametype`, and is why solitaire losing the (empty) game-code tooltip costs nothing. + assert.match(String(els.get('gamecardsummary')!['textContent']), /^Solitaire · 5 Days/); + + (els.get('gamecardtoggle')!['onclick'] as () => void)(); + const body = String(els.get('gamecardbody')!['innerHTML']); + assert.match(body, /Type<\/dt>]*>Solitaire/, 'the opened card does not name the game type'); + assert.match(body, /Combined Revenue floor<\/dt>]*>15/, 'the card does not carry the victory conditions'); + assert.match(body, /Days<\/dt>]*>5/, 'the card does not say how long the game is'); }); it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {