Put the Confirm button back on screen

Opened the pot wizard — the one dialog in this tool that divides money — and its
Confirm button was below a scroll. Not on a tall table, not on one screen: at
every size I measured, with six players and a side pot.

  iPhone 14 390x844   244px of the dialog below the fold
  Galaxy S8 360x640   417px
  844x390 landscape   583px
  desktop 1280x900    172px

Nothing on screen said there was anything under the list of names, so a dealer
with the table waiting had to scroll a list of players to find the only control
that finishes the hand.

Two goes at this, and the first was wrong. A sticky footer is the obvious
answer and it cannot work here: the actions were the last child of the
scrolling content, so their containing block ended exactly where they did and
`bottom` had no room to lift them. Sticky on the *header* did work, for the
mirror-image reason — its container extends below it — which is what made the
failure confusing to read. Capping the player list wasn't enough either; the
pot breakdown and the log still overflowed on a short screen.

So the dialog is a proper column now: a title that doesn't move, a body that
scrolls, and a footer outside the scroll. potWizard.js appends its buttons to
the footer instead of into the body, and clears both together so a step can't
inherit the previous step's buttons. Measured after: Confirm on screen and
hit-testable at all six configurations, twelve-handed on a Galaxy S8 included,
and the dialog itself no longer scrolls at all.

The checkboxes are painted now, too. `accent-color` only ever coloured the tick
— the box around it stayed the platform's own bright white square, which on a
near-black dialog was the loudest thing on screen and the last raw control in a
tool where everything else had been drawn. Same surface ramp, same corner
radius, and a tick drawn with clip-path so it can't pick up a substituted font.

uiAudit gains "an open dialog's buttons are on screen", which hit-tests rather
than trusting geometry — a button can be inside the viewport and still be under
something. Fault-injected: putting the footer back inside the scrolling body
reports Confirm 105px below the fold.

Two things checked and left alone, because they turned out to be right: the
standings table hides three columns on narrow screens through a container query
keyed to the sidebar rather than the viewport, which is why a desktop shows
fewer columns than an iPad — the sidebar is the narrower box there. And In/Won/
Net read $0 for hands settled outside the wizard, which the comment above them
already explains.

Caught by my own guard on the way past: the token test read `scroll-padding` as
`padding` and failed the spacing scale. Correct by its rules, wrong in substance
— those values are a sticky bar's measured height, not a step in the rhythm — so
the lookbehind now rejects a hyphen as well as a word character. Re-fault-
injected to confirm it still catches a real ad-hoc gap.

