Make every seat on the ring the same seat, and measure it before believing it

Nine chairs, and until this no two of them were the same size. 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.

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 the thumb of someone trying to tap it
— and because the ring is sized against the tallest puck on it, the whole table could
resize with it.

Three rows are held open instead: the name, the amount, and the stack. The name gets
one line whatever it says, the amount pill keeps its row when it is empty rather than
collapsing, and each row is a token rather than an em so an amount stepping its type
down to fit shortens its digits and not its row. What the puck says changes all night;
how big it is does not.

It costs nothing. measuredSeatBox already took the tallest puck on the ring, on
purpose, so the room was reserved at every seat and the short ones simply weren't
drawn into it. What pays for the third row is the second line of name going away: an
earlier round measured uniform-with-two-lines at 88px and rightly refused it.
Uniform-with-one is 71.

  A name with no word to drop steps its type down instead of ellipsising. fitSeatNames
  turns "Mike from work" into "Mike", which is what the table calls him, and could do
  nothing at all with "Christopher" — so that one was cut to "Christo…" on a felt that
  may also have a Christine at it. The amounts on the same puck have never taken that
  trade; this is fitAmount's rule applied to the other thing a dealer reads across a
  table. One step, floored at 9px, then the ellipsis.

  The float that kept the button seat's name off the dealer chip goes with the second
  line — three rules of it, indenting the first line so a long name could drop below
  the chip onto a second. There is no second line to drop to, and left in place it drew
  "Christopher" as an empty label. The padding every crowded ring already used is the
  rule at all sizes now, widened from 22px to 25 so it actually covers the chip and the
  gap it is inset by.

Then the part that was quietly wrong. renderSeats builds nine empty pucks and fills
them afterwards, so the layout search ran against blank boxes and cached the answer
under a key naming a table it had never measured. On a 390px phone that is a ring
sized for empty seats — and the comment where it was cached said "let the resize that
follows do this properly". Nothing resizes a phone. Turning the phone sideways and
back was the only thing that ever fixed it, and it fixed it into a different layout:
same viewport, same table, 90px pucks on load and 72px after a rotate.

So the finished pucks are measured against the box the search settled on, and if they
disagree the search is asked again with the real thing in front of it. Once, and the
second answer is cached. Fresh load and rotate-and-back now agree exactly.

That also lets the cache key shrink to almost nothing. It used to name every player's
name, the dealer's seat, whether anybody had cards and whether anybody had chips out —
four things, three of which flip during an ordinary hand, each flip a ten-candidate
re-search. None of them can change a puck's height any more. Measured over three calls
in a row: no re-search, and not one of the nine seats moved a pixel.

The crowded ring gives up the 1.4 leading on its money rows to pay for the row it now
holds open — 10px of figures is 12px of ink and the rest was blank. Without it the
Galaxy S8 twelve-handed, where the pucks are already at the 44px floor, put
neighbouring seats' blind badges down onto each other's "ALL IN". Hanging the badges
further out was tried first and is the wrong direction: the badge landing on a stack
figure belongs to the seat next door, so every pixel further out is a pixel further
onto a neighbour.

