diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index a1633d1..75728c2 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -3200,12 +3200,98 @@ body.hasActionBar #holdemApp { font-size: var(--fs-sm); } -.actionWho { +/* Who is being waited on, and what they have behind. + + It already said `text-overflow: ellipsis`, and the ellipsis never once appeared. A + flex item's `min-width` is `auto`, which means "never smaller than your own content", + so instead of giving way this pushed the clock and the undo pill onto a third line — + and the row is in a bar fixed to the bottom, so a third line comes straight off the + felt. Measured at 360x640 mid-street: "Dee · $600 behind" left the bar 151px tall, + "Christopher · $1,200 behind" made it 173, and the table resized between one player + and the next. Whose turn it was decided how big the felt was. + + `min-width: 0` is what lets the ellipsis do the job it was already written for. What + gives way is the name — see .actionWhoName. */ +/* The second line of the status block, and it is a line because it is written as one. + + `flex: 1 1 100%` is what makes it take a line of its own inside a wrapping parent — + which is the whole point. Flex settles its line breaks before it decides what to + shrink, so a long name in the wrapping row did not give way, it pushed the clock and + the undo pill onto a third line. Here the three of them are on a row that can shrink + instead of a row that can wrap. */ +.actionActing { + /* Only what it holds, when all it holds is the undo pill. Claiming a line + unconditionally cost one in the state that can least afford it: while a board is + running out there is nobody to act, so this row is the pill alone — it used to sit + up beside "All-in — running out the board" and now it wanted a line of its own. + That is 22px off the felt on the twelve-handed 360x640 table where the pucks are + already at their 44px floor, and the sweep caught it as a seat's "ALL IN" going + back under the blind badge next door. */ + flex: 0 1 auto; + display: flex; + /* The one that matters. Left wrapping, this row reproduced the fault it was made to + fix one level down: the undo pill is a fixed 9rem, so a long name pushed it onto a + line of its own and the block went back to being 84px for some players and 66 for + others. Not wrapping is what forces the shrink, and the shrink is what the name + is set up to absorb. */ + flex-wrap: nowrap; + align-items: baseline; + gap: var(--sp-1) var(--sp-2); + min-width: 0; +} + +/* And a line of its own the moment there is somebody being waited on, which is the case + this row exists for — the name, what they have behind, the clock, and the way back. + `flex: 1 1 100%` is what takes the line; nowrap above is what makes the name give way + inside it instead of pushing the pill onto another one. */ +.actionActing:has(.actionWho) { + flex: 1 1 100%; +} + +/* On a phone the pill gives some of its width back to the name beside it. + 10rem is 160px, and at 360px the acting row has 319 to spend: the pill took half, the + clock took 50, and what was left over went to the player being waited on — measured, + "Christopher" was cut to 23px of its 75, which reads "Ch…". A third of the row is + enough for a safety net when the other thing on the line is the question the dealer is + actually answering. + + Still one width at a time, which is the property that matters: the pill names the last + action, so its label changes on every tap, and it must not resize under a thumb. + A media query keeps that true. `min(10rem, 34%)` does not — the percentage resolves + against this row, the row is sized to its contents, and the two chase each other: the + sweep caught the pill drawn 38px wide at ten viewports. */ +@media (max-width: 480px) { + .actionUndo { + width: 7rem; + } +} + +/* Pushed to the far end of its own line, where it was before — the difference is that + it is now the end of a line this block chose rather than wherever the text ran out. */ +.actionActing .actionUndo { margin-left: auto; +} + +.actionWho { color: var(--text-muted); + display: flex; + align-items: baseline; + min-width: 0; + white-space: nowrap; +} + +/* The half that shrinks. */ +.actionWhoName { overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; + min-width: 0; +} + +/* And the half that doesn't. The same rule the seat pucks keep: a number read wrong is + a worse failure than a name read short, so the stack keeps every digit and the name + is what loses characters. */ +.actionWhoStack { + flex: none; } /* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does @@ -5248,6 +5334,13 @@ body.hasActionBar #toastHost { min-height: 0; } + /* The rail is 190px wide, so its acting row is a stack rather than a line — every + one of these readings is on its own line here regardless, and squeezing a name + into a fifth of the column would be shrinking to fit a row that isn't there. */ + .actionActing { + flex-wrap: wrap; + } + .actionUndo { width: 100%; } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 46fa16d..0c20df8 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -42,7 +42,13 @@ const ActionBar = { // What is under the felt is the bar in one layout and the two links in the // other. Either way it is the same question — how much of the bottom is // already spoken for — so it is the same argument. - this.publishFeltRoom(rail ? this.roomBelowFelt() : height); + // + // Except that the felt is asked a slightly different question from everything + // else that reads --actionBarHeight. The toast and the scroll padding want to + // know what is covering the bottom of the window *right now*; the table wants + // to know how big to be, and being a different size for the few seconds a pad + // is open over it is not an answer, it is a jolt. See restingBarHeight. + this.publishFeltRoom(rail ? this.roomBelowFelt() : this.restingBarHeight(height)); }; // Kept so anything that moves the table without touching the bar can ask for @@ -58,6 +64,42 @@ const ActionBar = { publish(); }, + // How tall the bar is when it is only a bar. + // + // The raise pad is 57px taller than the row it replaces, and the felt was sized + // against it. Measured with five players nine-handed, pressing Raise: + // + // 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px + // 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px + // 390x844 felt 309x463 -> 309x451, worst seat moved 11px + // + // The whole table jumped, re-ran the ten-candidate width search, redrew at a + // different size — and then undid all of it when the dealer pressed Raise to. Every + // raise, all night, at the one moment they are reading the felt to decide what to + // type. A phone held sideways already did the right thing here and scored 0px: the + // rail is a column down the side, so its height never was the table's business. + // + // A pad over the bar is a transient surface, and the thing a transient surface must + // not do is take the context away from underneath itself. So the felt is sized + // against the bar the thumb is coming back to, and the pad grows up over the bottom + // of the felt for as long as it is open. + // + // Only the felt's question is answered this way. --actionBarHeight stays what it has + // always been — what is actually covering the bottom of the window — because that is + // what the toast has to clear and what the page has to scroll past. + // + // The remembered height goes stale if the window changes size while the pad is open, + // which on a phone means turning it over mid-raise. The next publish after the pad + // closes is a measurement, so it puts itself right; a stale felt for the seconds in + // between is the same trade being made everywhere else here. + restingBarHeight(measured) { + if (!this.raiseOpen) { + this.barHeightAtRest = measured; + return measured; + } + return this.barHeightAtRest === undefined ? measured : this.barHeightAtRest; + }, + // Is the bar a rail down the side rather than a bar across the bottom? // // Asked of the stylesheet rather than answered again here. The layout is chosen by @@ -336,20 +378,59 @@ const ActionBar = { row.appendChild(runout); } + // The second line of this block, held as a line rather than left to whatever the + // wrapping happens to produce. + // + // The row wraps, and flex decides its line breaks before it decides what to + // shrink — so a name too long for the space left beside the clock and the undo + // pill did not give way, it pushed them onto a third line. That is 22px, and the + // bar is fixed to the bottom, so it came off the felt: measured at 360x640 + // mid-street, "Dee · $600 behind" left the table 283x329 and "Christopher · + // $1,200 behind" made it 283x310, with every seat moving 24px between one player + // and the next. Whose turn it was decided how big the table was. + // + // On its own line the three of them are a row that can shrink instead of a row + // that can wrap, and the block is two lines whoever is acting. + const acting = document.createElement("div"); + acting.className = "actionActing"; + const index = this.state.actingSeatIndex; if (index !== null && index !== undefined && !Betting.isHandOver(this.state)) { const player = this.state.seats[index]; + // Two spans rather than one sentence, because only one half of it is + // allowed to give way. This row wraps, and a name long enough to push the + // clock and the undo pill onto a third line takes 22px off the felt — so + // whose turn it is decided how big the table was. Measured at 360x640: + // "Dee · $600 behind" left the bar 151px tall and "Christopher · $1,200 + // behind" made it 173, and the felt resized between one player and the next. + // + // So the name shrinks and the number does not. A name cut to "Christophe…" + // is still the player the dealer is looking at; a stack cut to "$1,2…" is a + // figure they would have to go and find somewhere else. const who = document.createElement("span"); who.className = "actionWho"; - who.textContent = `${HoldemState.displayName(player, index)} · ${Money.format(Betting.stackOf(this.state, player))} behind`; - row.appendChild(who); + + const whoName = document.createElement("span"); + whoName.className = "actionWhoName"; + whoName.textContent = HoldemState.displayName(player, index); + // The whole of it, for the one name on the narrowest phone that still has to + // give up a few characters. Same arrangement the seat pucks have. + whoName.title = whoName.textContent; + who.appendChild(whoName); + + const whoStack = document.createElement("span"); + whoStack.className = "actionWhoStack"; + whoStack.textContent = ` · ${Money.format(Betting.stackOf(this.state, player))} behind`; + who.appendChild(whoStack); + + acting.appendChild(who); const tank = document.createElement("span"); tank.className = "actionTank"; tank.id = "actionTank"; tank.title = "How long this player has been thinking"; tank.textContent = "0:00"; - row.appendChild(tank); + acting.appendChild(tank); // Rendered every time and shown by the tick once the wait is long enough // to be worth it, so a clock never costs a re-render at the moment the @@ -375,7 +456,7 @@ const ActionBar = { clock.title = `${this.CLOCK_THINK_S} seconds, then a ${this.CLOCK_COUNTDOWN_S}-second countdown`; clock.addEventListener("click", () => this.callClock()); } - row.appendChild(clock); + acting.appendChild(clock); } // A standing control rather than a toast per action: a dealer taps this bar @@ -410,9 +491,12 @@ const ActionBar = { key.textContent = this.KEY_HINTS.actionUndo; undo.appendChild(key); undo.addEventListener("click", () => this.hooks.undoLast()); - row.appendChild(undo); + acting.appendChild(undo); } + // Nothing to act on and nothing to take back — no second line at all. + if (acting.childElementCount) row.appendChild(acting); + return row; }, diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index f05a0f6..133d5ea 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -156,9 +156,19 @@ describe("controls that must not resize under a thumb", () => { test("the undo pill has a fixed width, not a content width", () => { // It names the last action, so its label changes on every tap. Measured across // one hand before this: 148, 110, 109, 108, 109, 110, 120. - const rule = css.match(/\.actionUndo\s*\{[^}]*\}/); - assert.ok(rule, ".actionUndo rule not found"); - assert.match(rule[0], /width:\s*[0-9.]+rem/); + // + // A length at every breakpoint, never a percentage: `min(10rem, 34%)` was tried + // and the percentage resolves against the row it sits in, which is sized to its + // own contents — the two chase each other, and the sweep caught the pill drawn + // 38px wide at ten viewports. A phone gets a narrower one through a media query + // instead, which is still one width at a time. + const rules = [...css.matchAll(/\.actionUndo\s*\{([^}]*)\}/g)].map(m => m[1]); + assert.ok(rules.length, ".actionUndo rule not found"); + rules.filter(body => /(? + assert.match(body, /(? /(? { diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js index 0ee1352..2a7da9e 100644 --- a/tests/holdem/theRail.test.js +++ b/tests/holdem/theRail.test.js @@ -15,6 +15,8 @@ const { ActionBar, Render } = require("./load"); const CSS = fs.readFileSync( path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); +const CSS_JS = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"), "utf8"); // --------------------------------------------------------------------------- @@ -160,6 +162,138 @@ describe("the button seat's name", () => { // --------------------------------------------------------------------------- +describe("the table is sized against the bar, not against a pad over it", () => { + // The raise pad is 57px taller than the row it replaces, and the felt was sized + // against it. Measured with five players nine-handed, pressing Raise: + // + // 1280x900 felt 738x532 -> 669x481, pucks 120 -> 109, worst seat moved 73px + // 360x640 felt 283x329 -> 283x279, pucks 44 -> 48, worst seat moved 50px + // + // The whole table jumped, re-ran the ten-candidate width search, and undid all of it + // when the dealer pressed Raise to. Every raise, all night, at the one moment they + // are reading the felt to decide what to type. + // + // A phone held sideways already scored 0px, because the rail is a column down the + // side and its height never was the table's business. This is the upright layout + // being held to what the sideways one already does. + test.afterEach(() => { ActionBar.raiseOpen = false; delete ActionBar.barHeightAtRest; }); + + test("a closed bar is measured, and remembered", () => { + ActionBar.raiseOpen = false; + assert.equal(ActionBar.restingBarHeight(151), 151); + assert.equal(ActionBar.barHeightAtRest, 151); + }); + + test("and an open pad gets the height the bar had before it opened", () => { + ActionBar.raiseOpen = false; + ActionBar.restingBarHeight(151); + ActionBar.raiseOpen = true; + assert.equal(ActionBar.restingBarHeight(208), 151, + "the felt would resize under the dealer's thumb mid-raise"); + }); + + test("and gets it back the moment the pad closes", () => { + ActionBar.raiseOpen = false; + ActionBar.restingBarHeight(151); + ActionBar.raiseOpen = true; + ActionBar.restingBarHeight(208); + ActionBar.raiseOpen = false; + assert.equal(ActionBar.restingBarHeight(172), 172, + "a bar that really did grow — a side pot to show — still takes its room"); + }); + + test("a pad open before anything was ever measured takes what it can see", () => { + // Nothing has been remembered yet, so the measurement is the only answer there + // is. Better a felt sized for the pad than one sized for undefined. + ActionBar.raiseOpen = true; + delete ActionBar.barHeightAtRest; + assert.equal(ActionBar.restingBarHeight(208), 208); + }); + + test("the bar that is actually covering the window is still reported honestly", () => { + // Only the felt's question is answered from the resting height. The toast has to + // clear whatever is really there, and so does the page's scroll padding, so + // --actionBarHeight stays a measurement. + const publish = CSS_JS.slice(CSS_JS.indexOf("const publish = () => {")); + const body = publish.slice(0, publish.indexOf("};")); + assert.match(body, /--actionBarHeight[^\n]*Math\.round\(height\)/, + "--actionBarHeight should still be the measured height"); + assert.match(body, /publishFeltRoom\(.*restingBarHeight\(height\)/, + "the felt should be the one asking for the resting height"); + }); +}); + +// --------------------------------------------------------------------------- + +describe("the status block is the same number of lines whoever is acting", () => { + // The bar is fixed to the bottom, so a line added to this block comes straight off + // the felt. The block wraps, and flex settles its line breaks before it decides what + // to shrink — so a name too long for the space left beside the clock and the undo + // pill did not give way, it pushed them onto a third line. + // + // Measured at 360x640 mid-street: "Dee · $600 behind" left the table 283x329 and + // "Christopher · $1,200 behind" made it 283x310, with every seat moving 24px between + // one player and the next. Whose turn it was decided how big the table was. + const acting = CSS.match(/\.actionActing \{([^}]*)\}/); + const who = CSS.match(/\n\.actionWho \{([^}]*)\}/); + const name = CSS.match(/\.actionWhoName \{([^}]*)\}/); + const stack = CSS.match(/\.actionWhoStack \{([^}]*)\}/); + + test("the acting row takes a line of its own, once there is somebody on it", () => { + assert.ok(acting, ".actionActing rule not found"); + assert.match(acting[1], /flex:\s*0 1 auto/, + "the pill on its own should not claim a line"); + const withActor = CSS.match(/\.actionActing:has\(\.actionWho\) \{([^}]*)\}/); + assert.ok(withActor, "nothing gives the acting row its own line"); + assert.match(withActor[1], /flex:\s*1 1 100%/, + "without a full basis it shares whatever line the wrap left it"); + }); + + test("and only then — a run-out has nobody to act and cannot spare the line", () => { + // While a board is running out there is nobody to act, so this row is the undo + // pill alone. Claiming a line for it is 22px off the felt on the twelve-handed + // 360x640 table where the pucks are already at their 44px floor, and the sweep + // caught that as a seat's "ALL IN" going back under the blind badge next door. + assert.doesNotMatch(acting[1], /flex:\s*1 1 100%/); + }); + + test("and it shrinks rather than wrapping", () => { + // Left wrapping, this row reproduced the fault one level down: the undo pill is + // a fixed width, so a long name pushed it onto a line of its own and the block + // was 84px for some players and 66 for others. + assert.match(acting[1], /flex-wrap:\s*nowrap/); + }); + + test("the name is what gives way", () => { + // `min-width` on a flex item defaults to `auto` — "never smaller than your own + // content" — which is why the ellipsis this was written with never once appeared. + assert.ok(who && name, "the actor should be a name and an amount, not one string"); + assert.match(who[1], /min-width:\s*0/); + assert.match(name[1], /min-width:\s*0/); + assert.match(name[1], /text-overflow:\s*ellipsis/); + }); + + test("and the stack is what does not", () => { + // The same rule the seat pucks keep: a number read wrong is a worse failure than + // a name read short, so the stack keeps every digit. + assert.ok(stack, ".actionWhoStack rule not found"); + assert.match(stack[1], /flex:\s*none/); + }); + + test("except in the rail, which is a column and has no line to shrink into", () => { + // 190px wide: every one of these readings is on its own line there regardless, + // and squeezing a name into a fifth of the column would be shrinking to fit a + // row that isn't there. + const rules = [...CSS.matchAll(/\.actionActing \{([^}]*)\}/g)].map(m => m[1]); + assert.equal(rules.length, 2, + "one rule for the bar and one for the rail — no more, no fewer"); + assert.ok(rules.some(body => /flex-wrap:\s*wrap\s*;/.test(body)), + "the rail should say what it wants instead"); + }); +}); + +// --------------------------------------------------------------------------- + describe("how much of the bottom is spoken for", () => { // feltRoomFor asks one question — what is under the felt — and gets two different // answers depending on the layout. Under the bar it is the bar. Under the rail it is