Take the dealer button out of the writing and put it on the rim

A puck can say four things about where a seat sits in the hand: small blind,
big blind, straddle, button. Three of them were pills hanging off the top-left
edge. The fourth was a 22px disc inside the top-right corner, on a layer above
the name — so the dealer's eye had two places to look for one class of fact,
and the corner one was charging the name 25px of its line to be there.

What that cost, measured across the sweep's tables with the twelve packed names:

  Galaxy S8  9-handed   name box  56px   11 of 12 fit — 7 on the button seat
  Galaxy S8 12-handed   name box  34px    7 of 12 fit — 1 on the button seat
  iPhone 14  9-handed   name box  68px   11 of 12 fit — 7 on the button seat
  desktop    9-handed   name box 105px   12 of 12 fit — 11 on the button seat

Four names in twelve, on a phone, cut on one seat and whole on the other eight.
And the button is the one marker on the felt that moves every hand, so that is
not four unlucky players — it is every player in turn, once a round, all night.

The chip now hangs off the same edge as the badges, at the same 5px, at the
other end of it. That distance is not free — SEAT_BADGE_OVERHANG_PX charges
every chair on the ring for it — but it is already spent, and it is charged
once per seat rather than once per marker, which stays right precisely because
the two markers share an edge and sit at opposite ends of it. So the ring does
not move at all. 12px is what fits between -5px and the top of the name on the
tight ring, and it is enough: a finger never reaches this chip anyway — on
touch the ghosts are pointer-events: none and the live one is inert, so giving
the button is the seat's long-press menu, and what is left is a mouse on a
ghost that only appears under the pointer.

Round where the badges are pills, and on the right where they are on the left,
because the button's gold and the big blind's now share one edge: what tells
them apart is the shape and the side, not the colour.

--

The other half of this was a name that could not be trusted to be one line.

The label was a `-webkit-box` clamped to one line, which hides the lines past
the first — but not the part of the second one that the row's own min-height
leaves uncovered. The row is a px token, 15px, "12px of name at 1.2", while
.puckNameTight steps the name down to 10.2px, whose line box is 12.24. That
leaves 2.76px. Measured on a 390px phone: "Christopher" on the button seat drew
as "Christ…" with the ascenders of "opher" showing underneath it as a stray
mark, which reads as a rendering fault on the first seat a dealer looks at.

`white-space: nowrap` with `text-overflow: ellipsis` has no second line to
leak. It also makes the box measure honestly, which the clamp did not: a name
that wrapped reported scrollWidth == clientWidth, so fitSeatNames() had only
ever been seeing the overflow as height — the right answer to the wrong
question, and only right while the row and the line agreed.