992 unit tests. 320 layout audits across 13 viewports with a mouse and a thumb: one
failure, the chip behind seat 11 that master already carries.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-10 11:08:14 -06:00
co-authored by Claude Opus 5
parent 3326557043
commit 4a2ec1295b
5 changed files with 536 additions and 237 deletions
+144 -80
View File
@@ -1032,25 +1032,56 @@ button,
color: var(--text-muted); color: var(--text-muted);
} }
/* Two lines, not an ellipsis. /* ---- The puck is a stack of rows, and every row is the height of a row ----
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 Nine chairs on a ring, and until this block no two of them were the same size. A puck
typed to tell two Mikes apart, so the ellipsis was eating exactly the part that was as tall as whatever happened to be written in it: measured at 1280x900 with five
carried the information. Wrapping keeps both, and a name is the one thing on the players seated, 64px for Bob, 70 for Christopher because his name wrapped, 71 for
felt read at a glance rather than looked up. */ 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 { .seatPuckName {
/* Not shrunk to buy a line. Dropping a step did fit another character and a half, /* The size a name is read at. Dropping a word is what deals with length first,
and still didn't get "Christopher" onto one line on the button seat, where the because a word is a unit a dealer recognises and half a step of type is not; the
dealer chip takes 22px of the row — so it bought nothing and cost legibility on type only moves for the name with no word left to drop — see .puckNameTight. */
the one label a dealer reads at a glance across a table. The name stays the size font-size: var(--seatNameSize);
it was; the wrap deals with the length. */
font-size: var(--fs-sm);
font-weight: 600; font-weight: 600;
color: var(--text-bright); color: var(--text-bright);
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
-webkit-line-clamp: 2; -webkit-line-clamp: 1;
line-clamp: 2; line-clamp: 1;
overflow: hidden; overflow: hidden;
/* A single word longer than the puck has nothing to break on. `anywhere` was the /* 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 first try and it gave "Christo / pher", which reads as a rendering fault; with
@@ -1066,72 +1097,46 @@ button,
overflow-wrap: break-word; overflow-wrap: break-word;
hyphens: auto; hyphens: auto;
line-height: 1.2; line-height: 1.2;
/* Deliberately no reserved second line. Reserving one made every puck the height of /* The row is as tall as the row, whether or not there is a name in it — see the
the tallest — measured, 69-73px became a uniform 88 — and 15px added to all nine token block above .seatPuckStack. An open chair's blank label held no height at
seats is fifteen more pixels of felt spent on chrome, which is the thing this all, so the one puck on the ring with nobody in it was the one puck a different
round is trying to give back. A ragged ring is cheaper than a crowded one, and size from the rest. */
the ring was already ragged by 4px before any of this. */ min-height: var(--seatRowName);
margin-top: var(--sp-2); 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 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 A float used to do this on the roomier rings — three rules of it, indenting the name's
line of the name — and the chip is on one of them. Measured at 1280x900: the name box first line and letting a long one drop below the chip onto a second. That answer only
is 78px wide, "Christopher" is 66px and fits, and on the seat holding the button it existed because there was a second line to drop to. With one line there is nowhere to
was cut to 56px and broke as "Christop / her". Mid-word, and no hyphen (see below). go, and a float would clip the name to nothing rather than shorten it: measured, the
The button moves every hand, so that is a player whose name reads properly for four float plus a one-line clamp drew "Christopher" as an empty label, because the word
hands and comes apart on the fifth. On a Pixel 7 it went to three lines and the clamp cannot fit beside the chip and its whole line is then hidden.
ate the end of it.
A float is what indents only the lines it sits beside, which is the whole of the So the padding is the rule at every size, which is what the crowded ring always used.
problem. Line one gets 56px and the rest get 78, so a name too long to fit beside the What made padding unacceptable before is fixed where it belongs instead: at 1280x900
chip drops below it whole instead of being broken to fit next to something that isn't the name box is 78px, "Christopher" is 66, and the button seat cuts it to 56 — so it
there. A short name still sits up beside the chip exactly as it did. 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. */
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. */
.seat.seatHasButton .seatPuckName { .seat.seatHasButton .seatPuckName {
padding-right: 22px; padding-right: 25px;
} }
/* Two things have to go with the float. It does nothing inside `display: -webkit-box`, /* The name with no word left to drop.
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.
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 This is the rule the amounts have had all along, applied to the other thing a dealer
78px (desktop, iPad) "Katarzyna" was 2 lines of 49+6 now 1 line of 56 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
The float is one line tall, which is the height it has to be: tall enough to cover the 9px it stops being legible from across a table and an honest ellipsis is better than
chip's reach into the name — the chip's bottom edge is 26px down and the name's first an unreadable word. The row it sits in does not move — see --seatRowName. */
line ends at 27.4 — and no taller, because a float that spills into the second line .seatPuckName.puckNameTight {
indents that one too and puts the whole problem back. */ font-size: max(9px, calc(var(--seatNameSize) * 0.85));
#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;
} }
.seatPuckCards { .seatPuckCards {
@@ -1156,8 +1161,17 @@ button,
background: rgba(9, 255, 0, 0.1); background: rgba(9, 255, 0, 0.1);
border-radius: var(--r-pill); border-radius: var(--r-pill);
padding: 0 var(--sp-1); 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; 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 /* 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. less height to fit on the ring.
`:empty` is exact here: both rows are written by updatePuckContent, which sets an `: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 empty string rather than a space when there is nothing to say.
needs from the layout is that the ring re-measures when one of them fills — see
seatShapeSignature() in render.js, which now names both. */ 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 { .seatPuckCards:empty {
display: none; display: none;
} }
.seatPuckTotal:empty { .seatPuckTotal:empty {
display: none; visibility: hidden;
} }
.dealerChip { .dealerChip {
@@ -1325,6 +1356,17 @@ button,
padding take it from 15px to 12, which clears the name at 5px of overhang instead of 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. 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. */ 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 { .blindBadge {
display: none; display: none;
position: absolute; 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 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 here would push its neighbours into each other, which is the one thing the tight
layout exists to stop. */ 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 { .seatsTight .seatPuckName {
margin-top: var(--sp-1); margin-top: var(--sp-1);
font-size: var(--fs-2xs);
-webkit-line-clamp: 1;
line-clamp: 1;
min-height: 0;
text-overflow: ellipsis; 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 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. */ width is a continuum and the type is a set of deliberate sizes. */
.seatsRoomy .seatPuckName { #seatsLayer.seatsRoomy {
font-size: var(--fs-md); --seatRowName: 18px; /* 15px of name at 1.2 */
--seatRowMoney: 17px; /* 12px of figures at 1.4 */
--seatNameSize: var(--fs-md);
} }
.seatsRoomy .seatPuckStack, .seatsRoomy .seatPuckStack,
@@ -3676,6 +3737,9 @@ body.hasActionBar #holdemApp {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
margin-top: var(--sp-0); margin-top: var(--sp-0);
white-space: nowrap; 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 /* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
+126 -43
View File
@@ -631,10 +631,11 @@ const Render = {
SEAT_CLEARANCE_PX: 4, SEAT_CLEARANCE_PX: 4,
// And between a puck's inner edge and the chip sitting in front of it. // And between a puck's inner edge and the chip sitting in front of it.
BET_CLEARANCE_PX: 5, BET_CLEARANCE_PX: 5,
// How far a blind badge hangs off the puck it belongs to — the `top: -9px` and // How far a blind badge hangs off the puck it belongs to — the `top` and `bottom`
// `bottom: -9px` in holdem.css. It points away from the table at both ends, so it // on .blindBadge in holdem.css, which seatFootprint.test.js holds this to. It points
// takes no room between two seats; what it must not do is reach *sideways*, which is // away from the table at both ends; what it must not do is reach *sideways*, which
// what seatFootprint.test.js holds the stylesheet to. // is the other thing that test checks.
//
SEAT_BADGE_OVERHANG_PX: 5, SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres. // How much space two neighbouring seats need between their centres.
@@ -792,29 +793,36 @@ const Render = {
return widths; return widths;
}, },
// What the table has changed about the shape of its pucks. Anything that can make a // What the table has changed about the shape of its pucks — which is now almost
// name wrap to a second line belongs here, because the height that comes back from // nothing, and that is the point.
// measuring is what the ring is sized against.
// //
// The hole-card line and the amount pill belong here for the same reason, now that // This used to name every player's name, the dealer's seat, whether anybody had hole
// an empty one collapses instead of holding its space: a puck with either of them // cards and whether anybody had chips out, because each of those could change how
// filled is about 15px taller than the same puck without, and a ring still sized // tall a puck was and the ring is sized against the tallest puck on it. Four things,
// from the measurement taken before the first chips went in is sized for a puck // three of which flip during an ordinary hand, and every flip is a ten-candidate
// that no longer exists. // 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 // A puck's height no longer depends on any of them. The name gets one line whatever
// the measurement is — measuredSeatBox takes the tallest puck on the ring, so what // it says, the amount pill holds its row whether or not there is an amount in it, and
// matters is whether *anybody* has one. A boolean also flips about twice a hand // the button's chip changes a padding rather than a line count — see the row-height
// rather than on every action, and each flip costs ten forced layouts, which is the // tokens in holdem.css. What is left is the hole-card row, which still collapses when
// expense this cache exists to avoid. // 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() { seatShapeSignature() {
if (!this.state) return ""; if (!this.state) return "";
const seats = this.state.seats; return this.state.seats.some(p => (p.cards || []).some(Boolean)) ? "c" : "";
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" : ""}`;
}, },
// Which width the pucks get, and how wide a ring they sit on. // Which width the pucks get, and how wide a ring they sit on.
@@ -871,14 +879,19 @@ const Render = {
this.applySeatWidth(layer, width); this.applySeatWidth(layer, width);
const puck = this.measuredSeatBox(); const puck = this.measuredSeatBox();
if (!puck) { if (!puck) {
// Nothing laid out yet — the very first render. Take the widest and let // Nothing on the felt to measure at all. Take the widest and say nothing
// the resize that follows do this properly. // about it — no `puck`, so renderSeats' second pass knows this answer was
const radii = this.seatRingRadii(); // not measured and asks again once there is something to measure.
if (key) this.seatLayoutCache = { key, width, radii }; //
return { width, radii }; // 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({ candidates.push({
width, width,
puck,
contained: this.seatRingRadii(puck), contained: this.seatRingRadii(puck),
footprint: this.seatFootprint(puck), footprint: this.seatFootprint(puck),
tooTall: this.tooTallForTheTable(), tooTall: this.tooTallForTheTable(),
@@ -896,7 +909,10 @@ const Render = {
rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t, rx: candidate.contained.rx + (this.RING_MAX_RX - candidate.contained.rx) * t,
ry: candidate.contained.ry + (this.RING_MAX_RY - candidate.contained.ry) * 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. // so out loud rather than the layout quietly papering over it.
if (!chosen) { if (!chosen) {
const last = candidates[candidates.length - 1]; 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); 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 // 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 // starts writing to it: asked per seat they would force the browser to
// recompute the layout on every iteration. // recompute the layout on every iteration.
const { radii } = this.resolveSeatLayout(count); let { radii, puck } = this.resolveSeatLayout(count);
const aspect = this.feltAspect(); const aspect = this.feltAspect();
const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state); const { dealerIndex, sbIndex, bbIndex, manual: manualBlinds } = HoldemState.getBlindSeats(this.state);
// A button on an empty chair normally means the table has drifted and wants // A button on an empty chair normally means the table has drifted and wants
@@ -1475,8 +1495,31 @@ const Render = {
}); });
this.fitSeatNames(layer); 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); const settled = this.settleSeatsOnTable(layer, aspect, radii);
// Kept so the board can ask for the chips to be laid out again if placing it // 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); 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. // One seat, onto the ring.
placeSeat(node, index, aspect, radii) { placeSeat(node, index, aspect, radii) {
const pos = this.seatPosition(index, this.state.seatCount, 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 // 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 // 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 // than a fragment.
// always had — there is no honest way to shorten "Christopher" that a dealer
// glancing across a table would read faster than "Christo…".
// //
// Written as three passes over all the seats rather than a loop per seat: each pass // What is left when there is no word to drop is one word too long for its puck, and
// writes to every puck and then reads back from every puck, so the browser lays the // the ellipsis used to be the end of the story: "Christo…", which is a name only
// felt out three times whatever the table's size. Interleaved, nine seats would // 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. // have cost nine layouts, and this runs on every render.
fitSeatNames(layer) { fitSeatNames(layer) {
if (!layer) return; if (!layer) return;
@@ -1589,21 +1656,37 @@ const Render = {
if (!el || !el.textContent) return; if (!el || !el.textContent) return;
const full = (player.name || "").trim(); const full = (player.name || "").trim();
// The tooltip is the whole name whether or not this one gets shortened: a // 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; el.title = full;
const words = full.split(/\s+/).filter(Boolean); // Put back whatever a previous run of this took off, so what gets measured
if (words.length > 1) labels.push({ el, words, kept: words.length }); // 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; if (!labels.length) return;
labels.forEach(label => { label.kept = label.words.length; });
for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) { for (let pass = 0; pass < this.SEAT_NAME_PASSES; pass++) {
const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el)); const shorten = labels.filter(label => label.kept > 1 && this.overflowing(label.el));
if (!shorten.length) return; if (!shorten.length) break;
shorten.forEach(label => { shorten.forEach(label => {
label.kept = this.dropAWord(label.words, label.kept); label.kept = this.dropAWord(label.words, label.kept);
label.el.textContent = label.words.slice(0, label.kept).join(" "); 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 // Words a name can't end on. Dropping "work" off "Mike from work" leaves "Mike
+9 -4
View File
@@ -51,11 +51,13 @@ const valuesIn = (properties, source = body) => {
describe("the spacing scale", () => { describe("the spacing scale", () => {
// Hairlines and structural clearances, each one deliberate: // Hairlines and structural clearances, each one deliberate:
// 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat // 0px/1px/3px/4px/25px — borders, hairline offsets, and the 25px of room a seat
// puck's name gives the dealer button beside it. // 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 // 5rem/9rem — how far the page has to scroll past the fixed action
// bar. Not rhythm; clearance for a thing of a known size. // 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", () => { test("every gap, margin and padding comes off the scale", () => {
const offenders = valuesIn( const offenders = valuesIn(
@@ -79,7 +81,10 @@ describe("the type scale", () => {
// every text input states it. Not a typographic choice. // every text input states it. Not a typographic choice.
// 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for // 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for
// a six-figure stack on a 58px seat puck. // 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", () => { test("every font-size comes off the scale", () => {
const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value)); const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value));
+183 -66
View File
@@ -1,22 +1,28 @@
// What the ring is allowed to be sized from. // 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 // resolveSeatLayout buys its answer with ten forced layouts — ten candidate widths, each
// shows what a player has put in. Both used to hold their space anyway, and the pill, // applied to the felt and read back — and caches it against this signature. So the
// which has a background and a pill radius and side padding, painted an 8x11px green // signature has to move whenever a puck's height can, and must not move otherwise: one
// lozenge when it had nothing to say: measured on seven of nine seats at an ordinary // that changed on every action would throw the cache away on every tap, which is the
// table, a coloured mark on almost every seat standing for nothing. // whole cost it exists to avoid.
// //
// The space was not free either. seatRingRadii sizes the ring from the tallest puck on // It used to name four things: every player's name, the dealer's seat, whether anybody
// it, so two rows nobody had filled were setting how far apart the seats had to sit. On // had hole cards, and whether anybody had chips out. Three of those flip during an
// an iPhone 14 nine-handed the puck measured 78x88 with 25px of that reserved for // ordinary hand. A dealer calling a bet paid ten forced layouts for it, and so did the
// nothing; collapsed, the width search settles on 78x63. Twelve-handed on a Galaxy S8 // next street closing, and so did editing a name.
// the seats overlapped by 1556px² and now overlap by 87px².
// //
// The collapse is only safe if the layout re-measures when one of those rows fills, // None of them can change a puck's height any more. The stylesheet holds three rows open
// because resolveSeatLayout caches its ten forced layouts against this signature. That // — name, amount, stack — at a height that does not depend on what is written in them:
// is what is checked here: the cache key has to move when the puck's shape can, and // the name gets one line whatever it says, the amount pill keeps its row when it is
// stay still when it cannot — a signature that changed on every action would throw the // empty, and the dealer's chip changes a padding rather than a line count. What is left
// cache away on every tap, which is the cost it exists to avoid. // 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 test = require("node:test");
const { describe } = require("node:test"); const { describe } = require("node:test");
const assert = require("node:assert"); const assert = require("node:assert");
@@ -32,37 +38,39 @@ const signatureOf = state => {
const table = () => makeTable({ seatCount: 6, stack: 200 }); const table = () => makeTable({ seatCount: 6, stack: 200 });
describe("the signature the ring layout is cached against", () => { describe("the signature the ring layout is cached against", () => {
test("moves when the first chips go in", () => { test("moves when a hole card is entered", () => {
// Before the blinds every pill is empty and the pucks are short; after them two // The one row still allowed to collapse, and the one thing still worth
// seats are ~15px taller. A ring still sized from the first measurement is sized // re-measuring for: a puck with cards on it is about 15px taller.
// 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", () => {
const state = table(); const state = table();
const none = signatureOf(state); const none = signatureOf(state);
state.seats[0].cards = ["AS", "KC"]; state.seats[0].cards = ["AS", "KC"];
assert.notEqual(signatureOf(state), none); assert.notEqual(signatureOf(state), none);
}); });
test("but not again for the second player to put chips in", () => { test("but not again for the second player to be dealt one", () => {
// The whole point of a boolean over the table. The ring is sized from the // The point of a boolean over the table. The ring is sized from the tallest puck
// tallest puck on it, so once anybody has an amount the answer cannot change — // on it, so once anybody has cards the answer cannot change.
// and re-measuring here would mean ten forced layouts on every single action.
const state = table(); const state = table();
state.seats[1].committed = 10; state.seats[0].cards = ["AS", "KC"];
const first = signatureOf(state); const first = signatureOf(state);
state.seats[2].committed = 10; state.seats[1].cards = ["2H", "7D"];
assert.equal(signatureOf(state), first);
state.seats[3].committed = 250;
assert.equal(signatureOf(state), first); 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", () => { test("nor when an amount merely changes size", () => {
const state = table(); const state = table();
state.seats[1].committed = 10; 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"); "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", () => { test("and it stands still for a rename", () => {
// Renaming can wrap a name onto a second line, and the button's chip floats // Renaming used to be able to wrap a name onto a second line. A name gets one
// into the name's first line, so both change how tall a puck measures. // 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 state = table();
const base = signatureOf(state); const base = signatureOf(state);
state.seats[0].name = "Bartholomew Fairweather"; state.seats[0].name = "Bartholomew Fairweather";
const renamed = signatureOf(state); assert.equal(signatureOf(state), base);
assert.notEqual(renamed, base);
state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
assert.notEqual(signatureOf(state), renamed);
}); });
test("and two tables that differ only in where a name is split stay different", () => { test("and for the button moving round the table", () => {
// The names are joined on a control character no name can contain. Joined on // The chip in the puck's corner takes 22px off the name's line. That is a
// nothing, "ann" + "abel" and "anna" + "bel" are both "annabel" and one of those // narrower name, not a taller puck, so the ring it sits on is the same ring.
// tables would be laid out with the other's measurements — different names, so const state = table();
// different wrapping, so different puck heights. const base = signatureOf(state);
// state.dealerSeatIndex = (state.dealerSeatIndex + 1) % state.seats.length;
// The first version of this used "Ann"/"Abel" against "Anna"/"Bel", which can assert.equal(signatureOf(state), base);
// 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));
}); });
}); });
describe("a table nobody has sat down at", () => { 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 // 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(); const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 9); HoldemState.resizeSeats(state, 9);
assert.match(signatureOf(state), /\|$/, assert.equal(signatureOf(state), "",
"an untouched table should carry neither flag"); "an untouched table should carry nothing");
}); });
test("but a table with an ante on it does have an amount", () => { test("and neither does one with an ante on it", () => {
// computeContribution answers for a seat that has never been given a stack — // computeContribution answers for a seat that has never been given a stack — the
// the table's base pot stands in for it — so a table dealt with "everyone antes // table's base pot stands in for it — so a table dealt with "everyone antes 5"
// 5" has amounts on its pucks before anybody acts, and the ring has to know. // 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(); const state = HoldemState.createDefault();
HoldemState.resizeSeats(state, 6); HoldemState.resizeSeats(state, 6);
state.basePotAmount = 5; state.basePotAmount = 5;
state.seats.forEach(p => { p.inHand = true; }); 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);
}); });
}); });
+74 -44
View File
@@ -115,53 +115,46 @@ describe("the bar lines up with the table", () => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("the button seat's name", () => { describe("the button seat's name", () => {
// The dealer button's chip sits in the puck's top-right corner. Keeping the name out // The dealer button's chip sits in the puck's top-right corner, and the name has to
// from under it with `padding-right` takes the chip's width off *every* line of the // keep out from under it. `padding-right` takes the chip's width off the name's line,
// name, and the chip is on one of them: measured at 1280x900, the name box is 78px, // which is the whole of the answer now that there is only ever one line.
// "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 // A float used to do this on the roomier rings — three rules of it, indenting the
// whose name reads properly for four hands and comes apart on the fifth. // first line so a name too long to fit beside the chip could drop below it onto a
const roomy = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/); // second. That only worked because there was a second line. There isn't: see the row
const float = CSS.match(/#seatsLayer:not\(\.seatsTight\) \.seat\.seatHasButton \.seatPuckName::before \{([^}]*)\}/); // 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 \{([^}]*)\}/); const base = CSS.match(/\n\.seat\.seatHasButton \.seatPuckName \{([^}]*)\}/);
test("a ring with room for two lines indents only the line beside the chip", () => { test("the chip's width comes off the name, at every size", () => {
assert.ok(roomy && float, "the roomy regime should use a float"); assert.ok(base, "the base rule should reserve the chip's width");
assert.match(float[1], /float:\s*right/); assert.match(base[1], /padding-right:\s*25px/);
assert.match(roomy[1], /padding-right:\s*0/);
}); });
test("the float is exactly one line tall", () => { test("and it is as wide as the chip it stands in for", () => {
// 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", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/); const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/); const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const floatW = float[1].match(/width:\s*(\d+)px/); const pad = base[1].match(/padding-right:\s*(\d+)px/);
assert.ok(chip && inset && floatW); assert.ok(chip && inset && pad);
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]), assert.equal(Number(pad[1]), Number(chip[1]) + Number(inset[1]),
"the float should cover the chip and the gap it is inset by"); "the padding should cover the chip and the gap it is inset by");
});
test("the float that used to do this is gone, not merely unused", () => {
// 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(/(?<![\w-])line-clamp:\s*(\d+)/g) || [];
assert.ok(clamps.length > 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 // ladder — which words go, in what order, and when it stops — and the browser's part
// is one boolean per pass. // is one boolean per pass.
const label = (text, fits) => { const label = (text, fits) => {
const classes = new Set();
const el = { const el = {
textContent: text, textContent: text,
title: "", 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 // The browser's answer, standing in for a real one: this name fits if
// `fits` says the version currently written to it does. // `fits` says the version currently written to it does.
get scrollWidth() { return fits(this.textContent) ? 10 : 100; }, 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"); 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", () => { test("a single word steps its type down rather than being cut", () => {
// There is no honest way to shorten "Christopher" that a dealer glancing across // There is no honest way to shorten "Christopher" at a word, and "Christo…" is a
// a table would read faster than "Christo…". // 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); const el = label("Christopher", () => false);
seatsNamed("Christopher"); seatsNamed("Christopher");
Render.fitSeatNames(layer([el])); Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Christopher", "never cut mid-word here"); 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", () => { test("a name that fits is not touched", () => {
@@ -323,6 +327,32 @@ describe("a name that will not fit", () => {
seatsNamed("Mike from work"); seatsNamed("Mike from work");
Render.fitSeatNames(layer([el])); Render.fitSeatNames(layer([el]));
assert.equal(el.textContent, "Mike from work"); 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", () => { test("the whole name is on the tooltip whether it was shortened or not", () => {