diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 9165f02..155dccb 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2460,6 +2460,18 @@ input[type="checkbox"] { margin-bottom: var(--sp-2); } +/* The two tiers. Upright this is the column it has always been — the wrappers stack in + the order they are written and gap exactly as the loose children used to, so nothing + about this sheet moves on a phone held the way phones are usually held. They are here + for the one held sideways, where the tiers become columns instead of rows. */ +.seatMenuHand, +.seatMenuKeeping { + display: flex; + flex-direction: column; + gap: var(--sp-2); + min-width: 0; +} + /* What the group is for, said once, so the tiles under it don't each have to say it. Small and unsaturated on purpose: a caption that competes with the actions has made the sheet busier, which is the thing it is here to undo. */ @@ -2473,6 +2485,17 @@ input[type="checkbox"] { } .seatMenuGrid { + /* How narrow a tile in this grid is allowed to get before the row gives up a + column. Only read by the sideways layout, where the number of columns is a + function of the width there happens to be; upright there are two, always, because + a 360px phone has room for exactly two and counting them is not the dealer's job. + It lives here rather than there so the one grid of names can raise it. + + 7.5rem is not what the words need — the longest of these labels, "Small blind", + sets about 75px. It is what makes three columns fit the 397px the housekeeping + side has on a 640x360 phone, with the rest going to the tiles rather than to a + fourth column too narrow to hold anything. */ + --tileMin: 7.5rem; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--sp-2); @@ -5969,6 +5992,141 @@ body.hasActionBar #toastHost { } } +/* ---------- A phone on its side: the seat menu goes beside itself ---------- + + The block above moved the action bar off the bottom edge because a landscape phone is + short of height and swimming in width. The sheets never got the same news. Measured + on the fullest menu the tool can build, twelve-handed with somebody waiting: + + 640x360 545px of content in a 306px sheet — Cash out…, Move to…, Edit seat…, + Small blind, Big blind and Cancel below the fold, Cancel by 224px + 667x375 545 in 319, Cancel 211px under + 844x390 529 in 332, Cancel 183px under + + and in all three the sheet was 420px wide in a window 640 to 844 wide. Two hundred + and forty-seven pixels of width held empty, on the one screen shape whose whole + problem is height. A dealer holding the phone sideways had to scroll a menu to find + the way out of it. + + The sheet takes the width instead. The two tiers this menu has been sorted into since + round 7 — what you press with cards in the air, and housekeeping between hands — stop + being the top and bottom of a column and become the right and left of a row. Which + half goes right is not arbitrary: Fold and All-in sit under the same thumb the rail + above put Fold, Call and Raise under, so the urgent half of the screen is in the same + place whether the dealer is looking at the bar or at a seat. + + Cancel goes under them, in the same column, because the way out belongs at the thumb + too — and because a full-width Cancel under both columns is 60px of the 306 there + are. + + Scoped on height and width rather than orientation: a short desktop window has the + same shortage and the same answer, and a narrow one has neither the width to spend + nor a second column to spend it on. */ +@media (max-height: 560px) and (min-width: 600px) { + #seatMenuSheet { + /* Not the whole window. A sheet with the scrim showing either side of it still + reads as a sheet over a table, and the edge is where a tap dismisses it. */ + max-width: min(100% - var(--sp-5), 46rem); + /* 85vh of a 360px screen is 306px, and this is the one sheet whose content is + fixed by the game rather than by the dealer. The seven points buy 25px, which + is half a row of tiles. */ + max-height: 92vh; + display: grid; + /* Wide enough for Fold and All-in to stay the bars they are, and no wider: the + right-hand column is two words, and every pixel over that comes out of the + left one, where it is a whole extra column of tiles. Measured at 640x360 — + at 11rem the housekeeping grid gets 381px and fits two columns; at 10rem it + gets 397 and fits three, which is a row of tiles saved for 16px. */ + grid-template-columns: minmax(0, 1fr) minmax(8rem, 10rem); + grid-template-areas: + "header header" + "keeping hand" + "keeping cancel"; + align-content: start; + /* Nothing grows into the slack. The left column is the taller of the two, so + stretching would have left Cancel a 74px button on one phone and a 48px one + on the next — the way out should be the same size wherever it is read. */ + align-items: start; + column-gap: var(--sp-3); + } + + /* The move picker is a list of chairs and has no hand tier at all, so there is + nothing to put in the right-hand column and a column reserved for nothing would + narrow the names for no reason. One column, and the chairs spread across it. */ + #seatMenuSheet:not(:has(.seatMenuHand)) { + grid-template-columns: minmax(0, 1fr); + grid-template-areas: + "header" + "keeping" + "cancel"; + } + + /* The tiers are the grid's items, not this box's. It keeps its flex column for + upright phones and stands aside here. */ + #seatMenuActions { + display: contents; + } + + .seatMenuHeader { + grid-area: header; + } + + .seatMenuHand { + grid-area: hand; + } + + .seatMenuKeeping { + grid-area: keeping; + } + + #seatMenuCancelBtn { + grid-area: cancel; + } + + /* As many columns as the width holds, rather than the two a 360px phone has room + for. At 640x360 the left-hand column is ~397px, which is three tiles across: the + five under "This player" go from three rows to two, and the two under "Give this + seat" from two rows to one. + + auto-fill, not auto-fit. auto-fit throws away the tracks nothing sits in, so the + two blinds under "Give this seat" stretched to half the sheet each while the five + above them were a third — two grids in a column, neither lining up with the + other, which reads as two different lists rather than one menu. auto-fill keeps + the tracks, so every tile in this sheet is the same width and sits on the same + column edges however many there happen to be in a group. */ + .seatMenuGrid { + grid-template-columns: repeat(auto-fill, minmax(var(--tileMin), 1fr)); + } + + /* And the chairs are allowed to take two lines here. Twelve names three across is + four rows, which is the 30px that kept Back off the screen on a 640x360 phone; + four across is three rows, and the tile is 136px with 120 for the text, so the + longest of these — "Seat 2 — Mike from work", 115px wrapped — goes onto a second + line. It costs nothing: two lines of 15px sit inside the 44px floor the tile + already has, so the row is the same height it was. */ + .seatMenuGrid.seatMenuGridNames { + --tileMin: 8.25rem; + } + + /* Whose menu this is and what they have in front of them, on one line rather than + two. A second line of header is height every row of tiles below it pays for, and + this header is the part that cannot scroll away. It wraps back to two lines when + the name and the stack together need them — the move picker's sentence about + where the chips go still takes its own line on every phone here. */ + .seatMenuHeading { + display: flex; + align-items: baseline; + flex-wrap: wrap; + column-gap: var(--sp-2); + } + + /* The captions lose their top margin: they are the top of their own column now, + not a break in the middle of a list. */ + .seatMenuKeeping > .seatMenuCaption:first-child { + margin-top: 0; + } +} + /* ---------- Focus is brought to rest where it can be seen ---------- The ring below was the easy half. The hard half is that the bar is fixed over the diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js index f8aa2de..57347f4 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/seatMenu.js @@ -178,9 +178,23 @@ const SeatMenu = { // A row of tiles. Two columns on a phone: a seat action's label is a verb and a // short one, and stacking nine of them full width was most of the height the sheet // was spending. - grid() { + grid(extra) { const el = document.createElement("div"); - el.className = "seatMenuGrid"; + el.className = `seatMenuGrid ${extra || ""}`.trim(); + return el; + }, + + // The two tiers, as boxes rather than as a run of siblings. + // + // Upright they are what they always were: one column, in the order they are + // written. The wrappers exist for the phone on its side, where the sheet has 306px + // of height and 616 of width and the tiers stop being two halves of a column and + // become two columns — see the `max-height: 560px` block in holdem.css. That is + // only expressible if each tier is one box the layout can address; as five loose + // children they can only ever stack. + tier(name) { + const el = document.createElement("div"); + el.className = name; return el; }, @@ -219,7 +233,10 @@ const SeatMenu = { // button already at the thumb end, so it says Back while there is somewhere // to go back to. cancel.textContent = "Back"; - const grid = this.grid(); + const keeping = this.tier("seatMenuKeeping"); + // Chairs, not verbs: "Seat 3 — Alexandra" sets 116px where "Sit out" sets + // about 50, so this grid asks for a wider column than the tiles do. + const grid = this.grid("seatMenuGridNames"); this.destinationsFor(this.seatIndex).forEach(({ player: other, seat }) => { // displayName falls back to "Seat 5" for a chair with nobody in it, // which reads as "Seat 5 — Seat 5". An empty chair says it's empty. @@ -234,7 +251,8 @@ const SeatMenu = { }); grid.appendChild(btn); }); - list.appendChild(grid); + keeping.appendChild(grid); + list.appendChild(keeping); return; } @@ -267,18 +285,24 @@ const SeatMenu = { }); }; + const hand = this.tier("seatMenuHand"); actions.filter(a => a.group === "hand").forEach(action => { const btn = this.button(action.label, `seatMenuBtn ${action.className || ""}`.trim()); wire(btn, action); - list.appendChild(btn); + hand.appendChild(btn); }); + // An empty tier is left out rather than drawn empty — a folded player in a game + // with nobody waiting has nothing in the air to do, and the sideways layout + // reads the tier's absence to know it has one column rather than two. + if (hand.childElementCount) list.appendChild(hand); + const keeping = this.tier("seatMenuKeeping"); this.GROUPS.forEach(({ name, caption }) => { const inGroup = actions.filter(a => a.group === name); // A caption for nothing is worse than no caption. A group empties often — // past the blinds there is nothing to give a seat but the button. if (inGroup.length === 0) return; - list.appendChild(this.caption(caption)); + keeping.appendChild(this.caption(caption)); const grid = this.grid(); inGroup.forEach(action => { const btn = this.button(action.label, @@ -287,8 +311,9 @@ const SeatMenu = { wire(btn, action); grid.appendChild(btn); }); - list.appendChild(grid); + keeping.appendChild(grid); }); + if (keeping.childElementCount) list.appendChild(keeping); }, }; diff --git a/tests/holdem/theSheetOnItsSide.test.js b/tests/holdem/theSheetOnItsSide.test.js new file mode 100644 index 0000000..58f0dd1 --- /dev/null +++ b/tests/holdem/theSheetOnItsSide.test.js @@ -0,0 +1,277 @@ +// The seat menu on a phone held sideways. +// +// The tool learned two rounds ago that a landscape phone is short of height and +// swimming in width: the action bar stops being a bar across the bottom and becomes a +// rail down the right. The sheets were never told. Measured on the fullest menu the +// tool can build — twelve seated, somebody on the waiting list, blinds still the only +// money in: +// +// 640x360 545px of content in a 306px sheet. Cash out…, Move to…, Edit seat…, +// Small blind, Big blind and Cancel all below the fold, Cancel by 224px. +// 667x375 545 in 319, Cancel 211px under. +// 844x390 529 in 332, Cancel 183px under. +// +// And on all three the sheet was 420px wide inside a window 640 to 844 wide. Two +// hundred and forty-seven pixels of width held empty on the one screen shape whose +// whole problem is height, while the dealer scrolled a menu to find the way out of it. +// +// The two tiers this menu has been sorted into since round 7 — what you press with +// cards in the air, and housekeeping between hands — stop being the top and bottom of +// a column and become the right and left of a row. Fold and All-in land under the same +// thumb the rail puts Fold, Call and Raise under, with Cancel beneath them. Measured +// after: 297px in a 298px sheet at 640x360 and at 667x375, 281 in 282 at 844x390. +// Nothing below the fold on any of them, and the move picker's twelve chairs and its +// Back went from four rows and a scroll to three rows and none. +// +// Upright nothing moved at all: 545px before, 545px after, the same two columns. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const { Betting, HoldemState, SeatMenu, makeTable } = require("./load"); + +const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net"); +const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8"); + +const ruleFor = selector => { + const at = CSS.indexOf(`\n${selector} {`); + assert.ok(at >= 0, `there is no \`${selector}\` rule`); + return CSS.slice(at, CSS.indexOf("}", at)); +}; + +// Everything inside one @media block, braces balanced, so a test asks what that block +// says rather than whether a string appears anywhere in six thousand lines. +const SHORT = "@media (max-height: 560px) and (min-width: 600px) {"; +function shortViewportBlock() { + const at = CSS.indexOf(SHORT); + assert.ok(at >= 0, "there is no short-viewport block for the sheets"); + let depth = 0; + for (let i = at + SHORT.length - 1; i < CSS.length; i++) { + if (CSS[i] === "{") depth++; + else if (CSS[i] === "}" && --depth === 0) return CSS.slice(at, i + 1); + } + assert.fail("the short-viewport block is never closed"); +} + +// A document small enough to answer what paint() asks of one. It builds elements, +// appends them and reads their labels back; nothing here lays anything out, which is +// the stylesheet's half of the job and is checked as text below. +function fakeDom() { + const element = tag => ({ + tag, + className: "", + type: "", + textContent: "", + children: [], + appendChild(child) { this.children.push(child); return child; }, + addEventListener() {}, + setAttribute() {}, + focus() {}, + get childElementCount() { return this.children.length; }, + set innerHTML(value) { assert.equal(value, "", "only clearing is modelled"); this.children = []; }, + get innerHTML() { return ""; }, + }); + const byId = {}; + ["seatMenuTitle", "seatMenuSubtitle", "seatMenuActions", "seatMenuCancelBtn", + "seatMenuOverlay"].forEach(id => { byId[id] = element("div"); }); + return { + byId, + doc: { + createElement: element, + getElementById: id => byId[id] || null, + }, + }; +} + +function withDocument(doc, body) { + const had = Object.prototype.hasOwnProperty.call(globalThis, "document"); + const before = globalThis.document; + globalThis.document = doc; + try { return body(); } finally { + if (had) globalThis.document = before; else delete globalThis.document; + } +} + +// The fullest menu the tool can build, aimed at a live seat that isn't the dealer with +// the blinds still the only money in and an override already set. +function painted(seatIndex = 3, { move = false, seated = [0, 1, 2, 3, 4], waiting = true } = {}) { + const state = makeTable({ seatCount: 9, seated, stack: 500, dealerSeatIndex: 0 }); + Betting.beginHand(state); + state.blindOverride = { sbIndex: 1, bbIndex: 2 }; + if (waiting) state.waitingList = [{ id: "w1", name: "Nina" }]; + const { doc, byId } = fakeDom(); + return withDocument(doc, () => { + SeatMenu.state = state; + SeatMenu.hooks = {}; + SeatMenu.seatIndex = seatIndex; + SeatMenu.mode = move ? "move" : null; + SeatMenu.paint(); + return { list: byId.seatMenuActions, cancel: byId.seatMenuCancelBtn, state }; + }); +} + +const classes = el => el.className.split(/\s+/).filter(Boolean); +const labels = el => el.children.map(child => child.textContent); + +// --------------------------------------------------------------------------- + +describe("the two tiers are boxes, not a run of siblings", () => { + // Five loose children can only ever stack. Two boxes can be placed. + test("the sheet's actions are exactly the hand and the housekeeping", () => { + const { list } = painted(); + assert.deepEqual(list.children.map(child => child.className), + ["seatMenuHand", "seatMenuKeeping"]); + }); + + test("the hand tier holds the bars a dealer presses with cards in the air", () => { + const { list } = painted(); + const hand = list.children[0]; + assert.deepEqual(labels(hand), ["Fold", "All-in"]); + hand.children.forEach(btn => { + assert.ok(classes(btn).includes("seatMenuBtn")); + assert.ok(!classes(btn).includes("menuTile"), + "a bar is not a tile — the whole point of the tier is that it is a bar"); + }); + }); + + test("and the housekeeping tier holds its captions and grids in order", () => { + const { list } = painted(); + const keeping = list.children[1]; + assert.deepEqual(keeping.children.map(child => child.className), + ["seatMenuCaption", "seatMenuGrid", "seatMenuCaption", "seatMenuGrid"]); + assert.deepEqual(keeping.children.map(child => child.tag), ["p", "div", "p", "div"]); + }); + + // The sideways layout reads the tier's absence to know it has one column rather + // than two, so an empty tier has to be absent rather than empty. + test("a tier with nothing in it is left out rather than drawn empty", () => { + // A chair nobody is sitting in, with nobody waiting to take it: there is + // nothing at all to do to it while the cards are in the air. + const { list } = painted(7, { seated: [0, 1, 2], waiting: false }); + assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"], + "an empty chair still drew a hand tier with nothing in it"); + }); +}); + +describe("the move picker", () => { + test("its chairs go in the housekeeping tier too, so the sheet has one column", () => { + const { list } = painted(3, { move: true }); + assert.deepEqual(list.children.map(child => child.className), ["seatMenuKeeping"]); + }); + + // "Seat 3 — Alexandra" sets 116px against "Sit out"'s 50, so this grid asks for a + // different column from the one the tiles use. + test("and they ask for a column of their own width", () => { + const { list } = painted(3, { move: true }); + const grid = list.children[0].children[0]; + assert.deepEqual(classes(grid), ["seatMenuGrid", "seatMenuGridNames"]); + }); + + test("the way back out is still the sheet's own foot button", () => { + const { cancel } = painted(3, { move: true }); + assert.equal(cancel.textContent, "Back"); + assert.equal(painted(3).cancel.textContent, "Cancel"); + }); +}); + +describe("upright, nothing moved", () => { + // The wrappers gap exactly as the loose children used to, so the sheet a dealer + // holding a phone the usual way sees is the one it has been since round 7. + test("both tiers are the same column the sheet already was", () => { + const rule = ruleFor(".seatMenuHand,\n.seatMenuKeeping"); + assert.match(rule, /flex-direction:\s*column/); + assert.match(rule, /gap:\s*var\(--sp-2\)/); + assert.match(rule, /min-width:\s*0/); + }); + + test("and the grid is still two columns wherever the screen is not short", () => { + assert.match(ruleFor(".seatMenuGrid"), /grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/); + }); +}); + +describe("what the short screen does instead", () => { + const block = shortViewportBlock(); + + // Height and width, not orientation: a short desktop window has the same shortage + // and the same answer, and a narrow one has neither the width to spend nor a second + // column to spend it on. + test("it is scoped on the shortage rather than on which way up the phone is", () => { + assert.ok(!/orientation/.test(SHORT), "orientation says nothing about how much height there is"); + }); + + test("the sheet takes the width it has been leaving empty", () => { + assert.match(block, /max-width:\s*min\(100% - var\(--sp-5\), 46rem\)/); + }); + + test("the tiers become the two columns, with the way out under the thumb", () => { + assert.match(block, /"header\s+header"/); + assert.match(block, /"keeping hand"/); + assert.match(block, /"keeping cancel"/); + }); + + test("which means the box that held them stands aside", () => { + const at = block.indexOf("#seatMenuActions {"); + assert.ok(at >= 0, "#seatMenuActions is not addressed here"); + assert.match(block.slice(at, block.indexOf("}", at)), /display:\s*contents/); + }); + + // A column reserved for nothing would narrow the names for no reason. + test("a sheet with no hand tier is one column, not two", () => { + const at = block.indexOf("#seatMenuSheet:not(:has(.seatMenuHand))"); + assert.ok(at >= 0, "the move picker still reserves a column for a tier it hasn't got"); + const rule = block.slice(at, block.indexOf("}", at)); + assert.match(rule, /grid-template-columns:\s*minmax\(0, 1fr\)/); + assert.match(rule, /"keeping"/); + }); + + test("the tiles spread into as many columns as the width holds", () => { + assert.match(block, /repeat\(auto-fill, minmax\(var\(--tileMin\), 1fr\)\)/); + }); + + // auto-fit throws away the tracks nothing sits in, so a group of two stretched to + // half the sheet each while the group above it was a third — two grids in a column + // lining up with nothing. + test("and they line up with each other, group to group", () => { + // The comment above the rule says the word it is here to rule out, so the code + // is read with the comments stripped out of it. + const code = block.replace(/\/\*[\s\S]*?\*\//g, ""); + assert.ok(!/auto-fit/.test(code), + "auto-fit collapses the empty tracks and the grids stop sharing column edges"); + }); + + // The left column is the taller of the two, and stretching left Cancel a 74px + // button on one phone and a 48px one on the next. + test("nothing grows into the slack the taller column leaves", () => { + assert.match(block, /align-items:\s*start/); + }); +}); + +describe("the numbers the columns are chosen from", () => { + // The floor is what the longest of these labels needs, not a round figure that + // happened to work on the phone it was measured on. + test("a tile's narrowest is a token the grid of names can raise", () => { + assert.match(ruleFor(".seatMenuGrid"), /--tileMin:\s*7\.5rem/); + const block = shortViewportBlock(); + const at = block.indexOf(".seatMenuGrid.seatMenuGridNames"); + assert.ok(at >= 0, "the chairs never ask for more room than a verb"); + assert.match(block.slice(at, block.indexOf("}", at)), /--tileMin:\s*8\.25rem/); + }); + + // Two words. Every pixel over that comes out of the left column, where it is a + // whole extra column of tiles. + test("the hand column is sized for Fold and All-in and no wider", () => { + const block = shortViewportBlock(); + assert.match(block, /grid-template-columns:\s*minmax\(0, 1fr\) minmax\(8rem, 10rem\)/); + }); + + test("what was measured is written beside the rules", () => { + const block = shortViewportBlock(); + const note = CSS.slice(CSS.indexOf("A phone on its side: the seat menu goes beside itself"), + CSS.indexOf(SHORT)); + assert.match(note, /640x360/); + assert.match(note, /545px/); + assert.match(note, /224px/); + assert.match(block, /10rem it\s+gets 397 and fits three/); + }); +});