1046 tests pass; the 418-audit sweep is clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-10 16:20:37 -06:00
co-authored by Claude Opus 5
parent 8136025544
commit 37abc72de1
4 changed files with 172 additions and 94 deletions
+90 -54
View File
@@ -1131,24 +1131,24 @@ button,
font-size: var(--seatNameSize);
font-weight: 600;
color: var(--text-bright);
display: -webkit-box;
-webkit-box-orient: vertical;
-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
`break-word` the browser only breaks a word that cannot fit on a line of its own.
/* One line because it cannot wrap, not because a second line is hidden afterwards.
`hyphens` is here in hope rather than in trust. It is meant to put a hyphen in at
that break, and where a browser has a hyphenation dictionary it does — but that is
not a thing to build a layout on. Measured directly: a 56px box with `hyphens:
auto` and nothing else does not hyphenate "Christopher", it overflows, which means
the dictionary is simply absent and the property is inert. So nothing above this
line depends on it; where it works it is a free improvement to a break that is
already legible without it. */
overflow-wrap: break-word;
hyphens: auto;
A `-webkit-box` clamped to one line did the job for as long as the row was exactly
one line tall, and it stopped the moment the type inside it moved. The row is a
`px` token — 15px, "12px of name at 1.2" — while .puckNameTight steps the name down
to 10.2px, whose line box is 12.24. The clamp hides the lines past the first; it
does not hide the 2.76px of the second one that the row's own min-height leaves
uncovered. Measured on a 390px phone: "Christopher" on the button seat drew as
"Christ…" with the ascenders of "opher" showing underneath it as a stray mark — a
name that read as a rendering fault on the one seat a dealer looks at first.
`nowrap` has no second line to leak. It also makes the box measure honestly, which
the clamp did not: a name that wrapped reported scrollWidth == clientWidth, so
fitSeatNames() was detecting overflow by height and never by width — right answer,
wrong question, and only ever right while the row and the line agreed. */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
/* 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
@@ -1158,24 +1158,25 @@ button,
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 chip, plus the 3px it is inset by.
/* The name has no gutter reserved in it any more, on any seat.
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.
It used to have one on the button seat: `padding-right: 25px`, the dealer chip's 22px
plus the 3px it was inset by, taken off the name's line because the chip sat inside the
puck's top-right corner on a layer above the text. Three attempts lived in this spot —
a float, then a float with an indent, then the padding — and all three were answers to
the same question, which was the wrong question. The chip has moved to the puck's rim
instead; see .dealerChip.
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: 25px;
}
What the gutter cost, measured across the sweep's tables with the twelve packed names:
Galaxy S8 9-handed name box 56px 11 of 12 names fit — 7 on the button seat
Galaxy S8 12-handed name box 34px 7 of 12 names fit — 1 on the button seat
iPhone 14 9-handed name box 68px 11 of 12 names fit — 7 on the button seat
desktop 9-handed name box 105px 12 of 12 names fit — 11 on the button seat
Four names in twelve, on a phone, cut on one seat and whole on the other eight. And the
button is the one marker on the felt that moves every hand, so that is not four unlucky
players — it is every player in turn, once a round, all night. */
/* The name with no word left to drop.
@@ -1271,34 +1272,66 @@ button,
visibility: hidden;
}
/* On the rim, in the slot the blind badges keep — which is what it is.
There are four things a puck can say about where a seat sits in the hand: small blind,
big blind, straddle, button. Three of them were pills hanging off the top-left edge and
the fourth was a 22px disc inside the top-right corner, in the middle of the writing.
So the dealer's eye had two places to look for one class of fact, and the corner one
was charging the name a quarter of its width to be there — see .seatPuckName above for
what that cost in names.
It hangs off the edge exactly as far as the badges do and no further, because that
distance is not free: render.js's SEAT_BADGE_OVERHANG_PX charges every chair on the
ring for it, once, and seatFootprint.test.js holds the three numbers together. Same
5px, so the ring does not move at all — this chip now fits in room the felt had already
reserved for the badge on the other corner.
Which is what sets its size. 12px, the badges' height, is what fits between -5px and
the top of the name: on the tight ring the name starts 8px down (1px border, 3px
padding, 4px of margin), and the chip reaches 7.
It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval of
overlapping seats means hanging over the seat *next to it*. Measured then on a nine-seat
table: tapping the centre of seat 1 gave the button to seat 9. That is why this stays
inside the puck's own width, at the badges' `2px`, and only ever hangs vertically.
12px is smaller than the 22 it was, and the block below is why that costs nothing: a
finger never reaches this chip, so the only pointer it has to answer is a mouse on a
ghost that appears under it.
Round, where the badges are pills, and on the right, where they are on the left. Both
matter more now that the button's gold and the big blind's are on the same edge: what
tells them apart is the shape and the side, not the colour. */
.dealerChip {
position: absolute;
/* Inside its own seat, not on the rim.
It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval
of overlapping seats means hanging over the seat *next to it*. Measured on a
nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That
was always slightly true and the enlarged hit area made it much worse; three of
nine seats were losing taps, one of them losing its own middle.
Tucked inside, the overlap is zero at every size. It reads as a puck in the
corner of the seat rather than on its edge, which is a small price for tapping
a player and getting that player. */
top: 3px;
right: 3px;
width: 22px;
height: 22px;
top: -5px;
right: 2px;
width: 12px;
height: 12px;
padding: 0;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d8d8d8 55%, #b8b8b8 100%);
color: var(--surface-2);
border: 2px solid var(--text-muted);
border: 1.5px solid var(--text-muted);
font-weight: 700;
font-size: var(--fs-xs);
font-size: var(--fs-2xs);
line-height: 1;
cursor: pointer;
z-index: 2;
/* The badges' layer, not the seat's. It shares their edge now, so it has to outrank
the bet chips that come down onto that edge for the same reason they do. */
z-index: 4;
transition: opacity 0.15s ease;
}
/* And it follows them round the ring. Seats on the bottom arc wear their markers
underneath, because bets sit between a seat and the pot — on that arc the bet chip
lands on the seat's top edge, which is where this was. See .blindBadge. */
.seat.seatBelowCentre .dealerChip {
top: auto;
bottom: -5px;
}
/* There is no invisible pad around this chip any more, and that is the point.
It was here to make a 22px disc meet the 44px a fingertip needs. It was moved twice
@@ -1476,8 +1509,9 @@ button,
the big blind and stops short of the small one — so there was never anything for the
second position to avoid.
Left rather than centred because the dealer button's chip owns the top-right corner,
and the two are read together: who has the button, and who is posting. */
Left rather than centred because the button's chip has the other end of the same edge,
and the two are read together: who has the button, and who is posting. One line of
markers along the puck's rim, blinds at one end and the button at the other. */
.sbBadge {
left: 2px;
background: linear-gradient(180deg, #2f6cb5, #22508c);
@@ -1560,9 +1594,11 @@ button,
line-height: 1.2;
}
/* The ellipsis that used to be repeated here did nothing: the base rule was a
`-webkit-box`, which `text-overflow` has no effect on. It is in the base rule properly
now, for every ring. */
.seatsTight .seatPuckName {
margin-top: var(--sp-1);
text-overflow: ellipsis;
}
/* Not wrapping is only half an answer: a line that cannot wrap and still does not fit
@@ -631,11 +631,14 @@ 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` 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.
// How far a marker hangs off the puck it belongs to — the `top` and `bottom` on
// .blindBadge and on .dealerChip 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.
//
// Charged once per seat, not once per marker, and the two markers are why that is
// still right: they share one edge and sit at opposite ends of it, so a seat wearing
// both is no taller than a seat wearing either.
SEAT_BADGE_OVERHANG_PX: 5,
// How much space two neighbouring seats need between their centres.
@@ -1449,12 +1452,10 @@ const Render = {
const dealerChip = node.querySelector(".dealerChip");
const hasButton = index === dealerIndex;
dealerChip.classList.toggle("active", hasButton);
// Marked on the seat as well as the chip, so the puck's own text can keep
// out from under it. A name is centred across the whole puck and the chip
// sits in the corner on a layer above: measured, "Christopher" on the
// button lost 20px of its 64px box to the chip and showed no ellipsis,
// because the text believed it had the room it was painted out of.
node.classList.toggle("seatHasButton", hasButton);
// The seat used to be marked too — `seatHasButton` — so the puck's text could
// keep out from under a chip that sat in its corner. The chip is on the rim
// with the blind badges now and there is nothing to keep out of, so the seat
// no longer needs to know which of nine chairs the chip is on.
dealerChip.classList.toggle("hidden", !!this.state.dedicatedDealer);
// Every one of these announced itself as "D", so a screen reader heard a row
// of nine identical buttons and couldn't tell which seat held the real one.
@@ -1718,9 +1719,9 @@ const Render = {
// four plain words reaches its first in three.
SEAT_NAME_PASSES: 3,
// Past the box in either direction. Both matter: the label clamps to two lines, so
// a name too long runs out of height, while a single word too wide for the puck
// runs out of width without ever reaching the second line.
// Past the box in either direction. Width is the one that answers now that a name
// cannot wrap — see .seatPuckName — and height is kept because it costs nothing and
// is the honest question for anything else that ever asks this.
overflowing(el) {
return el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
},
+30
View File
@@ -204,6 +204,36 @@ describe("the blind badge counts", () => {
"the same distance at both ends, or one arc is charged for the other's badge");
});
test("and the dealer chip shares that edge on exactly the same terms", () => {
// It moved out of the puck's top-right corner and onto the rim beside the badges,
// which is only free while it hangs off by the same 5px they do. A pixel more and
// the ring is charging every chair for a distance it isn't measuring.
const top = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])top:\s*(-?[\d.]+)px/);
const bottom = CSS.match(/\.seat\.seatBelowCentre \.dealerChip \{[^}]*?(?<![\w-])bottom:\s*(-?[\d.]+)px/);
assert.ok(top && bottom, "the chip should hang off both arcs, like the badge");
assert.equal(Math.abs(Number(top[1])), Render.SEAT_BADGE_OVERHANG_PX);
assert.equal(Math.abs(Number(bottom[1])), Render.SEAT_BADGE_OVERHANG_PX);
});
test("and it stays inside the puck's own width, where the badges are", () => {
// Sideways is the direction that lands on the seat next door. This chip has been
// there: at -10px it took the centre of seat 1's taps and gave the button to seat
// 9. `right` is a positive inset for the same reason `left: 2px` is.
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(-?[\d.]+)px/);
assert.ok(inset, "the chip should be inset from the right edge");
assert.ok(Number(inset[1]) >= 0, "a negative inset hangs it over the neighbour");
});
test("and it is short enough to clear the name it sits above", () => {
// The tight ring is the close one: 1px of border, 3px of padding and 4px of
// margin put the name's top 8px below the puck's, and the chip reaches
// height - overhang below it.
const height = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])height:\s*([\d.]+)px/);
assert.ok(height, "the chip should have a height to check");
assert.ok(Number(height[1]) - Render.SEAT_BADGE_OVERHANG_PX <= 8,
"the chip dips into the name row on the tight ring");
});
test("and it is counted once, not once per seat", () => {
// seatsCollide is given one box and compares centres against it, so the
// separation it asks for is one puck plus one badge. That is exactly right for
+38 -27
View File
@@ -117,35 +117,27 @@ 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, 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.
// The button seat's name is the same name in the same box as every other seat's.
//
// 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 \{([^}]*)\}/);
// Three attempts said otherwise, in this order: a float, a float with a first-line
// indent, and `padding-right: 25px` — the dealer chip's width plus its inset, taken
// off the name because the chip sat inside the puck's top-right corner. All three
// were answers to a chip that had no business being in the writing, and the price
// was a quarter of the name's line on whichever seat currently held the button.
// Measured with the sweep's twelve names: 7 of 12 fitted on the button seat at
// 9-handed on an iPhone against 11 anywhere else, and 1 of 12 against 7 at
// 12-handed on a Galaxy S8.
const name = CSS.match(/\n\.seatPuckName \{([^}]*)\}/);
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("and it is as wide as the chip it stands in for", () => {
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
const pad = base[1].match(/padding-right:\s*(\d+)px/);
assert.ok(chip && inset && pad);
assert.equal(Number(pad[1]), Number(chip[1]) + Number(inset[1]),
"the padding should cover the chip and the gap it is inset by");
test("nothing reserves a gutter inside the name any more", () => {
assert.equal(/\.seat\.seatHasButton \.seatPuckName/.test(CSS), false,
"the button seat's name should be the same box as every other seat's");
});
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.
// Left in place with a one-line name it would clip the name to nothing: the word
// cannot fit beside the float, so it moves to a second line, and there is no
// second line. 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");
});
@@ -154,9 +146,28 @@ describe("the button seat's name", () => {
// 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));
assert.ok(name, "the name should have a base rule");
assert.match(name[1], /white-space:\s*nowrap/);
});
test("and it is one line by not wrapping, not by hiding what wrapped", () => {
// A `-webkit-box` clamped to one line hides the lines past the first; it does not
// hide the part of the second one that the row's own min-height leaves uncovered.
// The row is 15px and .puckNameTight's line box is 12.24, so 2.76px of the next
// line showed — "Christ…" with the ascenders of "opher" underneath it.
assert.equal(/(?<![\w-])line-clamp:/.test(CSS), false,
"a clamp and a fixed row height only agree while the type never moves");
assert.match(name[1], /text-overflow:\s*ellipsis/);
});
test("the box a name is measured in is the box it is drawn in", () => {
// What the clamp cost besides the stray line: a name that wrapped reported
// scrollWidth == clientWidth, so fitSeatNames() could only ever see the overflow
// as height. `nowrap` puts the whole name on one line whether it fits or not,
// which is what makes the width worth reading.
assert.match(name[1], /overflow:\s*hidden/);
assert.equal(/overflow-wrap:\s*break-word/.test(name[1]), false,
"nothing to break a word onto");
});
});