Give the rulings the window a sideways phone was already holding

Twelve rulings in a 460px sheet in the middle of an 844px window: 754px of
content in a 334px scrollport, 111 of it spent on a title and a caveat that
stand still, 384px of window width unused either side. Four of the twelve
names on screen, and the dealer is looking one up with an argument going on
at the table.

Material's rule for a bottom sheet in landscape is to stop being one, and
this tool already does exactly that under this query for the action bar. So
the sheet takes the window and the width pays for the height: the twelve go
into two columns of six, all of them on screen at 844x390 with nothing to
scroll at all, and the title, the caveat and Done share one line instead of
standing on three.

Two wrappers in the markup rather than one grid of twelve, because a grid
pairs its children into shared rows: opening the third ruling on the left
would push the ninth down the right-hand side to keep the row square. Two
flows means a column only ever moves itself — measured, opening one leaves
the other six names exactly where they were. Everywhere but landscape the
wrappers are `display: contents` and the twelve are the single column they
have always been.

And the same sheet upright: the header is stuck to the top of its own
scrollport now, the way the seat menu's has been. Measured before, at the
bottom of that scroll Done was 221px above the sheet's top edge on a 360x640
phone, and 262 on a 390x844 one with a ruling open — which is the only
reason anybody is in here. A dealer who has read the answer had to scroll
back past it to leave.

The layout sweep's scroll-into-view guard learns the new box, or it finds a
sheet whose scrollHeight equals its clientHeight and decides nothing is out
of view.