619 tests, 0 failures. 65 layout audits across 13 viewports x 5 table sizes and
35 across the five overlays. The whole wizard walked end to end: side pot
resolved, hand logged, $1,560 on the table before and after.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-03 12:20:02 -06:00
co-authored by Claude Opus 5
parent 1f3dda180f
commit c052ee32c8
5 changed files with 172 additions and 9 deletions
@@ -612,6 +612,13 @@
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
</div>
<div id="potWizardBody"></div>
@* The buttons live outside the scrolling body on purpose. This is the one
dialog that divides money, and its Confirm sat below a scroll at every screen
size — 244px off the bottom on an iPhone, 583 on a phone held sideways. A
sticky footer cannot fix that from inside: the actions were the last child of
the scrolling content, so their containing block ended exactly where they did
and `bottom` had no room to lift them. Out here they simply don't scroll. *@
<div id="potWizardFooter"></div>
</div>
</div>
+100 -3
View File
@@ -3185,15 +3185,58 @@ body.hasActionBar #toastHost {
controls couldn't be tapped. Leaving the bar's own height out of the budget
keeps the whole dialog above it. */
max-height: calc(85vh - var(--actionBarHeight, 0px));
overflow-y: auto;
overscroll-behavior: contain;
/* A column of three: the title, the part that scrolls, and the buttons. The modal
itself no longer scrolls — only its middle does — which is what keeps Confirm on
screen without asking a sticky footer to do something it cannot. */
display: flex;
flex-direction: column;
overflow: hidden;
padding: var(--sp-5) var(--sp-5) var(--sp-5);
}
#potWizardBody {
overflow-y: auto;
overscroll-behavior: contain;
/* Without this a flex child refuses to be shorter than its content, and the middle
would push the buttons back off the bottom of the dialog. */
min-height: 0;
/* Room for a focus ring on the first and last rows, which a flush edge clips. */
padding: 2px;
margin: -2px;
scroll-padding: var(--sp-4);
}
/* The footer is itself the row of buttons, so a step that appends a single button gets
the same full-width treatment as one that appends a pair — the winner step used to
put a lone Confirm in the left half of the dialog, looking like one option out of
several that hadn't drawn yet. */
#potWizardFooter {
flex: none;
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-4);
/* A hairline, so the buttons read as sitting below the list rather than as its
last row — the body scrolls under them and the edge is what says so. */
padding-top: var(--sp-3);
border-top: 1px solid var(--line);
}
#potWizardFooter > .holdemBtn,
#potWizardFooter > .potWizardActions {
flex: 1;
}
#potWizardFooter:empty {
display: none;
}
/* No longer sticky, and doesn't need to be: the modal stopped scrolling when its body
became the only thing that does, so the title and the ✕ stay put on their own. */
.potWizardHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
margin-bottom: var(--sp-3);
}
@@ -3220,6 +3263,19 @@ body.hasActionBar #toastHost {
margin-bottom: var(--sp-3);
}
/* This is the one screen in the tool that divides money, and its Confirm button sat
below a scroll at every size — measured with six players and a side pot: 244px of it
off the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways,
172 on a desktop. A dealer with the table waiting had to scroll a list of names to
find the only control that finishes the hand, and nothing on screen said there was
anything below it.
Capping the list rather than pinning the button, because a sticky footer cannot work
here: the actions are the last child of the scrolling content, so their containing
block ends exactly where they do and there is no room for `bottom` to lift them. The
list is the only part that grows without limit anyway — the pot breakdown, the
prompt and the buttons are all a fixed size — so bounding it keeps the whole dialog
inside the screen and the button where the dealer's thumb already is. */
.potWizardChoices {
display: flex;
flex-direction: column;
@@ -3251,11 +3307,52 @@ body.hasActionBar #toastHost {
}
}
/* The last raw browser control in the tool.
`accent-color` only paints the tick; the box around it stayed the platform's own —
a bright white square, which on this near-black dialog was the loudest thing on
screen and read as unfinished next to controls that had all been drawn. Painted to
match the rest, on the same surface ramp and the same corner radius. */
.potWizardChoice input {
flex: none;
appearance: none;
-webkit-appearance: none;
width: 22px;
height: 22px;
accent-color: #1e8449;
border: 2px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--surface-2);
display: grid;
place-content: center;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
/* The tick is drawn rather than typed, so it can't pick up a font the device happens
to substitute — and it scales with the box instead of with the text around it. */
.potWizardChoice input::before {
content: "";
width: 12px;
height: 12px;
transform: scale(0);
transition: transform 0.12s ease;
background: var(--surface-0);
clip-path: polygon(14% 46%, 0 60%, 40% 100%, 100% 22%, 86% 8%, 38% 72%);
}
.potWizardChoice input:checked {
background: #1e8449;
border-color: #2fa563;
}
.potWizardChoice input:checked::before {
transform: scale(1);
}
@media (prefers-reduced-motion: reduce) {
.potWizardChoice input,
.potWizardChoice input::before {
transition: none;
}
}
/* Selected has to be obvious at a glance — a dealer confirms this with players
@@ -87,6 +87,11 @@ const PotWizard = {
const body = document.getElementById("potWizardBody");
body.innerHTML = "";
// The footer is a sibling of the body, outside the scroll — see the note in
// TexasHoldem.cshtml. Cleared alongside it so a step can never inherit the
// previous step's buttons.
const footer = this.footer();
footer.innerHTML = "";
if (this.log.length === 0) body.appendChild(this.renderBreakdown());
body.appendChild(this.renderLog());
@@ -191,9 +196,13 @@ const PotWizard = {
this.entries = result.updatedEntries;
this.renderStep();
});
round.appendChild(confirmBtn);
body.appendChild(round);
footer.appendChild(confirmBtn);
},
// The row of buttons that never scrolls away.
footer() {
return document.getElementById("potWizardFooter");
},
recordRound(result) {
@@ -331,12 +340,10 @@ const PotWizard = {
const note = document.createElement("p");
note.className = "fieldHint potWizardPendingNote";
note.textContent = "Nothing has been paid out yet. Start Next Hand pushes the pot and logs the hand.";
wrap.appendChild(actions);
wrap.appendChild(note);
return wrap;
}
wrap.appendChild(actions);
this.footer().appendChild(actions);
return wrap;
},
};
+6 -1
View File
@@ -33,7 +33,12 @@ const body = declarations.slice(declarations.indexOf("#potBadgeAmount,"));
const valuesIn = (properties, source = body) => {
const found = [];
const re = new RegExp(`\\b(${properties.join("|")})\\s*:([^;}]*)`, "g");
// The lookbehind rejects a hyphen as well as a word character, so `scroll-padding`
// isn't read as `padding`. It caught the pot wizard's scroll-padding the moment
// that went in — correctly by its own rules, wrongly in substance: those two values
// are the measured heights of the dialog's sticky header and footer, a clearance
// like the action bar's, not a step in the spacing rhythm.
const re = new RegExp(`(?<![\\w-])(${properties.join("|")})\\s*:([^;}]*)`, "g");
let m;
while ((m = re.exec(source)) !== null) {
const [, prop, value] = m;
+47
View File
@@ -835,9 +835,56 @@ const LayoutAudit = {
return { name, offenders };
},
// An open dialog's buttons have to be on screen without hunting for them.
//
// The pot wizard — the one dialog in this tool that divides money — had its Confirm
// below a scroll at every size: measured with six players and a side pot, 244px off
// the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways, 172
// on a desktop. Nothing on screen said there was anything below the list of names,
// so the only control that finishes the hand was invisible with the table waiting.
//
// Checked by hit-testing rather than by geometry alone: a button can be inside the
// viewport and still be under something. Only dialogs that are actually open are
// considered, and only their buttons — a dialog with no buttons has nothing to find.
dialogActionsReachable() {
const name = "an open dialog's buttons are on screen";
const dialogs = [...document.querySelectorAll("[role='dialog'], [role='alertdialog']")]
.filter(d => {
const rect = d.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && getComputedStyle(d).visibility !== "hidden";
});
if (dialogs.length === 0) return { name, offenders: [], skipped: "no dialog is open" };
const offenders = [];
dialogs.forEach(dialog => {
const buttons = [...dialog.querySelectorAll("button")].filter(b => {
const r = b.getBoundingClientRect();
return r.width > 0 && r.height > 0;
});
buttons.forEach(button => {
const r = button.getBoundingClientRect();
const label = this.describe(button);
if (r.bottom > innerHeight || r.top < 0) {
const off = r.bottom > innerHeight
? Math.round(r.bottom - innerHeight) + "px below the fold"
: Math.round(-r.top) + "px above it";
offenders.push(`${label} is ${off}`);
return;
}
const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
if (hit && hit !== button && !button.contains(hit)) {
offenders.push(`${label} is covered by ${this.describe(hit)}`);
}
});
});
return { name, offenders };
},
runAll(options = {}) {
const checks = [
this.seatsOnTheTable(options.seatSlack),
this.dialogActionsReachable(),
this.touchAction(),
this.tapHighlight(),
this.textSelectable(),