Line the bar up with the table, and stop the button squeezing a name
Two things that were wrong at every desktop width, both of them the same shape: a number that was right about one thing being used for another. The action bar was centred on the window. The table is centred on its own column, and the sidebar takes 320px out of the right of that — so the two things a dealer is actually looking at sat 172px apart, at 960px wide and at 1600px and everywhere in between, because the offset is half the sidebar and the sidebar is a constant. The bar also finished 80px inside the sidebar's column at 1280, painting over whichever panel happened to be at that height. It leans away from the sidebar now, by the sidebar's own width, which comes out exact above and below the 1400px cap — measured on the running page at four widths, 0px off at all of them. No 100vw in it, which is the point: the bar's own centring comment warns that on a page with a scrollbar 100vw is wider than the viewport, and the offsets are exact without it. The dealer button's chip sits in the puck's top-right corner and the name kept out from under it with `padding-right: 22px`. That 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" — mid-word, no hyphen. The button moves every hand, so that is a player whose name reads properly for four hands and comes apart on the fifth. A float is what indents only the lines it sits beside. 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. Not on a crowded ring, though, and finding that out cost a sweep. A tight puck gives the name one line on purpose, and one line has nowhere to move to: with two lines granted 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. So the float is for the ring with room for two lines and the padding for the one without — two rules, matching two regimes the puck already had. The sweep also learned two things from this: - Text is measured where it was painted, not where its box is. The seat-label check modelled "text kept out of somewhere" as padding, which is one way of doing it and not the only one; a float does it per line, and the check called every seat in the sweep an offender while the text sat exactly where it was meant to. Range rects ask the question directly, so padding, floats, centring, wrapping and a short label in a wide box all come out right without any of them being modelled. - Offenders say how much is covered, not that something is. "Is under a badge" reads the same for a stack a dealer cannot read and for a badge's rounded corner grazing the last digit. Probing the whole line and counting turns the residual this sweep has carried for three rounds into "1/7 under SB" — one probe of seven, one configuration, thumb only. 294 audits, 4 failing configurations, 5 offenders: twelve players on a 360px phone with the raise pad open (3/7 and 2/7, pucks genuinely overlapping — the search reports it rather than papering over it) and the 1/7 hairline above. 770 unit tests. Fault-injected, all restored and re-verified: writing the sidebar's width out again instead of the token, growing the float to cover the chip's whole height, and taking the padding off the crowded ring are each caught by a test; and with both the float and the padding removed the rewritten label check still reports the name 6px under the chip, which is the fault it exists for. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -293,6 +293,13 @@ button,
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* The sidebar's width, named because three rules need to agree about it: the grid
|
||||
that draws the column, the action bar that has to stay out of it, and the seat
|
||||
sheet's right edge. */
|
||||
--sidebarW: 320px;
|
||||
}
|
||||
|
||||
#holdemApp {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -468,7 +475,7 @@ button,
|
||||
|
||||
#holdemLayout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
grid-template-columns: 1fr var(--sidebarW);
|
||||
gap: var(--sp-5);
|
||||
align-items: start;
|
||||
}
|
||||
@@ -962,8 +969,15 @@ button,
|
||||
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, and `hyphens` puts a hyphen in when it does. */
|
||||
`break-word` the browser only breaks a word that cannot fit on a line of its own.
|
||||
|
||||
`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;
|
||||
line-height: 1.2;
|
||||
@@ -975,14 +989,66 @@ button,
|
||||
margin-top: var(--sp-2);
|
||||
}
|
||||
|
||||
/* The button's chip sits in this puck's top-right corner, on a layer above the text.
|
||||
Reserving its width means a long name ends in an ellipsis before it reaches the
|
||||
chip, rather than disappearing underneath one — on the one seat at the table a
|
||||
dealer looks at most. */
|
||||
/* 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.
|
||||
|
||||
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 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. */
|
||||
.seat.seatHasButton .seatPuckName {
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
/* 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.
|
||||
|
||||
Measured, at the name-box widths a roomy button seat gets:
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.seatPuckCards {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
@@ -2611,6 +2677,38 @@ body.hasActionBar #holdemSidebar {
|
||||
padding-bottom: 9rem;
|
||||
}
|
||||
|
||||
/* ---------- The bar lines up with the table ----------
|
||||
It was centred on the window. The table is centred on its own column, and the sidebar
|
||||
takes 320px out of the right of that — so the two things a dealer is actually looking
|
||||
at were 172px apart, at 960px wide and at 1600px wide and at every width in between,
|
||||
because the offset is half the sidebar and the sidebar is a constant. The bar also
|
||||
finished 80px inside the sidebar's column at 1280, painting over whichever panel
|
||||
happened to be at that height.
|
||||
|
||||
Centred between the page's left padding and the table column's right edge instead.
|
||||
Worth doing in offsets rather than a width: the auto margins still centre the bar, so
|
||||
it stays 720px at most and centred inside whatever room this leaves it.
|
||||
|
||||
The arithmetic comes out exact at every width, above and below the 1400px cap, which
|
||||
is worth showing because it looks like it shouldn't:
|
||||
|
||||
bar centre = (--sp-6 + 100vw - (--sidebarW + --sp-5 + --sp-6)) / 2
|
||||
= (100vw - 344) / 2
|
||||
table centre = appLeft + --sp-6 + (contentWidth - (--sidebarW + --sp-5)) / 2
|
||||
|
||||
and under the cap appLeft is 0 with content = 100vw - 64, while over it appLeft is
|
||||
(100vw - 1400)/2 with content fixed at 1336. Both reduce to (100vw - 344) / 2.
|
||||
|
||||
No 100vw anywhere, which is the point — on a page with a scrollbar 100vw is wider
|
||||
than the viewport, and that is exactly the trap the bar's own centring comment
|
||||
warns about. */
|
||||
@media (min-width: 901px) {
|
||||
#actionBar {
|
||||
left: var(--sp-6);
|
||||
right: calc(var(--sidebarW) + var(--sp-5) + var(--sp-6));
|
||||
}
|
||||
}
|
||||
|
||||
.actionStatus {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -62,6 +62,23 @@ at that height, and reporting it that way is a diagnosis of something that isn't
|
||||
happening: the pot wizard's third winner, below the fold in a list, came back for four
|
||||
viewports running as "fully covered by button Confirm Winner(s)".
|
||||
|
||||
Text is measured where it was painted, not where its box is. The two are different
|
||||
things and the difference is not academic: a name is centred, so its box is wider than
|
||||
its ink, and a name kept clear of the dealer's chip by a float is out of the way on the
|
||||
line beside the chip and full width on the line below. The check used to model that as
|
||||
"box minus padding", which is one way of keeping text out of somewhere and not the only
|
||||
one — it called every seat in the sweep an offender while the text sat exactly where it
|
||||
was meant to. Range rects ask the question directly, so padding, floats, centring,
|
||||
wrapping and a short label in a wide box all come out right without any of them being
|
||||
modelled.
|
||||
|
||||
Offenders say how much is covered rather than that something is. "Is under a badge"
|
||||
reads the same for a stack a dealer cannot read and for a badge's rounded corner
|
||||
grazing the last digit, and the difference decides whether it is worth fixing. Probing
|
||||
the whole line and counting turned the residual this sweep has carried for three rounds
|
||||
into "1/7 under SB" — one probe of seven, on one configuration, in a thumb context
|
||||
only.
|
||||
|
||||
More than one table shape is also newer than it sounds. The sweep ran on one — five
|
||||
players at a nine-handed table — for its whole life, and how much room the felt has
|
||||
depends on how many people are sitting down far more than on anything a viewport can
|
||||
|
||||
@@ -69,6 +69,104 @@ describe("which shape the bar is in", () => {
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the bar lines up with the table", () => {
|
||||
// On a desktop the bar was centred on the window and the table on its own column,
|
||||
// and the sidebar takes 320px out of the right of that — so the two things a dealer
|
||||
// is looking at were 172px apart, at 960px wide and at 1600px and everywhere in
|
||||
// between, because the offset is half the sidebar and the sidebar is a constant.
|
||||
//
|
||||
// The rule that fixes it is four numbers that have to keep agreeing with four other
|
||||
// numbers, none of them anywhere near each other in the file. That is what this
|
||||
// block is for: the arithmetic is checked on the running page by measurement (bar
|
||||
// centre against table-column centre, which came out 0px off at every width), and
|
||||
// the numbers it depends on are checked here so they cannot drift apart afterwards.
|
||||
const rule = CSS.match(/@media \(min-width: 901px\) \{\s*#actionBar \{([^}]*)\}/);
|
||||
|
||||
test("there is a rule, and it is scoped above the one-column breakpoint", () => {
|
||||
// Below 901px the sidebar is not a column at all — it is stacked under the table
|
||||
// — so there is nothing to lean away from and the bar is centred as it was.
|
||||
assert.ok(rule, "no min-width: 901px rule for #actionBar");
|
||||
assert.match(CSS, /@media \(max-width: 900px\)[\s\S]{0,200}#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||
"the one-column layout should start exactly where this rule stops");
|
||||
});
|
||||
|
||||
test("it leans away from the sidebar by the sidebar's own width", () => {
|
||||
assert.match(rule[1], /left:\s*var\(--sp-6\)/);
|
||||
assert.match(rule[1], /right:\s*calc\(var\(--sidebarW\)\s*\+\s*var\(--sp-5\)\s*\+\s*var\(--sp-6\)\)/);
|
||||
});
|
||||
|
||||
test("and the sidebar's width is one number, not two", () => {
|
||||
// The grid draws the column and the bar leans away from it. Written out twice,
|
||||
// the day somebody widens the sidebar is the day the bar goes crooked and no
|
||||
// test says so.
|
||||
assert.match(CSS, /--sidebarW:\s*\d+px/, "the token should be declared");
|
||||
assert.match(CSS, /grid-template-columns:\s*1fr var\(--sidebarW\)/,
|
||||
"the grid should draw the column from the token");
|
||||
});
|
||||
|
||||
test("no 100vw anywhere in it", () => {
|
||||
// The bar's own centring comment warns about this: on a page with a scrollbar
|
||||
// 100vw is wider than the viewport, and the overflow drags the table sideways.
|
||||
// The offsets are exact without it — see the derivation in the stylesheet.
|
||||
assert.equal(/100vw/.test(rule[1]), false, rule[1]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 \{([^}]*)\}/);
|
||||
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 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", () => {
|
||||
const chip = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])width:\s*(\d+)px/);
|
||||
const inset = CSS.match(/\n\.dealerChip \{[^}]*?(?<![\w-])right:\s*(\d+)px/);
|
||||
const floatW = float[1].match(/width:\s*(\d+)px/);
|
||||
assert.ok(chip && inset && floatW);
|
||||
assert.equal(Number(floatW[1]), Number(chip[1]) + Number(inset[1]),
|
||||
"the float should cover the chip and the gap it is inset by");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("how much of the bottom is spoken for", () => {
|
||||
// feltRoomFor asks one question — what is under the felt — and gets two different
|
||||
// answers depending on the layout. Under the bar it is the bar. Under the rail it is
|
||||
|
||||
+45
-14
@@ -778,18 +778,30 @@ const LayoutAudit = {
|
||||
// concerned it had all the room it needed. Nothing else here would have caught it:
|
||||
// the occlusion check is about controls a thumb has to reach, and this is a label
|
||||
// a dealer has to read.
|
||||
// Measured against the lines of text as they were painted, not against the box they
|
||||
// live in. This used to take the box and subtract its padding, on the reasoning that
|
||||
// "padding is room the text has already been kept out of" — which is true, and is
|
||||
// not the only way to keep text out of somewhere. A right float does it too, and it
|
||||
// does it per line, which is the point of using one: the name on the button seat is
|
||||
// now indented past the chip on the line beside it and full width on the line below.
|
||||
// Box-minus-padding cannot see that, and called every seat in the sweep an offender
|
||||
// while the text sat exactly where it was supposed to.
|
||||
//
|
||||
// Range rects are the direct question — where did this text actually end up — so
|
||||
// padding, floats, centring, wrapping and a short label in a wide box all come out
|
||||
// right without any of them being modelled. Line boxes rather than one bounding box,
|
||||
// too: a two-line name that clears the chip on the line beside it is not covered
|
||||
// because its *other* line happens to sit under where the chip is.
|
||||
seatLabelsClear() {
|
||||
const offenders = [];
|
||||
document.querySelectorAll(".seat").forEach(seat => {
|
||||
const name = seat.querySelector(".seatPuckName");
|
||||
if (!name || !name.textContent.trim()) return;
|
||||
const box = name.getBoundingClientRect();
|
||||
if (!box.width || !box.height) return;
|
||||
// Padding is room the text has already been kept out of, so it is not
|
||||
// somewhere the text can be covered.
|
||||
const style = getComputedStyle(name);
|
||||
const textLeft = box.left + parseFloat(style.paddingLeft || 0);
|
||||
const textRight = box.right - parseFloat(style.paddingRight || 0);
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(name);
|
||||
const lines = [...range.getClientRects()].filter(line => line.width > 1 && line.height > 1);
|
||||
if (!lines.length) return;
|
||||
|
||||
seat.querySelectorAll(".dealerChip, .blindBadge").forEach(mark => {
|
||||
const over = mark.getBoundingClientRect();
|
||||
@@ -797,10 +809,15 @@ const LayoutAudit = {
|
||||
const marked = getComputedStyle(mark);
|
||||
if (marked.display === "none" || marked.visibility === "hidden" ||
|
||||
parseFloat(marked.opacity) < 0.05) return;
|
||||
const covered = Math.min(textRight, over.right) - Math.max(textLeft, over.left);
|
||||
const vertical = Math.min(box.bottom, over.bottom) - Math.max(box.top, over.top);
|
||||
if (covered > 1 && vertical > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(covered)}px under ${this.describe(mark)}`);
|
||||
|
||||
let worst = 0;
|
||||
lines.forEach(line => {
|
||||
const across = Math.min(line.right, over.right) - Math.max(line.left, over.left);
|
||||
const down = Math.min(line.bottom, over.bottom) - Math.max(line.top, over.top);
|
||||
if (across > 1 && down > 1) worst = Math.max(worst, across);
|
||||
});
|
||||
if (worst > 1) {
|
||||
offenders.push(`${this.describe(name)} :: ${Math.round(worst)}px under ${this.describe(mark)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -862,14 +879,28 @@ const LayoutAudit = {
|
||||
// Along the line rather than at its middle: a badge takes the start
|
||||
// of it and a neighbouring puck the end, and a single centre probe
|
||||
// walks past both.
|
||||
//
|
||||
// Every probe, not the first one that hits. How much of the line is
|
||||
// gone is the difference between a stack a dealer cannot read and a
|
||||
// hairline where a badge's rounded corner grazes the last digit, and
|
||||
// reporting both as "is under" gave no way to tell them apart — the
|
||||
// one residual this sweep has carried for three rounds turns out to
|
||||
// be one probe of seven.
|
||||
let hits = 0;
|
||||
let culprit = null;
|
||||
let probes = 0;
|
||||
for (let across = 0.08; across <= 0.92; across += 0.14) {
|
||||
const x = line.left + (line.right - line.left) * across;
|
||||
const y = (line.top + line.bottom) / 2;
|
||||
probes++;
|
||||
const over = document.elementFromPoint(x, y);
|
||||
if (!over || seat.contains(over) || over.contains(seat)) continue;
|
||||
offenders.push(
|
||||
`seat ${index + 1} "${text}" is under ${this.describe(over)}`);
|
||||
return;
|
||||
hits++;
|
||||
if (!culprit) culprit = over;
|
||||
}
|
||||
if (culprit) {
|
||||
offenders.push(`seat ${index + 1} "${text}" is ${hits}/${probes} `
|
||||
+ `under ${this.describe(culprit)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user