1324 tests pass. The sweep is clean across 428 audits; contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-15 04:36:36 -06:00
co-authored by Claude Opus 5
parent f86e50268e
commit 023228efc0
4 changed files with 446 additions and 150 deletions
+17 -1
View File
@@ -549,8 +549,12 @@
<div id="rulingsOverlay" hidden>
<div id="rulingsSheet" role="dialog" aria-labelledby="rulingsTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
@* The grip belongs inside the header, not above it, because the header is stuck
to the top of the scrollport — anything above a sticky thing is something the
list scrolls out through. The seat menu's header is built the same way, and
for the same reason. *@
<div class="rulingsHeader">
<div class="seatSheetGrip" aria-hidden="true"></div>
<h2 id="rulingsTitle" tabindex="-1" data-modal-focus>Common rulings</h2>
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
@@ -579,6 +583,14 @@
Not exclusive (no `name` attribute): closing one because another opened moves
everything under the dealer's finger, which is the disorientation the
accordion is here to avoid. *@
@* Six and six, down one column and then the next. Two boxes rather than one
list so that landscape can lay them side by side without the columns
sharing rows: opening a ruling in one of them moves what is under it in
that column and nothing else. Everywhere but landscape the wrappers are
`display: contents` and the twelve are one column, exactly as before. *@
<div class="rulingsList">
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Redeal it — misdeal</h3></summary>
<ul>
@@ -654,7 +666,9 @@
off the short one. The action bar follows this for you.
</p>
</details>
</div>
<div class="rulingsColumn">
<details class="ruling">
<summary><h3>Chips nobody called come back</h3></summary>
<p>
@@ -733,6 +747,8 @@
decide how much to bet. Say it, or push it all at once.
</p>
</details>
</div>
</div>
<p class="rulingsSource">
Follows the Tournament Directors Association rules, which most rooms and
+146 -5
View File
@@ -2179,29 +2179,60 @@ input[type="checkbox"] {
max-width: 460px;
max-height: 86vh;
overflow-y: auto;
overscroll-behavior: contain;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--line);
border-bottom: none;
border-radius: var(--r-lg) var(--r-lg) 0 0;
padding: var(--sp-1) var(--sp-4) calc(var(--sp-5) + env(safe-area-inset-bottom, 0px));
/* No padding at the top: the header owns it, because the header is sticky and a
gap above a sticky thing is a gap the list scrolls through. The seat menu's
sheet is built the same way, one section down. */
padding: 0 var(--sp-4) calc(var(--sp-5) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
/* Stuck to the top of the sheet's own scrollport, for the reason the seat menu's header
is: the way out should not scroll away. This sheet is the longer of the two and never
had it. Measured before: on a 360x640 phone the twelve shut rulings are 791px of a
549px sheet, so at the bottom of that scroll Done sits 221px above the top edge — and
on a 390x844 phone with one ruling opened, which is the only reason anybody is in
here, 262px. A dealer who has read the answer has to scroll back past it to leave. */
.rulingsHeader {
display: flex;
position: sticky;
top: 0;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
column-gap: var(--sp-2);
/* Out to the sheet's edges and back again, so nothing shows through either side of
a header sitting over a scrolling list. */
margin: 0 calc(var(--sp-4) * -1) var(--sp-2);
padding: var(--sp-1) var(--sp-4) var(--sp-2);
background: var(--surface-2);
border-bottom: 1px solid var(--line);
}
/* The grip is inside the header now rather than above it — above a sticky element is
the one place on this sheet that scrolls out of sight. */
.rulingsHeader .seatSheetGrip {
grid-column: 1 / -1;
}
.rulingsHeader h2 {
flex: 1;
margin: 0;
font-size: var(--fs-lg);
font-weight: 500;
color: var(--text-bright);
}
/* Two boxes of six that are not boxes at all: `display: contents` hands the twelve
rulings straight to the list, so every layout but landscape sees the single column it
always had. Landscape turns them back into boxes and stands them side by side. */
.rulingsColumn {
display: contents;
}
.rulingsIntro {
margin: 0 0 var(--sp-4);
color: var(--text-muted);
@@ -5853,6 +5884,116 @@ body.hasActionBar #toastHost {
inset: max(24px, 12%) 44px min(26px, 12%);
}
/* ---------- Common rulings: the sheet becomes the screen ----------
Everywhere else this is a bottom sheet a dealer scrolls, and that works because
upright there is height to scroll through. Sideways there is not. Measured at
844x390 before this: a 460px-wide sheet standing in the middle of an 844px window
with 384px of that window unused either side of it, 754px of rulings in 334px of
scrollport, 111px of it spent on a title and a caveat that stand still — four of
the twelve names on screen, and the dealer is looking one up with an argument
going on at the table.
So the sheet takes the window, and the width pays for the height. Material's rule
for a bottom sheet on a landscape screen is to stop being one — a side sheet on a
wide screen, a full-screen dialog when the content needs real scrolling — and this
is the tool's own move as well: the action bar becomes a right-hand rail under
exactly this query, for exactly this reason.
The twelve go into two columns of six. Measured after: all twelve names on screen
at 844x390 with nothing to scroll at all, and the columns are 400px wide, which is
a better measure for the prose inside an open ruling than the 460 it had.
Capped rather than unbounded, because this query also matches a short desktop
window, and two columns of a 1400px window would be 680px lines of body text. */
#rulingsOverlay {
align-items: stretch;
}
#rulingsSheet {
max-width: 62rem;
max-height: none;
height: 100%;
border: none;
border-radius: 0;
/* The list inside does the scrolling now, so the sheet itself does none. */
overflow: hidden;
padding:
var(--sp-2)
calc(var(--sp-4) + env(safe-area-inset-right, 0px))
calc(var(--sp-2) + env(safe-area-inset-bottom, 0px))
calc(var(--sp-4) + env(safe-area-inset-left, 0px));
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-rows: auto minmax(0, 1fr) auto;
grid-template-areas:
"title intro done"
"list list list"
"src src src";
column-gap: var(--sp-3);
row-gap: var(--sp-2);
}
/* The title, the caveat and the way out share one line, which is the whole of the
111px this screen was spending on them. `display: contents` because the three sit
in two different boxes in the markup and the sheet's own grid is where they need
to be. The sticky positioning goes with the box it is dropping, and is not wanted
here: nothing above the list scrolls, so nothing has to stick. */
.rulingsHeader {
display: contents;
}
/* A grab handle for a sheet that is not a sheet any more. */
.rulingsHeader .seatSheetGrip {
display: none;
}
.rulingsHeader h2 {
grid-area: title;
align-self: center;
}
.rulingsIntro {
grid-area: intro;
align-self: center;
margin: 0;
font-size: var(--fs-sm);
}
#closeRulingsBtn {
grid-area: done;
align-self: center;
}
/* Two tracks holding two independent flows, not one flow wrapped into two. That is
what the pair of wrappers in the markup buys: a grid with twelve children of its
own would pair them into six shared rows, and opening the third ruling on the left
would push the ninth down the right-hand side to keep the row square. Here a
column only ever moves itself.
`scrollbar-gutter` because the gutter arrives the moment a ruling is opened, and
without the reservation it takes 8px off the tracks and slides the right-hand
column sideways while the dealer is reading it. */
.rulingsList {
grid-area: list;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: var(--sp-5);
align-items: start;
}
.rulingsColumn {
display: block;
}
.rulingsSource {
grid-area: src;
padding-top: var(--sp-1);
}
}
/* ---------- Short phones, upright ----------
+132
View File
@@ -40,6 +40,35 @@ const ruleFor = selector => {
return CSS.slice(at, CSS.indexOf("}", at));
};
// Every @media block written at exactly this condition, concatenated — the same helper
// theHeader.test.js uses, and for the same reason: this condition is opened more than
// once in the file, and a helper that stops at the first finds a block with none of
// these rules in it and reports them missing.
const mediaBlock = condition => {
const found = [];
const needle = "@media ";
for (let at = CSS.indexOf(needle); at >= 0; at = CSS.indexOf(needle, at + 1)) {
const open = CSS.indexOf("{", at);
if (CSS.slice(at + needle.length, open).trim() !== condition) continue;
let depth = 0;
for (let i = open; i < CSS.length; i++) {
if (CSS[i] === "{") depth++;
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(open + 1, i)); break; }
}
}
assert.ok(found.length > 0, `no @media block written as "${condition}"`);
return found.join("\n");
};
const ruleIn = (block, selector) => {
const re = new RegExp(`(^|[},])\\s*${selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`, "m");
const found = block.match(re);
assert.ok(found, `there is no \`${selector}\` rule in that block`);
return found[2];
};
const SIDEWAYS = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
// Each ruling as its own slice of markup.
const rulings = () => {
const parts = sheet().split('<details class="ruling"');
@@ -140,3 +169,106 @@ describe("reading one of them", () => {
assert.match(MAIN, /getElementById\("openRulingsBtn"\)\.addEventListener\("click", openRulings\)/);
});
});
// The dealer who has read the answer has to be able to leave without scrolling back
// past it. Measured before this: on a 360x640 phone the twelve shut rulings are 791px of
// a 549px sheet, so at the bottom of that scroll Done is 221px above the sheet's top
// edge; on a 390x844 phone with one ruling opened — the only reason anybody is in here —
// it is 262px. The seat menu's sheet had already been given the fix and this one, the
// longer of the two, had not.
describe("the way out does not scroll away", () => {
test("the header is stuck to the top of the sheet's own scrollport", () => {
const rule = ruleFor(".rulingsHeader");
assert.match(rule, /position:\s*sticky/);
assert.match(rule, /top:\s*0/);
// Out to the sheet's edges and back, or the list shows through either side of it.
assert.match(rule, /margin:\s*0 calc\(var\(--sp-4\) \* -1\)/);
assert.match(rule, /background:\s*var\(--surface-2\)/);
});
test("and nothing is left above it for the list to scroll out through", () => {
assert.match(ruleFor("#rulingsSheet"), /padding:\s*0 var\(--sp-4\)/,
"the sheet keeps a top padding, which is a gap above a sticky header");
// The grip was the one thing up there. It belongs inside the header now.
const header = sheet().match(/<div class="rulingsHeader">[\s\S]*?<\/div>\s*<\/div>/);
assert.ok(header, "the header is not in the markup where it was");
assert.match(header[0], /seatSheetGrip/,
"the grip is still outside the header, so it scrolls away above it");
assert.ok(ruleFor(".rulingsHeader .seatSheetGrip"),
"the grip has no rule placing it inside a two-column header");
});
test("the scroll inside the sheet stays inside it", () => {
assert.match(ruleFor("#rulingsSheet"), /overscroll-behavior:\s*contain/);
});
});
// A phone held sideways is 844x390: no height and swimming in width. Measured before
// this, the bottom sheet was 460px wide in the middle of that window — 384px of it
// unused either side — holding 754px of rulings in a 334px scrollport, 111px of which
// went on a title and a caveat that stand still. Four of the twelve names on screen.
//
// Material's rule for a bottom sheet in landscape is to stop being one, and this tool
// already does exactly that under this query for the action bar. So the sheet takes the
// window and the width pays for the height: two columns of six, all twelve names on
// screen at 844x390 with nothing to scroll at all.
describe("a phone on its side gets the whole window", () => {
test("the twelve are six and six in the markup", () => {
const columns = sheet().split('<div class="rulingsColumn">').slice(1);
assert.equal(columns.length, 2, "there are not exactly two columns to stand side by side");
columns.forEach((c, i) => {
const count = (c.slice(0, c.indexOf("\n </div>")).match(/<details class="ruling"/g) || []).length;
assert.equal(count, 6, `column ${i + 1} holds ${count} rulings rather than six`);
});
});
test("but everywhere else they are still one column", () => {
// `display: contents` hands the rulings straight to the list, so every layout
// that isn't landscape sees exactly the markup it saw before the wrappers.
assert.match(ruleFor(".rulingsColumn"), /display:\s*contents/);
assert.match(ruleIn(SIDEWAYS(), ".rulingsColumn"), /display:\s*block/);
});
test("sideways the sheet stops being a sheet and becomes the screen", () => {
const block = SIDEWAYS();
assert.match(ruleIn(block, "#rulingsOverlay"), /align-items:\s*stretch/,
"the sheet is still anchored to the bottom edge, so it is still short");
const sheetRule = ruleIn(block, "#rulingsSheet");
assert.match(sheetRule, /height:\s*100%/);
assert.match(sheetRule, /max-height:\s*none/);
// Capped rather than unbounded: this query also matches a short desktop window,
// and two columns of a 1400px one would be 680px lines of body text.
assert.match(sheetRule, /max-width:\s*62rem/);
assert.match(sheetRule, /overflow:\s*hidden/,
"the sheet scrolls as well as the list, which is two scrollbars for one list");
});
test("the title, the caveat and the way out share one line", () => {
// That line is the whole of the 111px this screen was spending on them, and the
// sticky positioning goes with the box being dropped: nothing above the list
// scrolls here, so nothing has to stick.
const block = SIDEWAYS();
assert.match(ruleIn(block, ".rulingsHeader"), /display:\s*contents/);
assert.match(ruleIn(block, ".rulingsHeader .seatSheetGrip"), /display:\s*none/,
"a grab handle on a sheet that is not a sheet any more");
assert.match(ruleIn(block, "#rulingsSheet"), /grid-template-areas:\s*\n?\s*"title intro done"/);
});
test("the two columns are two flows, not one flow wrapped", () => {
// A grid with twelve children of its own pairs them into six shared rows, and
// opening the third ruling on the left pushes the ninth down the right-hand side
// to keep the row square. Two wrappers means a column only ever moves itself.
const rule = ruleIn(SIDEWAYS(), ".rulingsList");
assert.match(rule, /grid-template-columns:\s*1fr 1fr/);
assert.match(rule, /overflow-y:\s*auto/);
assert.match(rule, /min-height:\s*0/,
"a grid item's floor is its content, so the list would push the sheet open");
});
test("and the gutter is reserved before it is needed", () => {
// It arrives the moment a ruling is opened. Without the reservation it takes 8px
// off the tracks and slides the right-hand column sideways under the eye that is
// reading it.
assert.match(ruleIn(SIDEWAYS(), ".rulingsList"), /scrollbar-gutter:\s*stable/);
});
});
+8 -1
View File
@@ -957,7 +957,14 @@ const LayoutAudit = {
// check used to pass it in silence — which is how "Confirm Winner(s)",
// the button that pays the pot out, went unmeasured for 36 rounds.
// Bring it into view before probing.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
// `.rulingsList` before `#rulingsSheet`, and `closest` takes the nearer
// of the two: on a phone held sideways the sheet fills the window and
// stops scrolling, and the twelve rulings scroll inside the list instead.
// Naming only the sheet there finds a box whose scrollHeight equals its
// clientHeight, decides nothing is out of view, and puts the check back
// in the silence this guard exists to break.
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, " +
".rulingsList, #rulingsSheet, #seatEditorPanel");
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
const box = scroller.getBoundingClientRect();
const own = el.getBoundingClientRect();