diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index bc405a4..a1633d1 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1032,25 +1032,56 @@ button, color: var(--text-muted); } -/* Two lines, not an ellipsis. - A 96px puck fits about nine characters on a line, so "Christopher" came out as - "Chri…" and "Mike from work" as "Mike fro…" — and "from work" is the half a dealer - typed to tell two Mikes apart, so the ellipsis was eating exactly the part that - carried the information. Wrapping keeps both, and a name is the one thing on the - felt read at a glance rather than looked up. */ +/* ---- The puck is a stack of rows, and every row is the height of a row ---- + + Nine chairs on a ring, and until this block no two of them were the same size. A puck + was as tall as whatever happened to be written in it: measured at 1280x900 with five + players seated, 64px for Bob, 70 for Christopher because his name wrapped, 71 for + Alexandra because she had chips out, 89 for "Mike from work" who had both. Twenty-five + pixels of difference around one ring, and the ragged edge was the smaller half of it. + + The larger half is that the sizes *moved*. A puck grew 17px the moment its player put + money in and shrank again when the street closed, so the felt reflowed twice a hand + under a dealer's thumb — and the ring is sized against the tallest puck on it, which + meant every one of those changes could resize the whole table. + + Three rows are held open instead: the name, the amount, and the stack. Each is a token + rather than an em, so an amount that steps its type down to fit (see .puckAmountLong) + shortens its digits and not its row. What the puck says changes all night; how big it + is does not. + + Sized so it costs nothing. The ring already reserved room for the tallest puck at every + seat — measuredSeatBox() takes the maximum, on purpose — so drawing the short ones at + 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. */ +#seatsLayer { + --seatRowName: 15px; /* 12px of name at 1.2 */ + --seatRowMoney: 14px; /* 10px of figures at 1.4 */ + --seatNameSize: var(--fs-sm); +} + +/* One line, and the ellipsis is not what saves it. + A 96px puck fits about nine characters, so "Christopher" came out as "Chri…" and + "Mike from work" as "Mike fro…" — and "from work" is the half a dealer typed to tell + two Mikes apart, so the ellipsis was eating exactly the part that carried the + information. The second line was the first answer to that. fitSeatNames() is the + better one: it drops whole trailing words, so the same name becomes "Mike", which is + what the table calls him and reads at a glance rather than being looked up. + + The second line is what made the pucks different sizes from each other, and its cost + was never only the pixels. See the row heights below. */ .seatPuckName { - /* Not shrunk to buy a line. Dropping a step did fit another character and a half, - and still didn't get "Christopher" onto one line on the button seat, where the - dealer chip takes 22px of the row — so it bought nothing and cost legibility on - the one label a dealer reads at a glance across a table. The name stays the size - it was; the wrap deals with the length. */ - font-size: var(--fs-sm); + /* The size a name is read at. Dropping a word is what deals with length first, + because a word is a unit a dealer recognises and half a step of type is not; the + type only moves for the name with no word left to drop — see .puckNameTight. */ + font-size: var(--seatNameSize); font-weight: 600; color: var(--text-bright); display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 2; - line-clamp: 2; + -webkit-line-clamp: 1; + line-clamp: 1; overflow: hidden; /* A single word longer than the puck has nothing to break on. `anywhere` was the first try and it gave "Christo / pher", which reads as a rendering fault; with @@ -1066,72 +1097,46 @@ button, overflow-wrap: break-word; hyphens: auto; line-height: 1.2; - /* Deliberately no reserved second line. Reserving one made every puck the height of - the tallest — measured, 69-73px became a uniform 88 — and 15px added to all nine - seats is fifteen more pixels of felt spent on chrome, which is the thing this - round is trying to give back. A ragged ring is cheaper than a crowded one, and - the ring was already ragged by 4px before any of this. */ + /* The row is as tall as the row, whether or not there is a name in it — see the + token block above .seatPuckStack. An open chair's blank label held no height at + all, so the one puck on the ring with nobody in it was the one puck a different + size from the rest. */ + min-height: var(--seatRowName); margin-top: var(--sp-2); } /* The button's chip sits in this puck's top-right corner, on a layer above the text, so - the name has to keep out from under it. + the name has to keep out from under it. The chip, plus the 3px it is inset by. - It used to do that with `padding-right: 22px`, which takes the chip's width off every - line of the name — and the chip is on one of them. Measured at 1280x900: the name box - is 78px wide, "Christopher" is 66px and fits, and on the seat holding the button it - was cut to 56px and broke as "Christop / her". Mid-word, and no hyphen (see below). - The button moves every hand, so that is a player whose name reads properly for four - hands and comes apart on the fifth. On a Pixel 7 it went to three lines and the clamp - ate the end of it. + A float used to do this on the roomier rings — three rules of it, indenting the name's + first line and letting a long one drop below the chip onto a second. That answer only + existed because there was a second line to drop to. With one line there is nowhere to + go, and a float would clip the name to nothing rather than shorten it: measured, the + float plus a one-line clamp drew "Christopher" as an empty label, because the word + cannot fit beside the chip and its whole line is then hidden. - A float is what indents only the lines it sits beside, which is the whole of the - problem. Line one gets 56px and the rest get 78, so a name too long to fit beside the - chip drops below it whole instead of being broken to fit next to something that isn't - there. A short name still sits up beside the chip exactly as it did. - - The padding stays for the crowded ring, and it has to. A tight puck gives the name one - line on purpose — see `.seatsTight .seatPuckName` — and one line has nowhere to move - to, so the only way to keep it off the chip there is to make the line shorter. Trying - the float on both cost a whole sweep to find out: with two lines granted, the name - dropped below the float onto a second line that the chip's lower half was still - sitting on, and the audit called it at every viewport in the list. - - So the float is for the ring that has room for two lines, and the padding is for the - one that doesn't. Two rules, matching the two regimes the puck already had. */ + So the padding is the rule at every size, which is what the crowded ring always used. + What made padding unacceptable before is fixed where it belongs instead: at 1280x900 + the name box is 78px, "Christopher" is 66, and the button seat cuts it to 56 — so it + was broken mid-word as "Christop / her" for the one hand in nine that player has the + button. It now steps its type down and stays whole. See .puckNameTight. */ .seat.seatHasButton .seatPuckName { - padding-right: 22px; + padding-right: 25px; } -/* Two things have to go with the float. It does nothing inside `display: -webkit-box`, - so the clamp is replaced by the same two lines held as a max-height. And - `overflow-wrap: break-word` has to stand down, because it is what stops the float - working: it breaks a word to fit the short line rather than letting it move past the - float to the long one, which is the whole manoeuvre. +/* The name with no word left to drop. - Measured, at the name-box widths a roomy button seat gets: + fitSeatNames() shortens "Mike from work" to "Mike", which is what the table calls him. + It can do nothing at all with "Christopher", and what was left for that name was the + ellipsis — "Christop…" — on a felt that may well also have a Christine at it. - 78px (desktop, iPad) "Christopher" was 2 lines of 54+12 now 1 line of 66 - 78px (desktop, iPad) "Katarzyna" was 2 lines of 49+6 now 1 line of 56 - - The float is one line tall, which is the height it has to be: tall enough to cover the - chip's reach into the name — the chip's bottom edge is 26px down and the name's first - line ends at 27.4 — and no taller, because a float that spills into the second line - indents that one too and puts the whole problem back. */ -#seatsLayer:not(.seatsTight) .seat.seatHasButton .seatPuckName { - padding-right: 0; - display: block; - max-height: 2.4em; - overflow: hidden; - overflow-wrap: normal; -} - -#seatsLayer:not(.seatsTight) .seat.seatHasButton .seatPuckName::before { - content: ""; - float: right; - /* The chip, plus the 3px it is inset by. */ - width: 25px; - height: 1.2em; + This is the rule the amounts have had all along, applied to the other thing a dealer + reads across a table: shrink rather than abbreviate, because a name read wrong is a + worse failure than a name read small. One step only, and floored, because past about + 9px it stops being legible from across a table and an honest ellipsis is better than + an unreadable word. The row it sits in does not move — see --seatRowName. */ +.seatPuckName.puckNameTight { + font-size: max(9px, calc(var(--seatNameSize) * 0.85)); } .seatPuckCards { @@ -1156,8 +1161,17 @@ button, background: rgba(9, 255, 0, 0.1); border-radius: var(--r-pill); padding: 0 var(--sp-1); - min-height: 1.1em; + /* The token, not an em: .puckAmountLong steps this pill's type down so a tournament + stack keeps every digit, and an em would have taken the row down with it. */ + min-height: var(--seatRowMoney); line-height: 1.4; + /* An inline-block sits on its own last line's baseline, and an empty one has no + last line — so it sits on its bottom edge instead and the line box comes out 2px + shorter than the same pill with a figure in it. Which put the two seats that had + money out 2px below everyone else: the one difference this row was reserved to + remove, reappearing as its own mirror image. Aligned to the top, the pill is the + same box whether or not there is anything written in it. */ + vertical-align: top; } /* Neither row has anything in it most of the time, and both were holding their space @@ -1176,15 +1190,32 @@ button, less height to fit on the ring. `:empty` is exact here: both rows are written by updatePuckContent, which sets an - empty string rather than a space when there is nothing to say. What the collapse - needs from the layout is that the ring re-measures when one of them fills — see - seatShapeSignature() in render.js, which now names both. */ + empty string rather than a space when there is nothing to say. + + The cards row still collapses, and the amount pill no longer does. They are not the + same case, and the difference is how often each one changes. + + Hole cards are a whole-table decision — a dealer either tracks them or doesn't, and + for most tables the row is empty from the first hand to the last. Holding 15px open on + nine seats all night for a feature the table isn't using is the crowding that comment + above is about. + + Chips are the opposite. Somebody has money out for most of every hand, and *which* + seats changes on every action. Collapsing that row is what made a puck grow 17px when + its player called and shrink again when the street closed, under the thumb of someone + trying to tap it — and because the ring is sized against the tallest puck, the whole + table could resize with it. The pill keeps its row and hides its paint instead, so the + lozenge that comment is about never appears and the row underneath never moves. + + Visibility rather than display, because that is the difference between a box that + holds its place and a box that isn't there. It leaves the accessibility tree either + way, which is right: there is nothing to announce. */ .seatPuckCards:empty { display: none; } .seatPuckTotal:empty { - display: none; + visibility: hidden; } .dealerChip { @@ -1325,6 +1356,17 @@ button, padding take it from 15px to 12, which clears the name at 5px of overhang instead of 9 — and four pixels off what every seat is charged is a whole size step of puck back. The label is the same 10px bold it always was; what went was the air around it. */ +/* How far it hangs off the edge, and the number matters in two places at once: this is + what render.js's SEAT_BADGE_OVERHANG_PX reserves between two seats on the ring, so the + two have to keep agreeing. seatFootprint.test.js holds them to it. + + Hanging it further out was tried, to stop it reaching into the writing on its own puck, + and it is the wrong direction: on the crowded rings the badge that lands on a stack + figure is the one belonging to the seat *next door*, so every pixel further out is a + pixel further onto a neighbour. Measured on a Galaxy S8 twelve-handed with everyone + all-in, -8px took the worst case from four of seven probes across an "ALL IN" to five. + The room a crowded puck needs comes out of its own leading instead — see + --seatRowMoney on .seatsTight. */ .blindBadge { display: none; position: absolute; @@ -1442,12 +1484,29 @@ button, hasn't got — it already gives up the hole cards to fit — and a puck that grew taller here would push its neighbours into each other, which is the one thing the tight layout exists to stop. */ +/* The clamp used to be here, because this was the only ring that gave the name one line. + Every ring does now, so what is left is the type and the tighter row it sits in. */ +/* Tighter leading on both money rows, which is where a crowded ring pays for holding + the amount row open. 1.4 is a comfortable line for a figure with room around it and + this ring has none: a 10px figure is 12px of ink, and the extra 2px was blank. + + Four pixels off the puck, and it is the four that keep this ring where it was. The + Galaxy S8 twelve-handed is the tightest table in the sweep — the pucks are already at + the 44px floor there — and at 58px tall the neighbouring seats' badges came down onto + each other's stack figures. At 54 they don't, which is the height that table's pucks + measured before the amount row was held open at all. */ +#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); +} + +.seatsTight .seatPuckTotal { + line-height: 1.2; +} + .seatsTight .seatPuckName { margin-top: var(--sp-1); - font-size: var(--fs-2xs); - -webkit-line-clamp: 1; - line-clamp: 1; - min-height: 0; text-overflow: ellipsis; } @@ -1480,8 +1539,10 @@ button, Steps on the scale rather than a clamp(), for the same reason the tight set is: the width is a continuum and the type is a set of deliberate sizes. */ -.seatsRoomy .seatPuckName { - font-size: var(--fs-md); +#seatsLayer.seatsRoomy { + --seatRowName: 18px; /* 15px of name at 1.2 */ + --seatRowMoney: 17px; /* 12px of figures at 1.4 */ + --seatNameSize: var(--fs-md); } .seatsRoomy .seatPuckStack, @@ -3676,6 +3737,9 @@ body.hasActionBar #holdemApp { font-variant-numeric: tabular-nums; margin-top: var(--sp-0); white-space: nowrap; + /* Held open on the empty chairs too, so an open seat is the same puck as a taken + one with nothing written in it — which is what an open seat is. */ + min-height: var(--seatRowMoney); } /* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index c6ad5c8..46cae0b 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -631,10 +631,11 @@ const Render = { SEAT_CLEARANCE_PX: 4, // And between a puck's inner edge and the chip sitting in front of it. BET_CLEARANCE_PX: 5, - // How far a blind badge hangs off the puck it belongs to — the `top: -9px` and - // `bottom: -9px` in holdem.css. It points away from the table at both ends, so it - // takes no room between two seats; what it must not do is reach *sideways*, which is - // what seatFootprint.test.js holds the stylesheet to. + // How far a blind badge hangs off the puck it belongs to — the `top` and `bottom` + // on .blindBadge in holdem.css, which seatFootprint.test.js holds this to. It points + // away from the table at both ends; what it must not do is reach *sideways*, which + // is the other thing that test checks. + // SEAT_BADGE_OVERHANG_PX: 5, // How much space two neighbouring seats need between their centres. @@ -792,29 +793,36 @@ const Render = { return widths; }, - // What the table has changed about the shape of its pucks. Anything that can make a - // name wrap to a second line belongs here, because the height that comes back from - // measuring is what the ring is sized against. + // What the table has changed about the shape of its pucks — which is now almost + // nothing, and that is the point. // - // The hole-card line and the amount pill belong here for the same reason, now that - // an empty one collapses instead of holding its space: a puck with either of them - // filled is about 15px taller than the same puck without, and a ring still sized - // from the measurement taken before the first chips went in is sized for a puck - // that no longer exists. + // This used to name every player's name, the dealer's seat, whether anybody had hole + // cards and whether anybody had chips out, because each of those could change how + // tall a puck was and the ring is sized against the tallest puck on it. Four things, + // three of which flip during an ordinary hand, and every flip is a ten-candidate + // re-search: ten widths applied and read back, ten forced layouts, on a phone, while + // somebody is waiting to know whether the bet is called. // - // Booleans over the whole table rather than a list per seat, because that is what - // the measurement is — measuredSeatBox takes the tallest puck on the ring, so what - // matters is whether *anybody* has one. A boolean also flips about twice a hand - // rather than on every action, and each flip costs ten forced layouts, which is the - // expense this cache exists to avoid. + // A puck's height no longer depends on any of them. The name gets one line whatever + // it says, the amount pill holds its row whether or not there is an amount in it, and + // the button's chip changes a padding rather than a line count — see the row-height + // tokens in holdem.css. What is left is the hole-card row, which still collapses when + // the table isn't tracking cards, because that is a decision a table makes once and + // not a thing that changes under a dealer's thumb. + // + // A boolean over the whole table rather than a list per seat, because that is what + // the measurement is: measuredSeatBox takes the tallest puck on the ring, so what + // matters is whether *anybody* has one. + // + // Being wrong here is a stale ring rather than a wrong one, and it would not stay + // wrong: renderSeats measures the finished pucks against the box the search settled + // on and asks again if they no longer match. That check is what catches a fifth thing + // nobody thought of, and it is what catches the first render of all — where the pucks + // are built empty and the search that used to run against them was measuring nine + // blank boxes and caching the answer under this table's key. seatShapeSignature() { if (!this.state) return ""; - const seats = this.state.seats; - const anyCards = seats.some(p => (p.cards || []).some(Boolean)); - const anyAmount = seats.some(p => Betting.handTotal(p) > 0 || - HoldemState.computeContribution(p, this.state.chipLegend, this.state.basePotAmount) > 0); - return `${seats.map(p => p.name || "").join("")}|${this.state.dealerSeatIndex}` + - `|${anyCards ? "c" : ""}${anyAmount ? "a" : ""}`; + return this.state.seats.some(p => (p.cards || []).some(Boolean)) ? "c" : ""; }, // Which width the pucks get, and how wide a ring they sit on. @@ -871,14 +879,19 @@ const Render = { this.applySeatWidth(layer, width); const puck = this.measuredSeatBox(); if (!puck) { - // Nothing laid out yet — the very first render. Take the widest and let - // the resize that follows do this properly. - const radii = this.seatRingRadii(); - if (key) this.seatLayoutCache = { key, width, radii }; - return { width, radii }; + // Nothing on the felt to measure at all. Take the widest and say nothing + // about it — no `puck`, so renderSeats' second pass knows this answer was + // not measured and asks again once there is something to measure. + // + // It used to cache this one, on the reasoning that "the resize that + // follows" would put it right. Nothing resizes a phone. A table opened + // upright kept a ring measured before it had any seats on it until the + // dealer turned the phone sideways and back. + return { width, radii: this.seatRingRadii() }; } candidates.push({ width, + puck, contained: this.seatRingRadii(puck), footprint: this.seatFootprint(puck), tooTall: this.tooTallForTheTable(), @@ -896,7 +909,10 @@ const Render = { rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t, ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * t, }; - if (fits(candidate, radii)) { chosen = { width: candidate.width, radii }; break; } + if (fits(candidate, radii)) { + chosen = { width: candidate.width, radii, puck: candidate.puck }; + break; + } } } @@ -905,7 +921,11 @@ const Render = { // so out loud rather than the layout quietly papering over it. if (!chosen) { const last = candidates[candidates.length - 1]; - chosen = { width: last.width, radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY } }; + chosen = { + width: last.width, + radii: { rx: this.RING_MAX_RX, ry: this.RING_MAX_RY }, + puck: last.puck, + }; } this.applySeatWidth(layer, chosen.width); @@ -1408,7 +1428,7 @@ const Render = { // these read the layout back, so they are taken once, here, before the loop // starts writing to it: asked per seat they would force the browser to // recompute the layout on every iteration. - const { radii } = this.resolveSeatLayout(count); + let { radii, puck } = this.resolveSeatLayout(count); const aspect = this.feltAspect(); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); // A button on an empty chair normally means the table has drifted and wants @@ -1475,8 +1495,31 @@ const Render = { }); this.fitSeatNames(layer); - // The names have just changed how tall some of these pucks are, so this comes - // after them: a two-line name shortened to one line is 15px of height back. + + // 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 + // rows the stylesheet holds open, and none of the writing above changes it. + // + // Twice they don't. The first render of all builds nine empty pucks and then + // fills them, so a search run against that measured blank boxes; and any + // assumption in seatShapeSignature that turns out to be wrong lands here too. + // Both used to be permanent, because the answer was cached under a key naming a + // table the measurement had never seen: a phone that never resizes kept a ring + // sized for empty seats for the whole session. + // + // So the finished pucks are measured against the ones the ring was built from, + // and if they have moved the search is asked again with the real thing in front + // of it. Once. The second answer is cached and the render after this one reads it + // back, which is what stops this being a loop. + if (this.seatBoxMoved(puck)) { + this.seatLayoutCache = null; + ({ radii, puck } = this.resolveSeatLayout(count)); + for (let index = 0; index < layer.childElementCount; index++) { + this.placeSeat(layer.children[index], index, aspect, radii); + } + this.fitSeatNames(layer); + } + const settled = this.settleSeatsOnTable(layer, aspect, radii); // Kept so the board can ask for the chips to be laid out again if placing it @@ -1485,6 +1528,23 @@ const Render = { this.renderBets(aspect, settled); }, + // Are the pucks on the felt still the puck the ring was measured for? + // + // A pixel of slack, the same as settleSeatsOnTable's, because the two measurements + // are taken at different moments and a sub-pixel difference between them is a + // rounding artefact rather than a table that has changed shape. Without it this + // would ask the search to run again on every render for the rest of the session. + // + // No box at all means the search never measured one — the first render, before the + // pucks were filled in. That is exactly the case worth asking again about. + seatBoxMoved(puck) { + if (!puck) return true; + const now = this.measuredSeatBox(); + if (!now) return false; + return Math.abs(now.width - puck.width) > this.SEAT_SETTLE_SLACK_PX || + Math.abs(now.height - puck.height) > this.SEAT_SETTLE_SLACK_PX; + }, + // One seat, onto the ring. placeSeat(node, index, aspect, radii) { const pos = this.seatPosition(index, this.state.seatCount, aspect, radii); @@ -1572,13 +1632,20 @@ const Render = { // // So a name that overflows drops whole trailing words until it fits: "Mike from // work" becomes "Mike from", then "Mike", and stops there with a real word rather - // than a fragment. A single word that still doesn't fit is left to the ellipsis it - // always had — there is no honest way to shorten "Christopher" that a dealer - // glancing across a table would read faster than "Christo…". + // than a fragment. // - // Written as three passes over all the seats rather than a loop per seat: each pass - // writes to every puck and then reads back from every puck, so the browser lays the - // felt out three times whatever the table's size. Interleaved, nine seats would + // What is left when there is no word to drop is one word too long for its puck, and + // the ellipsis used to be the end of the story: "Christo…", which is a name only + // until a Christine sits down. The amounts on the same puck have never accepted that + // trade — see fitAmount, which steps a tournament stack's type down rather than + // rounding it off — and a misread name is the same kind of failure as a misread + // stack. So the last resort before the ellipsis is one step of type. Every name that + // has one, not only the ones with a spare word, because "Christopher" is exactly the + // case this is for. + // + // Written as passes over all the seats rather than a loop per seat: each pass writes + // to every puck and then reads back from every puck, so the browser lays the felt out + // a fixed number of times whatever the table's size. Interleaved, nine seats would // have cost nine layouts, and this runs on every render. fitSeatNames(layer) { if (!layer) return; @@ -1589,21 +1656,37 @@ const Render = { if (!el || !el.textContent) return; const full = (player.name || "").trim(); // The tooltip is the whole name whether or not this one gets shortened: a - // label that wraps to two clamped lines was already hiding some of it. + // label clamped to its one line was already hiding some of it. el.title = full; - const words = full.split(/\s+/).filter(Boolean); - if (words.length > 1) labels.push({ el, words, kept: words.length }); + // Put back whatever a previous run of this took off, so what gets measured + // below is the name against the puck it has now. Normally a no-op — + // updatePuckContent has just written the full name — and not a no-op on the + // second pass of a render that had to resolve the ring twice, which is the + // one time a puck's width changes underneath a name already fitted to it. + if (el.textContent !== full) el.textContent = full; + // Cleared before anything is measured, so a name that fitted last render at + // a smaller size is measured at the size it is meant to be read at. Left on, + // it would be a ratchet: once a puck went small it could never come back. + el.classList.remove("puckNameTight"); + labels.push({ el, words: full.split(/\s+/).filter(Boolean), kept: 0 }); }); if (!labels.length) return; + labels.forEach(label => { label.kept = label.words.length; }); for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) { const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el)); - if (!shorten.length) return; + if (!shorten.length) break; shorten.forEach(label => { label.kept = this.dropAWord(label.words, label.kept); label.el.textContent = label.words.slice(0, label.kept).join(" "); }); } + + // One read for the whole table, then one write, in that order — the same reason + // the loop above is written in passes. + labels + .filter(label => this.overflowing(label.el)) + .forEach(label => label.el.classList.add("puckNameTight")); }, // Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index 5a2d260..f05a0f6 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -51,11 +51,13 @@ const valuesIn = (properties, source = body) => { describe("the spacing scale", () => { // Hairlines and structural clearances, each one deliberate: - // 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat - // puck's name gives the dealer button beside it. + // 0px/1px/3px/4px/25px — borders, hairline offsets, and the 25px of room a seat + // puck's name gives the dealer button beside it: the chip's + // own 22px plus the 3px it is inset by, which theRail's + // "as wide as the chip it stands in for" holds it to. // 5rem/9rem — how far the page has to scroll past the fixed action // bar. Not rhythm; clearance for a thing of a known size. - const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]); + const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "25px", "5rem", "9rem"]); test("every gap, margin and padding comes off the scale", () => { const offenders = valuesIn( @@ -79,7 +81,10 @@ describe("the type scale", () => { // 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. - const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]); + // 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"]); test("every font-size comes off the scale", () => { const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value)); diff --git a/tests/holdem/seatPuckShape.test.js b/tests/holdem/seatPuckShape.test.js index 2d5d481..069e8e2 100644 --- a/tests/holdem/seatPuckShape.test.js +++ b/tests/holdem/seatPuckShape.test.js @@ -1,22 +1,28 @@ // What the ring is allowed to be sized from. // -// Two rows in every seat puck are usually empty — the hole-card line, and the pill that -// shows what a player has put in. Both used to hold their space anyway, and the pill, -// which has a background and a pill radius and side padding, painted an 8x11px green -// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary -// table, a coloured mark on almost every seat standing for nothing. +// resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each +// applied to the felt and read back — and caches it against this signature. So the +// signature has to move whenever a puck's height can, and must not move otherwise: one +// that changed on every action would throw the cache away on every tap, which is the +// whole cost it exists to avoid. // -// The space was not free either. seatRingRadii sizes the ring from the tallest puck on -// it, so two rows nobody had filled were setting how far apart the seats had to sit. On -// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for -// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8 -// the seats overlapped by 1556px² and now overlap by 87px². +// It used to name four things: every player's name, the dealer's seat, whether anybody +// had hole cards, and whether anybody had chips out. Three of those flip during an +// ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the +// next street closing, and so did editing a name. // -// The collapse is only safe if the layout re-measures when one of those rows fills, -// because resolveSeatLayout caches its ten forced layouts against this signature. That -// is what is checked here: the cache key has to move when the puck's shape can, and -// stay still when it cannot — a signature that changed on every action would throw the -// cache away on every tap, which is the cost it exists to avoid. +// None of them can change a puck's height any more. The stylesheet holds three rows open +// — name, amount, stack — at a height that does not depend on what is written in them: +// the name gets one line whatever it says, the amount pill keeps its row when it is +// empty, and the dealer's chip changes a padding rather than a line count. What is left +// is the hole-card row, which still collapses, because whether a table tracks hole cards +// is something it decides once rather than something that changes under a thumb. +// +// The other half of that bargain is in renderSeats: it measures the finished pucks +// against the box the search settled on and asks again if they disagree. That is what +// makes a wrong assumption here a stale frame rather than a stale session — and it is +// what catches the first render of all, where the pucks are built empty and the search +// that used to run against them was measuring nine blank boxes. const test = require("node:test"); const { describe } = require("node:test"); const assert = require("node:assert"); @@ -32,37 +38,39 @@ const signatureOf = state => { const table = () => makeTable({ seatCount: 6, stack: 200 }); describe("the signature the ring layout is cached against", () => { - test("moves when the first chips go in", () => { - // Before the blinds every pill is empty and the pucks are short; after them two - // seats are ~15px taller. A ring still sized from the first measurement is sized - // for a puck that no longer exists. - const state = table(); - const empty = signatureOf(state); - state.seats[1].committed = 10; - assert.notEqual(signatureOf(state), empty, - "the ring would still be sized for pucks with no amount on them"); - }); - - test("and when a hole card is entered", () => { + test("moves when a hole card is entered", () => { + // The one row still allowed to collapse, and the one thing still worth + // re-measuring for: a puck with cards on it is about 15px taller. const state = table(); const none = signatureOf(state); state.seats[0].cards = ["AS", "KC"]; assert.notEqual(signatureOf(state), none); }); - test("but not again for the second player to put chips in", () => { - // The whole point of a boolean over the table. The ring is sized from the - // tallest puck on it, so once anybody has an amount the answer cannot change — - // and re-measuring here would mean ten forced layouts on every single action. + test("but not again for the second player to be dealt one", () => { + // The point of a boolean over the table. The ring is sized from the tallest puck + // on it, so once anybody has cards the answer cannot change. const state = table(); - state.seats[1].committed = 10; + state.seats[0].cards = ["AS", "KC"]; const first = signatureOf(state); - state.seats[2].committed = 10; - assert.equal(signatureOf(state), first); - state.seats[3].committed = 250; + state.seats[1].cards = ["2H", "7D"]; assert.equal(signatureOf(state), first); }); + test("and it stands still while the chips move", () => { + // The expensive one. Somebody has money out for most of every hand and which + // seats it is changes on every action, so this used to be a ten-layout re-search + // twice a hand — and the pucks it was re-measuring for are the same height now + // whether there is an amount in them or not. + const state = table(); + const quiet = signatureOf(state); + state.seats[1].committed = 10; + assert.equal(signatureOf(state), quiet, + "the amount pill holds its row whether or not it has an amount in it"); + state.seats[2].committed = 250; + assert.equal(signatureOf(state), quiet); + }); + test("nor when an amount merely changes size", () => { const state = table(); state.seats[1].committed = 10; @@ -72,54 +80,163 @@ describe("the signature the ring layout is cached against", () => { "a wider number is the amount fitter's problem, not the ring's"); }); - test("and it still moves for the things it always moved for", () => { - // Renaming can wrap a name onto a second line, and the button's chip floats - // into the name's first line, so both change how tall a puck measures. + test("and it stands still for a rename", () => { + // Renaming used to be able to wrap a name onto a second line. A name gets one + // line now, and one that does not fit drops a word or steps its type down — see + // fitSeatNames. Neither moves the row it sits in. const state = table(); const base = signatureOf(state); state.seats[0].name = "Bartholomew Fairweather"; - const renamed = signatureOf(state); - assert.notEqual(renamed, base); - state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length; - assert.notEqual(signatureOf(state), renamed); + assert.equal(signatureOf(state), base); }); - test("and two tables that differ only in where a name is split stay different", () => { - // The names are joined on a control character no name can contain. Joined on - // nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those - // tables would be laid out with the other's measurements — different names, so - // different wrapping, so different puck heights. - // - // The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can - // never collide: the capital letters land in different places, so it passed with - // the separator taken out and proved nothing at all. Lower case is what makes it - // a real test. - const a = table(); - const b = table(); - a.seats[0].name = "ann"; a.seats[1].name = "abel"; - b.seats[0].name = "anna"; b.seats[1].name = "bel"; - assert.notEqual(signatureOf(a), signatureOf(b)); + test("and for the button moving round the table", () => { + // The chip in the puck's corner takes 22px off the name's line. That is a + // narrower name, not a taller puck, so the ring it sits on is the same ring. + const state = table(); + const base = signatureOf(state); + state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length; + assert.equal(signatureOf(state), base); }); }); describe("a table nobody has sat down at", () => { - test("has no amounts and no cards on it", () => { + test("carries no flag at all", () => { // The empty-table case is the one the collapse is most visible on, and it is - // also the one where a stray default could quietly keep both rows open forever. + // also the one where a stray default could quietly keep the row open forever. const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, 9); - assert.match(signatureOf(state), /\|$/, - "an untouched table should carry neither flag"); + assert.equal(signatureOf(state), "", + "an untouched table should carry nothing"); }); - test("but a table with an ante on it does have an amount", () => { - // computeContribution answers for a seat that has never been given a stack — - // the table's base pot stands in for it — so a table dealt with "everyone antes - // 5" has amounts on its pucks before anybody acts, and the ring has to know. + test("and neither does one with an ante on it", () => { + // computeContribution answers for a seat that has never been given a stack — the + // table's base pot stands in for it — so a table dealt with "everyone antes 5" + // has amounts on its pucks before anybody acts. That used to have to be in here. + // The pucks are the same height with it as without, so it does not any more. const state = HoldemState.createDefault(); HoldemState.resizeSeats(state, 6); state.basePotAmount = 5; state.seats.forEach(p => { p.inHand = true; }); - assert.match(signatureOf(state), /a$/); + assert.equal(signatureOf(state), ""); + }); +}); + +// --------------------------------------------------------------------------- + +// The other half: the stylesheet has to actually hold those rows open, or the signature +// above is a promise about a puck that doesn't exist. +// +// Read as text, the way the seat sheet's rules and the reduced-motion rules are. What +// height a row is, is a decision that lives in the file — the browser is where it is +// proved, and runUiAudit.js is what proves it there. +const fs = require("fs"); +const path = require("path"); + +const CSS = fs.readFileSync( + path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8"); + +const ruleFor = selector => { + const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const found = CSS.match(new RegExp(`(^|[},])\\s*${escaped}\\s*\\{([^}]*)\\}`, "m")); + return found ? found[2] : null; +}; + +describe("every puck on the ring is the same puck", () => { + test("each of the three rows is a row high whether or not it says anything", () => { + // A blank label had no height at all, so the one puck with nobody in it was the + // one puck a different size from the rest — and a seat that had put chips in was + // a different size again from one that hadn't. + ["name", "total", "stack"].forEach(row => { + const selector = `.seatPuck${row[0].toUpperCase()}${row.slice(1)}`; + const body = ruleFor(selector) || ""; + assert.match(body, /min-height:\s*var\(--seatRow(Name|Money)\)/, + `${selector} does not hold its row open`); + }); + }); + + test("and the row heights are tokens, not ems", () => { + // fitAmount steps a tournament stack's type down so every digit stays on the + // puck, and .puckNameTight does the same to a name with no word to drop. In ems + // each of those would have taken its row down with it, and the puck with it. + ["--seatRowName", "--seatRowMoney"].forEach(token => + assert.match(CSS, new RegExp(`\\${token}:\\s*\\d+px`), `${token} is not a fixed height`)); + }); + + test("the amount pill hides its paint rather than giving up its place", () => { + // Collapsing it is what made a puck grow 17px when its player called and shrink + // again when the street closed. Painting an empty pill is what collapsing it was + // introduced to stop — an 8x11px green lozenge on seven of nine seats, standing + // for nothing. Neither, now: the row stays and the pill is not drawn. + assert.match(ruleFor(".seatPuckTotal:empty") || "", /visibility:\s*hidden/); + assert.doesNotMatch(ruleFor(".seatPuckTotal:empty") || "", /display:\s*none/); + }); + + test("but the hole-card row still collapses, because a table decides that once", () => { + // Holding 15px open on nine seats all night for a row most tables never fill is + // the crowding this is meant to avoid, and it is the one thing left that the + // signature above still watches for. + assert.match(ruleFor(".seatPuckCards:empty") || "", /display:\s*none/); + }); + + test("an empty pill sits on its top edge, not on a baseline it hasn't got", () => { + // An inline-block sits on its last line's baseline, and an empty one has no last + // line — so it sits on its bottom edge instead and the line box comes out 2px + // shorter than the same pill with a figure in it. That put the seats with money + // out 2px below everyone else: the one difference this row exists to remove, + // reappearing as its own mirror image. + assert.match(ruleFor(".seatPuckTotal") || "", /vertical-align:\s*top/); + }); +}); + +// --------------------------------------------------------------------------- + +describe("and the ring checks its own measurement afterwards", () => { + // The signature above is an assumption about what can change a puck. This is what + // happens when the assumption is wrong, and it is also what happens on the first + // render of all: buildSeatNodes puts nine empty pucks on the felt and the search runs + // before anything is written into them, so it was measuring nine blank boxes and + // caching the answer under a key naming a table it had never seen. + // + // That used to be permanent. The comment where it was cached said "let the resize + // that follows do this properly" — and nothing resizes a phone. A table opened + // upright kept a ring measured for empty seats until the dealer turned the phone + // sideways and back, which is the only thing that ever changed the key. + const withBox = (box, run) => { + const before = Render.measuredSeatBox; + Render.measuredSeatBox = () => box; + try { return run(); } finally { Render.measuredSeatBox = before; } + }; + + test("a layout that measured nothing is always asked again", () => { + // No box on the answer means the search never had a puck in front of it. + assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(null)), true); + assert.equal(withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved(undefined)), true); + }); + + test("one that measured the puck that is now on the felt is not", () => { + assert.equal( + withBox({ width: 72, height: 56 }, () => Render.seatBoxMoved({ width: 72, height: 56 })), + false); + }); + + test("and a pixel of difference is a rounding artefact, not a table that moved", () => { + // The two measurements are taken at different moments. Without the slack this + // would ask the search to run again on every render for the rest of the session, + // which is ten forced layouts a tap — the exact cost the cache exists to avoid. + assert.equal( + withBox({ width: 72.4, height: 56.4 }, () => Render.seatBoxMoved({ width: 72, height: 56 })), + false); + assert.equal( + withBox({ width: 72, height: 58 }, () => Render.seatBoxMoved({ width: 72, height: 56 })), + true, "two pixels taller is a puck that has changed shape"); + }); + + test("nothing on the felt to measure is not a reason to ask again", () => { + // Between a table being resized and the new pucks being built there is a moment + // with no seats at all. Asking again there would clear a good answer and replace + // it with one measured from nothing. + assert.equal(withBox(null, () => Render.seatBoxMoved({ width: 72, height: 56 })), false); }); }); diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js index 4e5c9de..0ee1352 100644 --- a/tests/holdem/theRail.test.js +++ b/tests/holdem/theRail.test.js @@ -115,53 +115,46 @@ describe("the bar lines up with the table", () => { // --------------------------------------------------------------------------- describe("the button seat's name", () => { - // The dealer button's chip sits in the puck's top-right corner. Keeping the name out - // from under it with `padding-right` takes the chip's width off *every* line of the - // name, and the chip is on one of them: measured at 1280x900, the name box is 78px, - // "Christopher" is 66 and fits, and on the seat holding the button it was cut to 56 - // and broke as "Christop / her". The button moves every hand, so that is a player - // whose name reads properly for four hands and comes apart on the fifth. - const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); - const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/); + // The dealer button's chip sits in the puck's top-right corner, and the name has to + // keep out from under it. `padding-right` takes the chip's width off the name's line, + // which is the whole of the answer now that there is only ever one line. + // + // A float used to do this on the roomier rings — three rules of it, indenting the + // first line so a name too long to fit beside the chip could drop below it onto a + // second. That only worked because there was a second line. There isn't: see the row + // heights in holdem.css, and the fitSeatNames tests below for what happens instead to + // a name that does not fit. const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); - test("a ring with room for two lines indents only the line beside the chip", () => { - assert.ok(roomy && float, "the roomy regime should use a float"); - assert.match(float[1], /float:\s*right/); - assert.match(roomy[1], /padding-right:\s*0/); + test("the chip's width comes off the name, at every size", () => { + assert.ok(base, "the base rule should reserve the chip's width"); + assert.match(base[1], /padding-right:\s*25px/); }); - test("the float is exactly one line tall", () => { - // Tall enough to cover the chip's reach into the name, and no taller: a float - // that spills into the second line indents that one too, which puts the whole - // problem back. - assert.match(float[1], /height:\s*1\.2em/); - }); - - test("break-word stands down where the float is doing the work", () => { - // It is what stops the float working — it breaks a word to fit the short line - // rather than letting it move past the float to the long one. - assert.match(roomy[1], /overflow-wrap:\s*normal/); - }); - - test("a crowded ring keeps the padding, because it has only one line", () => { - // `.seatsTight .seatPuckName` is `line-clamp: 1` on purpose, and one line has - // nowhere to move to — the only way to keep it off the chip is to shorten it. - // Trying the float on both cost a whole sweep to find out: the name dropped onto - // a second line that the chip's lower half was still sitting on, and the audit - // called it at every viewport in the list. - assert.ok(base, "the base rule should still reserve the chip's width"); - assert.match(base[1], /padding-right:\s*22px/); - assert.match(CSS, /\.seatsTight \.seatPuckName \{[^}]*line-clamp:\s*1/); - }); - - test("the float is as wide as the chip it stands in for", () => { + test("and it is as wide as the chip it stands in for", () => { const chip = CSS.match(/\n\.dealerChip \{[^}]*?(? { + // Left in place with a one-line clamp it would clip the name to nothing: the + // word cannot fit beside the float, so it moves to a second line, and the second + // line is what the clamp hides. Measured — "Christopher" drew as an empty label. + assert.equal(/\.seatPuckName::before/.test(CSS), false, + "a float and one line of name cannot both be true"); + }); + + test("and no rule anywhere gives a puck's name a second line", () => { + // This is what every puck being the same height rests on. A name is a label, and + // the ring is sized against the tallest puck on it, so one name wrapping is one + // seat setting how far apart all of them have to sit. + const clamps = CSS.match(/(? 0, "the name should still be clamped to something"); + clamps.forEach(clamp => assert.match(clamp, /:\s*1$/, clamp)); }); }); @@ -234,9 +227,17 @@ describe("a name that will not fit", () => { // ladder — which words go, in what order, and when it stops — and the browser's part // is one boolean per pass. const label = (text, fits) => { + const classes = new Set(); const el = { textContent: text, title: "", + // The step of type a name takes when it has no word left to drop, held as + // the same set of strings the real element's classList is. + classList: { + add: name => classes.add(name), + remove: name => classes.delete(name), + contains: name => classes.has(name), + }, // The browser's answer, standing in for a real one: this name fits if // `fits` says the version currently written to it does. get scrollWidth() { return fits(this.textContent) ? 10 : 100; }, @@ -309,13 +310,16 @@ describe("a name that will not fit", () => { assert.equal(Render.dropAWord(["Mike"], 1), 1, "one word has nothing to drop"); }); - test("a single word is left to the ellipsis it always had", () => { - // There is no honest way to shorten "Christopher" that a dealer glancing across - // a table would read faster than "Christo…". + test("a single word steps its type down rather than being cut", () => { + // There is no honest way to shorten "Christopher" at a word, and "Christo…" is a + // name only until a Christine sits down. The amounts on the same puck have never + // taken that trade — fitAmount shrinks a tournament stack rather than rounding it + // — and a misread name is the same kind of failure as a misread stack. const el = label("Christopher", () => false); seatsNamed("Christopher"); Render.fitSeatNames(layer([el])); assert.equal(el.textContent, "Christopher", "never cut mid-word here"); + assert.ok(el.classList.contains("puckNameTight"), "and never left overflowing either"); }); test("a name that fits is not touched", () => { @@ -323,6 +327,32 @@ describe("a name that will not fit", () => { seatsNamed("Mike from work"); Render.fitSeatNames(layer([el])); assert.equal(el.textContent, "Mike from work"); + assert.ok(!el.classList.contains("puckNameTight"), "nothing to shrink for"); + }); + + test("a name shortened until it fits is not also shrunk", () => { + // The step of type is the last resort, not a second helping. "Mike" fits, so it + // is read at the size everything else on the felt is. + const el = label("Mike from work", text => text === "Mike"); + seatsNamed("Mike from work"); + Render.fitSeatNames(layer([el])); + assert.equal(el.textContent, "Mike"); + assert.ok(!el.classList.contains("puckNameTight")); + }); + + test("the step comes off again when the puck it was for is gone", () => { + // Otherwise it is a ratchet: a name that had to shrink on a nine-handed phone + // would still be shrunk on the same table six-handed, or on a desktop, because + // nothing ever measured it at the size it was supposed to be read at. + const el = label("Christopher", () => false); + seatsNamed("Christopher"); + Render.fitSeatNames(layer([el])); + assert.ok(el.classList.contains("puckNameTight")); + + const roomy = label("Christopher", () => true); + roomy.classList.add("puckNameTight"); + Render.fitSeatNames(layer([roomy])); + assert.ok(!roomy.classList.contains("puckNameTight")); }); test("the whole name is on the tooltip whether it was shortened or not", () => {