diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index f73f0b1..d2e4018 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1055,10 +1055,35 @@ button, that height spends room that was already spent. What pays for the third row is the second line of name going away above: uniform-with-two-lines was 88px, which an earlier round measured and rightly refused. Uniform-with-one is 71. */ +/* The two sizes a puck is written at, named so that the steps below them are steps *of* + them rather than separate sizes to be kept in agreement — and capped, which is the one + place in this stylesheet where type does not follow the browser's font size. + + Why it has to be. Every row above is a `px` token so that an amount stepping its type + down shortens its digits and not its row — but a token is a floor, not a ceiling, and + text bigger than its row simply makes the row taller. Turn a phone's text up to the + 125% its slider offers and --fs-2xs goes from 10px to 12.5, the two money rows go from + 14px each to 17.5, and the puck grows with them. At 150% the figures were being cut + off by the puck's own edge; on the tightest ring, where twelve pucks are already at + the 44px a thumb needs and there is nowhere narrower to go, they came down on each + other instead — the sweep measured seat 3's "$1,495" five-sevenths under seat 4's BB + badge at every phone in the list. + + `min(…, Npx)` is exactly the size each of these has always been drawn at, so nothing + changes for anybody at an ordinary text size: the cap only ever engages where the + alternative is a figure clipped in half or two seats printed on top of each other. + And what it holds is the one thing on the page that is also somewhere that does + scale — the Standings panel lists every stack in full, at whatever size its owner + asked for. The felt is a diagram; its cells are sized by the table, and their writing + has to fit inside them. That rule already governed a puck's width. This is the same + rule for its height. + + .seatsRoomy is the exception, below: a puck past the ordinary ceiling has the room. */ #seatsLayer { --seatRowName: 15px; /* 12px of name at 1.2 */ --seatRowMoney: 14px; /* 10px of figures at 1.4 */ - --seatNameSize: var(--fs-sm); + --seatNameSize: min(var(--fs-sm), 12px); + --seatAmountSize: min(var(--fs-2xs), 10px); } /* One line, and the ellipsis is not what saves it. @@ -1155,7 +1180,7 @@ button, .seatPuckTotal { display: inline-block; margin-top: var(--sp-0); - font-size: var(--fs-2xs); + font-size: var(--seatAmountSize); font-weight: 700; color: #a9f5b8; background: rgba(9, 255, 0, 0.1); @@ -1498,10 +1523,12 @@ button, #seatsLayer.seatsTight { --seatRowName: 12px; /* 10px of name at 1.2 */ --seatRowMoney: 12px; /* and 10px of figures at 1.2, not 1.4 */ - --seatNameSize: var(--fs-2xs); + --seatNameSize: min(var(--fs-2xs), 10px); + --seatAmountSize: min(var(--fs-2xs), 10px); } -.seatsTight .seatPuckTotal { +.seatsTight .seatPuckTotal, +.seatsTight .seatPuckStack { line-height: 1.2; } @@ -1510,10 +1537,6 @@ button, text-overflow: ellipsis; } -.seatsTight .seatPuckStack { - font-size: var(--fs-2xs); -} - /* Not wrapping is only half an answer: a line that cannot wrap and still does not fit paints over the seat next door instead. So on the tightest pucks the mark gives up most of its letter-spacing, which is 3.6px of the 36 it wanted. @@ -1543,11 +1566,7 @@ button, --seatRowName: 18px; /* 15px of name at 1.2 */ --seatRowMoney: 17px; /* 12px of figures at 1.4 */ --seatNameSize: var(--fs-md); -} - -.seatsRoomy .seatPuckStack, -.seatsRoomy .seatPuckTotal { - font-size: var(--fs-sm); + --seatAmountSize: var(--fs-sm); } .seatsRoomy .seatChairNumber { @@ -3841,7 +3860,7 @@ body.hasActionBar #holdemApp { 600 — so the hierarchy is intact; it is just carried by size and weight instead of by fading the number underneath it. */ .seatPuckStack { - font-size: var(--fs-2xs); + font-size: var(--seatAmountSize); color: var(--text); font-variant-numeric: tabular-nums; margin-top: var(--sp-0); @@ -3868,21 +3887,30 @@ body.hasActionBar #holdemApp { border-color: rgba(255, 207, 107, 0.45); } -/* Tournament-sized amounts. A number has no spaces to break on, so an amount too - wide for the 64px puck neither wraps nor ellipsises — it draws straight out over - the seat next to it. These step the size down so every digit stays on its own - puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more - comfortably, but a dealer reading a rounded stack off the felt is a worse failure - than a small one, and the exact figure is what the tool is for. */ +/* The two steps an amount comes down when it is too wide for its puck. A number has no + spaces to break on, so an amount too wide neither wraps nor ellipsises — it draws + straight out over the seat next to it. Shrinking rather than abbreviating is + deliberate: "$1.25M" would fit more comfortably, but a dealer reading a rounded stack + off the felt is a worse failure than a small one, and the exact figure is what the + tool is for. + + Both are steps *of* --seatAmountSize rather than sizes of their own, so they follow + the tight ring's cap down instead of stepping back over it — and so the ladder is one + number with two fractions under it rather than three sizes to keep in agreement. + + Which step a figure gets is measured, not counted. See Render.fitAmounts: the old + rule was eight characters for the first and ten for the second, which is a guess at + how wide a figure is, and it stopped being true the moment anybody turned their + phone's text up. */ .seatPuckStack.puckAmountLong, .seatPuckTotal.puckAmountLong { - font-size: var(--fs-2xs); + font-size: calc(var(--seatAmountSize) * 0.85); letter-spacing: -0.01em; } .seatPuckStack.puckAmountHuge, .seatPuckTotal.puckAmountHuge { - font-size: 0.44rem; + font-size: calc(var(--seatAmountSize) * 0.7); letter-spacing: -0.02em; } @@ -4656,10 +4684,20 @@ body.hasActionBar #toastHost { /* Takes the row rather than shrink-wrapping to the right of it. Sized to their labels the three buttons left 78px of nothing where the title used to be, and that gap buys nothing — spent on the buttons instead it is about 26px of extra - target each, on the controls a dealer presses once a hand with a thumb. */ + target each, on the controls a dealer presses once a hand with a thumb. + + Wrapping, though, and this used to say `nowrap`. Three labels that will not break + and will not shrink — `white-space: nowrap` and a `min-width: auto` floor apiece — + have to go somewhere when they stop fitting, and with nowrap that somewhere was + off the side of the screen. Measured with the text turned up, which every phone + lets you do: at 20px of root text (an Android slider at 125%) "Resolve Pot" ended + 23px past the right edge of a 360px window and put a horizontal scrollbar under + the whole page; at 24px it was 60px past the edge of a 390px one. Nothing is lost + by letting them take a second row on the day they need one — at any ordinary text + size they still fit on the first and still grow into it. */ #holdemHeaderActions { flex: 1 1 auto; - flex-wrap: nowrap; + flex-wrap: wrap; min-width: 0; gap: var(--sp-1); } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 9fe74e6..66027ab 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -247,9 +247,27 @@ const ActionBar = { // After the tank timer, because whether the stopwatch and the clock button are // on the acting line is what decides how much room the name has left. this.fitActingLine(); + this.showRailEnd(host); this.announce(); }, + // A phone held sideways puts this bar down the right-hand edge as a rail, anchored + // low: the buttons sit at the bottom, where a thumb rests rather than where a hand + // has to reach up. The rail scrolls when what it holds outgrows it, which the raise + // pad does on a 360px-tall screen the moment the dealer has their text turned up — + // measured at 640x360 with 20px of root text, 407px of pad in a 358px rail. + // + // And a scroll container starts at its top. So the overflow came off the bottom, + // which is exactly where the two buttons that end a raise are: Cancel and Raise to + // were half under the edge of the screen with the hand number and the pot — the two + // things a dealer had already read — holding the room above them. Anchored low, the + // rail should open at the end for the same reason it is anchored low at all. + showRailEnd(host) { + if (!this.isRail(host)) return; + if (host.scrollHeight <= host.clientHeight) return; + host.scrollTop = host.scrollHeight; + }, + // The empty table. One sentence and one button, in the place every other prompt in // this tool appears, so a dealer who has never opened it before is looking at the // next step rather than at ten chairs labelled EMPTY. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index 46cae0b..38c390d 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -1495,6 +1495,7 @@ const Render = { }); this.fitSeatNames(layer); + this.fitAmounts(this.seatAmountCells(layer)); // The pucks are only now what they will be drawn as, and the ring was sized // before any of it went in. Usually those agree — a puck's height is a stack of @@ -1518,6 +1519,8 @@ const Render = { this.placeSeat(layer.children[index], index, aspect, radii); } this.fitSeatNames(layer); + // The puck is a different width than the one these were fitted to. + this.fitAmounts(this.seatAmountCells(layer)); } const settled = this.settleSeatsOnTable(layer, aspect, radii); @@ -2028,7 +2031,11 @@ const Render = { : HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount); const totalEl = node.querySelector(".seatPuckTotal"); totalEl.textContent = total > 0 ? Money.format(total) : ""; - this.fitAmount(totalEl); + // The figure as it is meant to be read, kept beside the one that gets drawn. + // fitAmounts may take the separators out of what is on screen, and the pass + // after it has to start from the grouped figure again or the puck can only ever + // get further from it. + totalEl.dataset.figure = totalEl.textContent; // A player with nothing behind used to leave this line blank, so the felt drew // an all-in exactly like a seat whose stack nobody had entered. On a table with @@ -2039,7 +2046,7 @@ const Render = { const allIn = Betting.isAllIn(this.state, player); stackEl.textContent = empty ? "" : allIn ? "ALL IN" : stack > 0 ? Money.format(stack) : ""; stackEl.classList.toggle("seatPuckAllIn", allIn); - this.fitAmount(stackEl); + stackEl.dataset.figure = stackEl.textContent; node.classList.toggle("seatAllIn", allIn); node.classList.toggle("seatActing", index === this.state.actingSeatIndex); @@ -2049,35 +2056,90 @@ const Render = { node.classList.toggle("seatLive", player.inHand && !player.folded); }, - // A seat puck is 64px of usable width. A tournament stack doesn't fit in that and, - // because the number has no spaces to break on, it doesn't wrap or ellipsise - // either — it just draws straight out over the neighbouring seat. Stepping the - // font down keeps every digit, which matters: a dealer misreading a stack is a - // worse outcome than a small one, so this shrinks rather than abbreviating. - fitAmount(el) { - const formatted = el.textContent || ""; - // The separators go before the type size does. "$1,234,567" is ten characters, - // which used to drop the puck to 7px — and 7px is not a size anybody reads a - // stack off a felt at, so the shrink-rather-than-abbreviate rule above was - // buying precision the dealer then couldn't see. Without its commas the same - // number is eight characters, and eight fits at 10px with every digit intact. - // Grouping is a reading aid for a column of figures; this is one number on a - // 64px puck, and the aid costs more than it gives. - const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted; - if (text !== formatted) el.textContent = text; + // The two steps of type an amount can come down, in order, once taking the + // separators out has not been enough. + AMOUNT_STEPS: ["puckAmountLong", "puckAmountHuge"], - const length = text.length; - el.classList.toggle("puckAmountLong", length >= 8 && length < 10); - // Only a stack of $100,000,000 or more still lands here. It is an overflow - // guard rather than a size anybody is expected to read. - el.classList.toggle("puckAmountHuge", length >= 10); + // Puts one amount back to the figure and the size it is meant to be read at, and + // hands back the grouped figure. Everything below measures from here, so a puck that + // once went small can come back — left as it was, this would be a ratchet. + resetAmount(el) { + const figure = el.dataset.figure === undefined ? el.textContent : el.dataset.figure; + if (el.textContent !== figure) el.textContent = figure; + this.AMOUNT_STEPS.forEach(step => el.classList.remove(step)); + return figure; + }, + + // A seat puck is 64px of usable width, and 34 on the tightest ring in the sweep. A + // tournament stack doesn't fit in that and, because a number has no spaces to break + // on, it doesn't wrap or ellipsise either — it draws straight out over the seat next + // to it. Stepping the type down keeps every digit, which matters: a dealer misreading + // a stack is a worse outcome than a small one, so this shrinks rather than + // abbreviating. + // + // Measured, and it used to be counted. The old rule was eight characters for one + // step and ten for the other, which is a guess at how wide a figure is that happens + // to hold at one text size and one puck width. Turn a phone's text up — 20px of root + // text is what an Android slider gives you at 125%, and every size in this + // stylesheet is in `rem` on purpose — and it stops holding: measured at 360x640 + // twelve-handed, "$1,200" is six characters, took no step at all, and wanted 38px of + // a 34px puck. The sweep caught it as a stack figure painted over the seat next door + // at every phone in the list. What the puck can hold is a question about the puck, + // so it is asked of the puck. + // + // Written as passes over every amount on the ring rather than a loop per seat: each + // pass writes to all of them and then reads back from all of them, so the browser + // lays the felt out four times whatever the table's size. Interleaved, twelve seats + // would have cost twenty-four layouts, and this runs on every render. + fitAmounts(cells) { + const live = cells.filter(el => el && (el.dataset.figure || el.textContent)); + if (!live.length) return; + const figures = new Map(live.map(el => [el, this.resetAmount(el)])); + + // The separators go before the type size does. "$1,234,567" is ten characters, + // which used to drop the puck's figures to 7px — and 7px is not a size anybody + // reads a stack off a felt at, so the shrink-rather-than-abbreviate rule above + // was buying a precision the dealer then couldn't see. Without its commas the + // same number is eight characters and fits two steps higher up this ladder. + // Grouping is a reading aid for a column of figures; this is one number on a + // 44px puck, and there the aid costs more than it gives. + const ungroup = live.filter(el => figures.get(el).includes(",") && this.overflowing(el)); + ungroup.forEach(el => { el.textContent = figures.get(el).replace(/,/g, ""); }); + + let carried = null; + this.AMOUNT_STEPS.forEach(step => { + const over = live.filter(el => this.overflowing(el)); + const previous = carried; + over.forEach(el => { + if (previous) el.classList.remove(previous); + el.classList.add(step); + }); + carried = step; + }); + }, + + // One puck's worth — for refreshPuck, which redraws a single seat. + fitAmount(el) { + this.fitAmounts([el]); + }, + + // Every amount on the ring, so fitAmounts can do the whole table in four layouts. + seatAmountCells(layer) { + const cells = []; + for (let index = 0; index < layer.childElementCount; index++) { + const node = layer.children[index]; + cells.push(node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack")); + } + return cells; }, // Cheaper than a full renderSeats() — used after edits so the seat editor's inputs // (which live in a separate panel) never lose focus. refreshPuck(index) { const node = document.querySelector(`.seat[data-seat-index="${index}"]`); - if (node) this.updatePuckContent(node, this.state.seats[index], index); + if (!node) return; + this.updatePuckContent(node, this.state.seats[index], index); + this.fitAmounts([node.querySelector(".seatPuckTotal"), node.querySelector(".seatPuckStack")]); }, // ---- Seat gestures ---- diff --git a/tests/holdem/README.md b/tests/holdem/README.md index bfbd9b9..be45b8b 100644 --- a/tests/holdem/README.md +++ b/tests/holdem/README.md @@ -1017,6 +1017,53 @@ lose. Chromium and Safari answer silently on their own engagement heuristics; Fi asks. A no is not an error worth reporting at a table, so the only thing that changes is how confident Save & reset is allowed to sound, and whether it offers the ask as a button. +## The sweep had never once been run at a size anybody sets + +Every length in `holdem.css` that isn't a structural clearance is in `rem`. That is the +right way round: it means the whole page follows the browser's font size, which is what +a phone's text-size slider changes. It also means the page had a whole axis of behaviour +that 320 audits had never touched, because every one of them ran at the default 16px. + +A third pass now runs the same surfaces on the eight phone-shaped viewports with the +root at **20px** — the 125% an Android accessibility slider gives you. It found four +things on its first run, and every check that caught them was already written: + +- **The page slid sideways.** `#holdemHeaderActions` was `flex-wrap: nowrap` with three + `white-space: nowrap` labels inside it, so when they stopped fitting they had nowhere + to go: "Resolve Pot" ended 23px past the right edge of a 360px window and put a + horizontal scrollbar under the whole document. At 24px it was 60px past the edge of a + 390px one. `page never scrolls sideways` had been in the audit for months and had never + had anything to say. +- **Figures painted over the seat next door.** Which of the two type steps an amount got + was decided by *counting characters* — eight for one, ten for the other — which is a + guess at how wide a figure is that happens to hold at one text size and one puck width. + At 20px, "$1,200" is six characters, took no step at all, and wanted 38px of a 34px + puck. `Render.fitAmounts` measures now, in the same batched passes `fitSeatNames` uses, + and takes the separators out before it takes a step of type. +- **Pucks grew and the ring could not answer.** The row heights are `px` tokens so that + an amount stepping its type down shortens its digits and not its row — but a token is + a floor, not a ceiling, and text bigger than its row makes the row taller. The puck + went from 55px to 65 and twelve of them came down on each other, because at the 44px + floor there is nowhere narrower for the search to go. The two sizes a puck is written + at are capped at exactly the sizes they have always been drawn at, so nothing changes + at an ordinary text size; the felt is a diagram, and the rule that already governed a + puck's width now governs its height. `.seatsRoomy` is exempt — those pucks have room. +- **A check that was crying wolf.** `no control is parked where scrolling can't reach it` + flagged the raise pad's Cancel and Raise-to in the landscape rail. The rail is + `overflow-y: auto` on purpose and they were one flick away, so the message was untrue; + it now asks whether a scrollable ancestor can bring the control back. What was true is + that the rail opened at its *top*, so the overflow came off the bottom — where the two + buttons that end a raise are. It opens at the end now, which is the same reason it is + anchored low in the first place. + +One shape it is not held to, with the arithmetic in `runUiAudit.js`: twelve players +all-in on a 360x640 phone. The action bar is at its tallest there — 283px of the 640 at +20px — and with the header that leaves the felt 290x163 where it had 296x262. Twelve +pucks 44px wide (the floor a thumb needs) and 62px tall do not fit around an ellipse +163px deep. That is a geometry with no answer rather than a fault with a fix, so it is +reported as a note every run instead of a failure every run — a sweep that is always red +stops being read. + ## Why the hand evaluator is tested the way it is `handRank.js` is the only module here that can pay the wrong person without anybody diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index 133d5ea..5d07f20 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -79,15 +79,26 @@ describe("the spacing scale", () => { describe("the type scale", () => { // 16px — the size below which iOS zooms the page when a field is focused, so // every text input states it. Not a typographic choice. - // 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for - // a six-figure stack on a 58px seat puck. - // 9px — the floor under the same shrink-to-fit applied to a name. It is a limit - // rather than a size: past it the word stops being legible from across a - // table and an honest ellipsis is the better answer. - const ALLOWED = new Set(["16px", "1.05em", "0.44rem", "9px"]); + // 1.05em — a relative bump inside a component. + // 9px — the floor under the seat puck's shrink-to-fit applied to a name. It is a + // limit rather than a size: past it the word stops being legible from + // across a table and an honest ellipsis is the better answer. + // 10px, 12px — the ceilings on a seat puck's two sizes, which are the one place in + // this file where type does not follow the browser's font size. Each is + // exactly the size that row has always been drawn at, so a cap only ever + // engages when a phone's text is turned up; see the #seatsLayer token block + // for why a ring whose pucks are already at the 44px floor cannot answer a + // taller row any other way. Limits rather than sizes. + const ALLOWED = new Set(["16px", "1.05em", "9px", "10px", "12px"]); + + // The seat puck's two sizes are custom properties that feed `font-size` through + // `var()`, so the sweep above walks straight past them — which is exactly where a + // raw number would end up hiding. They are held to the same scale. + const TYPE_TOKENS = ["--seatNameSize", "--seatAmountSize"]; test("every font-size comes off the scale", () => { - const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value)); + const offenders = valuesIn(["font-size", ...TYPE_TOKENS]) + .filter(v => !ALLOWED.has(v.value)); assert.deepEqual(offenders, [], "use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason"); }); diff --git a/tests/holdem/runUiAudit.js b/tests/holdem/runUiAudit.js index 1157a9f..2a905e0 100644 --- a/tests/holdem/runUiAudit.js +++ b/tests/holdem/runUiAudit.js @@ -110,23 +110,72 @@ async function main() { const warnings = []; let checks = 0; - // Twice over: once with a mouse, once with a thumb. The 44px rule stands itself - // down where `hover: hover` says there's a pointer, and a headless desktop browser - // is a pointer — so a single pass here has never once measured a touch target, - // however many viewports it visited. The phone-sized ones are exactly the pass that - // needs it. - for (const touch of [false, true]) { + // Three passes over the same surfaces. + // + // A mouse and a thumb, because the 44px rule stands itself down where `hover: hover` + // says there's a pointer, and a headless desktop browser is a pointer — so a single + // pass has never once measured a touch target, however many viewports it visited. + // + // And a thumb on a phone whose owner has turned the text up. Every length in this + // stylesheet that isn't a structural clearance is in `rem`, which is the right way + // round and means the whole page follows the browser's font size — including out of + // the side of the window, which is what it was doing: at 20px, which is the 125% + // an Android text-size slider gives you, "Resolve Pot" ran 23px off the right of a + // 360px phone and took the document's scroll width with it. Every check in the + // audit already knew how to catch that; not one of them had ever been run at a size + // anybody actually sets. + // + // One shape it is not held to, and the arithmetic for why. Twelve players all-in on + // a 360x640 phone is the tallest the action bar ever gets — the status block and the + // pot breakdown both grow with the text — and at 20px it stands 283px of the 640, + // which with the header leaves the felt 290x163 where it had 296x262. Twelve pucks + // 44px wide (the floor a thumb needs; there is nothing narrower to go to) and 62px + // tall do not fit around an ellipse 163px deep: two per side is all the height there + // is, and the sweep reports it as seats 9, 10 and 11 written across each other. + // + // That is a geometry with no answer rather than a fault with a fix, so it is + // reported as a note every run instead of a failure every run. A sweep that is + // always red stops being read — the same reasoning as the 30% furniture warning. + const CROWDED_BEYOND_HELP = "all in"; + const LARGE_TEXT_PX = 20; + const PHONES = VIEWPORTS.filter(([w, h]) => Math.min(w, h) <= 430); + const PASSES = [ + { hand: "mouse", touch: false, viewports: VIEWPORTS }, + { hand: "touch", touch: true, viewports: VIEWPORTS }, + { hand: "large text", touch: true, viewports: PHONES, rootPx: LARGE_TEXT_PX, soft: CROWDED_BEYOND_HELP }, + ]; + + for (const { hand, touch, viewports, rootPx, soft } of PASSES) { const context = await browser.newContext(touch ? { hasTouch: true, isMobile: true, deviceScaleFactor: 3 } : {}); + // Before any of the page's own scripts, so its listener is registered first and + // the size is in place before main.js measures anything. A stylesheet rather + // than an inline style because that is what a browser font-size setting is: the + // root's used size, from first paint, not something applied afterwards. + if (rootPx) { + await context.addInitScript((px) => { + document.addEventListener("DOMContentLoaded", () => { + const style = document.createElement("style"); + style.textContent = `html { font-size: ${px}px }`; + document.head.appendChild(style); + }, { once: true }); + }, rootPx); + } const page = await context.newPage(); - const hand = touch ? "touch" : "mouse"; - for (const [width, height, rawLabel] of VIEWPORTS) { + for (const [width, height, rawLabel] of viewports) { await page.setViewportSize({ width, height }); for (const [shape, seats, players, board, shoves] of TABLES) { const label = `${rawLabel} · ${shape} · ${hand}`; + // Measured either way; where the shape has no answer at this text size, + // said in the notes rather than counted against the run. + const record = (where, offenders) => { + if (!offenders.length) return; + if (shape === soft) warnings.push(`${where}: ${offenders.join("; ")}`); + else failures.push({ where, offenders }); + }; await page.goto(`${BASE}/Casino/TexasHoldem`); await page.evaluate(seedTable, [seats, players, board]); await page.goto(`${BASE}/Casino/TexasHoldem`); @@ -201,7 +250,7 @@ async function main() { const table = await page.evaluate(() => LayoutAudit.runAll()); checks++; - if (!table.clean) failures.push({ where: `table · ${label} ${width}x${height}`, offenders: table.failed.flatMap(f => f.offenders) }); + record(`table · ${label} ${width}x${height}`, table.failed.flatMap(f => f.offenders)); table.warnings.forEach(w => warnings.push(`${label}: ${w}`)); // The raise pad, which is part of the bar rather than an overlay and so @@ -224,12 +273,8 @@ async function main() { const pad = await page.evaluate(() => LayoutAudit.runAll({ occlusionSelector: "#actionBar button, #actionBar input", })); - if (!pad.clean) { - failures.push({ - where: `raise pad · ${label} ${width}x${height}`, - offenders: pad.failed.flatMap(f => f.offenders), - }); - } + record(`raise pad · ${label} ${width}x${height}`, + pad.failed.flatMap(f => f.offenders)); await page.evaluate(() => { const cancel = [...document.querySelectorAll("#actionBar button")] .find(b => /^Cancel/.test(b.textContent.trim())); @@ -253,12 +298,12 @@ async function main() { return LayoutAudit.occlusion(`#${overlay.id} button, #${overlay.id} a, #${overlay.id} input`); }); await page.evaluate(() => document.querySelectorAll("[id$='Overlay']").forEach(o => { o.hidden = true; })); - if (result.offenders.length) failures.push({ where: `${name} · ${label} ${width}x${height}`, offenders: result.offenders }); + record(`${name} · ${label} ${width}x${height}`, result.offenders); } } } - for (const [width, height, rawLabel] of VIEWPORTS.filter((_, i) => i % 4 === 0)) { + for (const [width, height, rawLabel] of viewports.filter((_, i) => i % 4 === 0)) { const label = `${rawLabel} · ${hand}`; await page.setViewportSize({ width, height }); await page.goto(`${BASE}/Casino/TexasHoldem/QuickSplit`); @@ -278,7 +323,8 @@ async function main() { await browser.close(); - console.log(`${checks} audits across ${VIEWPORTS.length} viewports, with a mouse and with a thumb`); + console.log(`${checks} audits across ${VIEWPORTS.length} viewports — with a mouse, with a thumb, ` + + `and with a thumb at ${LARGE_TEXT_PX}px of root text on ${PHONES.length} phones`); warnings.forEach(w => console.log(` note: ${w}`)); if (failures.length === 0) { console.log("clean"); diff --git a/tests/holdem/seatPuckShape.test.js b/tests/holdem/seatPuckShape.test.js index 069e8e2..0f7e1e6 100644 --- a/tests/holdem/seatPuckShape.test.js +++ b/tests/holdem/seatPuckShape.test.js @@ -240,3 +240,118 @@ describe("and the ring checks its own measurement afterwards", () => { assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false); }); }); + +// --------------------------------------------------------------------------- + +describe("how much room a figure has on the puck it is written on", () => { + // The two steps of type an amount comes down used to be chosen by counting + // characters: eight for the first, ten for the second. That is a guess at how wide a + // figure is, and it happens to hold at one text size and one puck width. + // + // Turn a phone's text up — 20px of root text is what an Android slider gives you at + // 125%, and every size in the stylesheet is in `rem` on purpose — and it stops + // holding. Measured at 360x640 twelve-handed: the puck is at its 44px floor, which + // is 34px of usable width, and "$1,200" is six characters, took no step at all, and + // wanted 38. The sweep caught it as a stack figure painted over the seat next door + // at every phone in the list, and as "ALL IN" doing the same on the all-in table. + // + // So it is measured. A cell is a stand-in for the real one: what it wants against + // what it has, and a class list to write the answer into. + const cell = (figure, wants, has) => ({ + dataset: { figure }, + textContent: figure, + classes: new Set(), + classList: { + add(name) { this.owner.classes.add(name); }, + remove(name) { this.owner.classes.delete(name); }, + }, + // What the text wants is proportional to how many characters are in it and how + // far down the ladder it has come — which is what a browser would tell us. + get scrollWidth() { + const step = this.classes.has("puckAmountHuge") ? 0.7 + : this.classes.has("puckAmountLong") ? 0.85 : 1; + return Math.round((wants * this.textContent.length / figure.length) * step); + }, + clientWidth: has, + }); + + const make = (...args) => { + const c = cell(...args); + c.classList.owner = c; + return c; + }; + + const fit = cells => { + const before = Render.overflowing; + Render.overflowing = el => el.scrollWidth > el.clientWidth + 1; + try { Render.fitAmounts(cells); } finally { Render.overflowing = before; } + }; + + test("a figure that fits is left at the size it is meant to be read at", () => { + const c = make("$600", 30, 34); + fit([c]); + assert.equal(c.textContent, "$600"); + assert.deepEqual([...c.classes], []); + }); + + test("one that doesn't gives up its separators first", () => { + // Grouping is a reading aid for a column of figures. This is one number on a + // 44px puck, and a step of type costs more than the commas do. + const c = make("$1,200", 38, 34); + fit([c]); + assert.equal(c.textContent, "$1200"); + assert.deepEqual([...c.classes], [], "no type step needed once the commas are out"); + }); + + test("then a step of type, and then another", () => { + const c = make("$1,234,567", 90, 34); + fit([c]); + assert.equal(c.textContent, "$1234567"); + assert.deepEqual([...c.classes], ["puckAmountHuge"]); + }); + + test("and only ever one step at a time is worn", () => { + // The two rules have the same specificity, so a puck wearing both would be sized + // by whichever the stylesheet happens to mention last. + const c = make("$1,234,567", 45, 34); + fit([c]); + assert.equal(c.classes.has("puckAmountHuge") && c.classes.has("puckAmountLong"), false); + }); + + test("a figure with no separators to give goes straight to the type", () => { + // "ALL IN" is the one thing on this row that isn't a number, and it is the widest + // — six characters and a space, at 700 weight with its letters spread apart. + const c = make("ALL IN", 39, 34); + fit([c]); + assert.equal(c.textContent, "ALL IN"); + assert.deepEqual([...c.classes], ["puckAmountLong"]); + }); + + test("a puck that went small can come back", () => { + // Everything is measured from the figure and the size it is meant to be read at, + // not from whatever the last render left behind. Otherwise this is a ratchet: one + // crowded frame and the seat is small for the rest of the night. + const c = make("$600", 30, 34); + c.classes.add("puckAmountHuge"); + c.textContent = "$600"; + fit([c]); + assert.deepEqual([...c.classes], []); + }); + + test("and the grouped figure is what it comes back to", () => { + const c = make("$1,200", 38, 34); + fit([c]); + assert.equal(c.textContent, "$1200"); + c.clientWidth = 96; + fit([c]); + assert.equal(c.textContent, "$1,200", "a wider puck should get its separators back"); + }); + + test("an empty row is not an amount", () => { + // The pill keeps its place on a seat with nothing in the pot — that is what makes + // every puck on the ring the same puck — and there is nothing there to fit. + const c = make("", 0, 34); + assert.doesNotThrow(() => fit([c])); + assert.deepEqual([...c.classes], []); + }); +}); diff --git a/tests/holdem/uiAudit.js b/tests/holdem/uiAudit.js index c3e5c8e..be59993 100644 --- a/tests/holdem/uiAudit.js +++ b/tests/holdem/uiAudit.js @@ -213,6 +213,26 @@ const LayoutAudit = { // accessibility tree, and because a fixed element does not move when the page does, // no amount of scrolling could bring it back. visibility:hidden puts it away; a // transform only moves it. + // A fixed panel that scrolls is a different thing from a fixed panel that doesn't, + // and this check could not tell them apart. The landscape rail is `overflow-y: auto` + // on purpose — the raise pad outgrows a 360px-tall screen the moment the text is + // turned up, and the stylesheet chose overflow over drawing controls on top of one + // another — so a button below its fold is one flick away, not lost. Reported as + // unreachable it is simply untrue, and a sweep that cries wolf is a sweep nobody + // reads. + // + // The ancestor has to be on screen itself for this to hold, which the rail is: it + // is `top: 0; bottom: 0`. If a scrolling panel is ever parked off the side, the + // thing to report is the panel, not the button inside it. + scrollableAncestor(el) { + for (let node = el.parentElement; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if ((overflow === "auto" || overflow === "scroll") && + node.scrollHeight > node.clientHeight + 1) return node; + } + return null; + }, + focusablesReachable() { const offenders = this.visibleControls() .filter(el => { @@ -225,6 +245,7 @@ const LayoutAudit = { const b = el.getBoundingClientRect(); return b.top >= innerHeight || b.bottom <= 0 || b.left >= innerWidth || b.right <= 0; }) + .filter(el => !this.scrollableAncestor(el)) .map(el => `${this.describe(el)} :: fixed and off-screen, so nothing can scroll to it`); return { name: "no control is parked where scrolling can't reach it", offenders };