Hold'em dealer: a table that reads its own showdown, and a tool that is there with no signal #120
@@ -16,10 +16,13 @@
|
||||
danger is on the label rather than at the end of a scroll — Quick Split down
|
||||
with Common rulings where the other "open something else" link already lives,
|
||||
and the way back to the site up top where a back link goes. *@
|
||||
@* The word is its own element so a phone can drop it and keep the arrow, and the
|
||||
aria-label is what a screen reader gets either way. It contains the visible
|
||||
text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name
|
||||
that says more than the label does. *@
|
||||
<div id="holdemHeaderLeft">
|
||||
<a href="/casino" class="holdemBackLink">← Casino</a>
|
||||
<a href="/casino" class="holdemBackLink" aria-label="Back to Casino">←<span class="backLinkWord"> Casino</span></a>
|
||||
<h1>Texas Hold'em Dealer</h1>
|
||||
<span id="handCounterBadge" class="handCounterBadge">Hand #1</span>
|
||||
</div>
|
||||
<div id="holdemHeaderActions">
|
||||
<button id="misdealBtn" class="holdemBtn" title="Take this hand back — every chip returns to the player who put it in, and the same button deals again" hidden>Misdeal</button>
|
||||
|
||||
@@ -422,16 +422,10 @@ button,
|
||||
}
|
||||
}
|
||||
|
||||
.handCounterBadge {
|
||||
display: inline-block;
|
||||
margin-top: var(--sp-2);
|
||||
padding: var(--sp-1) var(--sp-3);
|
||||
border-radius: var(--r-pill);
|
||||
background: rgba(216, 180, 106, 0.12);
|
||||
border: 1px solid rgba(216, 180, 106, 0.4);
|
||||
color: var(--gold);
|
||||
font-size: var(--fs-sm);
|
||||
letter-spacing: 0.04em;
|
||||
/* The word beside the arrow, which a phone drops. Below 700px the link is the arrow
|
||||
and a 44px box around it; the name a screen reader gets is on the <a> either way. */
|
||||
.backLinkWord {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#holdemHeaderActions {
|
||||
@@ -3103,6 +3097,16 @@ body.hasActionBar #holdemApp {
|
||||
}
|
||||
}
|
||||
|
||||
/* Which hand this is, at the head of the row that says what hand you are in. Quieter
|
||||
than the street beside it — the street is read every deal and this is read when
|
||||
somebody asks — and tabular, because it is a number that ticks over while the dealer
|
||||
is looking at it. */
|
||||
.actionHand {
|
||||
color: var(--text-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.actionStreet {
|
||||
color: var(--gold);
|
||||
font-weight: 600;
|
||||
@@ -4404,26 +4408,92 @@ body.hasActionBar #toastHost {
|
||||
padding: var(--sp-4) var(--sp-3) calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
/* ---- One row, the way a phone on its side already had it ----
|
||||
|
||||
The header stood 120px tall on a 390px phone and only 46 of that was a control.
|
||||
The other 74 were a 22px title with a gold rule under it, a back link and a gold
|
||||
"Hand #1" pill — read once, at the top of a screen whose subject is the green
|
||||
table underneath. Sixteen percent of the window, before the felt started.
|
||||
|
||||
A phone held sideways has solved this since the landscape block went in: one row,
|
||||
title clipped, arrow and buttons. Upright never got the same answer, and upright
|
||||
is how this tool is held. It is 44px now, and the felt is 76px taller — 496 to
|
||||
572, about fifteen percent more table on the same screen.
|
||||
|
||||
Nothing left the page. The title is still the document's <h1>, clipped rather
|
||||
than removed, so it is still the heading a screen reader lands on and still what
|
||||
the browser tab and the installed app are called. The hand number moved into the
|
||||
action bar's status row, where the street, the pot and the player count already
|
||||
say what hand you are in. And the arrow keeps its 44px box; only the word "Casino"
|
||||
is gone, which is what every phone does with a back link. */
|
||||
#holdemHeader {
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
margin-bottom: var(--sp-4);
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* So the arrow is a flex item of the header itself rather than a block inside a
|
||||
block — there is nothing else left in this group once the title is clipped. */
|
||||
#holdemHeaderLeft {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* Clipped, not removed: `display: none` would take the page's only <h1> off the
|
||||
accessibility tree, and a page whose heading disappears below 700px is a page
|
||||
with no heading on the device it is mostly read on. */
|
||||
#holdemHeader h1 {
|
||||
font-size: var(--fs-xl);
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backLinkWord {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 44 square, not 32. The word used to carry the width and the padding only had to
|
||||
make up the height; with the word gone the arrow is the whole target, and the
|
||||
layout sweep caught it at 31x43 on every touch viewport there is. It costs
|
||||
nothing — the arrow and the three buttons come to 323px of the 336 a 360px phone
|
||||
has, which is the narrowest screen in the sweep. */
|
||||
.holdemBackLink {
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
/* The negative margins that gave the hit area back to a two-row header would
|
||||
now pull the arrow out of a row it is the first item of. */
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
/* Takes the row rather than shrink-wrapping to the right of it. Sized to their
|
||||
labels the three buttons left 78px of nothing where the title used to be, and
|
||||
that gap buys nothing — spent on the buttons instead it is about 26px of extra
|
||||
target each, on the controls a dealer presses once a hand with a thumb. */
|
||||
#holdemHeaderActions {
|
||||
width: 100%;
|
||||
gap: var(--sp-2);
|
||||
flex: 1 1 auto;
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
/* Grow into that spare width, never shrink below the label. `flex: 1 1 0` would
|
||||
divide the row into equal thirds and squeeze "Next Hand ⟳" — 105px of text — into
|
||||
99, and these are `white-space: nowrap`, so what gives is the text. An `auto`
|
||||
basis makes the label the floor and the spare width the bonus. */
|
||||
#holdemHeaderActions .holdemBtn {
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
font-size: var(--fs-base);
|
||||
padding: var(--sp-2) var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Keeps every puck inside the viewport: the ring is drawn within this box,
|
||||
@@ -4842,8 +4912,12 @@ body.hasActionBar #toastHost {
|
||||
they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
|
||||
the page title: the one thing on this screen a dealer already knows. It is hidden
|
||||
from the eye rather than removed, so the page keeps its heading for a screen
|
||||
reader and for the browser's own outline. The hand number stays — nothing else on
|
||||
the screen says which hand this is. */
|
||||
reader and for the browser's own outline.
|
||||
|
||||
The upright block above now does all of this too, and the hand pill it used to
|
||||
make room for has gone to the action bar's status row for both of them. What is
|
||||
left here is what upright does not need: this query reaches up to 900px wide,
|
||||
where the 700px block does not, so a phone held sideways still needs to be told. */
|
||||
#holdemHeader {
|
||||
margin-bottom: var(--sp-2);
|
||||
gap: var(--sp-1);
|
||||
@@ -4851,6 +4925,10 @@ body.hasActionBar #toastHost {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#holdemHeaderLeft {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
#holdemHeader h1 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
@@ -4862,17 +4940,28 @@ body.hasActionBar #toastHost {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.handCounterBadge {
|
||||
margin-top: 0;
|
||||
.backLinkWord {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.holdemBackLink {
|
||||
flex: none;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
/* Tightened across, never down: the buttons keep the 44px a thumb needs. */
|
||||
#holdemHeaderActions {
|
||||
flex-wrap: nowrap;
|
||||
margin-left: auto;
|
||||
min-width: 0;
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
#holdemHeaderActions .holdemBtn {
|
||||
flex: 0 1 auto;
|
||||
padding-left: var(--sp-2);
|
||||
padding-right: var(--sp-2);
|
||||
font-size: var(--fs-sm);
|
||||
|
||||
@@ -277,10 +277,29 @@ const ActionBar = {
|
||||
return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`;
|
||||
},
|
||||
|
||||
// Pure, so the wording can be checked without a page to hang it on — the same reason
|
||||
// Render.panelGlances is. A table that has never dealt is on hand one, not hand
|
||||
// zero and not hand undefined, and that fallback is the whole of what there is to
|
||||
// get wrong here.
|
||||
handLabel(state) {
|
||||
return `#${(state && state.handNumber) || 1}`;
|
||||
},
|
||||
|
||||
renderStatus() {
|
||||
const row = document.createElement("div");
|
||||
row.className = "actionStatus";
|
||||
|
||||
// Which hand this is. It used to be a gold pill beside the page title, and it
|
||||
// was the wrong fact in the wrong place: a counter that changes once a hand,
|
||||
// read after the fact, holding a line of a 390px phone's header all night. It
|
||||
// belongs with the street, the pot and the player count — the other three
|
||||
// things that say what hand you are in — and here it costs nothing, because
|
||||
// this row was already drawn.
|
||||
const hand = document.createElement("span");
|
||||
hand.className = "actionHand";
|
||||
hand.textContent = this.handLabel(this.state);
|
||||
row.appendChild(hand);
|
||||
|
||||
const street = document.createElement("span");
|
||||
street.className = "actionStreet";
|
||||
street.textContent = Betting.streetLabel(this.state.street);
|
||||
|
||||
@@ -3031,8 +3031,6 @@ const Render = {
|
||||
if (!force && signature === this.lastHistorySignature) return;
|
||||
this.lastHistorySignature = signature;
|
||||
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// What stands between the top of a phone and the felt.
|
||||
//
|
||||
// The header was 120px of a 390x844 phone — 16% of the window, spent before the table
|
||||
// started — and only 46 of that was a control. The other 74 were a 22px page title with
|
||||
// a gold rule under it, a back link, and a gold "Hand #1" pill: three things read once,
|
||||
// above a screen whose subject is the green table underneath.
|
||||
//
|
||||
// A phone held sideways had already solved this. The landscape block clips the title and
|
||||
// puts everything on one row, because at 390px tall there was no other option. Upright
|
||||
// never got the same answer, and upright is how this tool is held: measured after, the
|
||||
// header is 44px and the felt went from 496px to 570.
|
||||
//
|
||||
// Two claims are checked here. The stylesheet's, as text — the same way the seat sheet's
|
||||
// and the reduced-motion rules are — because "which row is this on" is a layout decision
|
||||
// that lives in the file. And the action bar's, by running it: the hand number had to go
|
||||
// somewhere, and where it went is a rendering question.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ActionBar, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
// Every @media block written at exactly this condition, concatenated.
|
||||
//
|
||||
// All of them, not the first one — `@media (max-width: 700px)` is opened twice in this
|
||||
// file, the second time to hold the phone layout proper, and a helper that stopped at
|
||||
// the first found a block with none of these rules in it and reported 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);
|
||||
return found ? found[2] : null;
|
||||
};
|
||||
|
||||
const PHONE = () => mediaBlock("(max-width: 700px)");
|
||||
// The header's sideways rules are in the plain landscape query, not the one that also
|
||||
// caps the width — that later block is the seat sheet's and the action rail's. Both
|
||||
// exist, and picking the wrong one finds a block with no header in it at all.
|
||||
const LANDSCAPE = () => mediaBlock("(orientation: landscape) and (max-height: 560px)");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("the header is one row on a phone", () => {
|
||||
test("upright gets the shape a phone on its side already had", () => {
|
||||
// These four together are the whole change: don't wrap, drop the word beside the
|
||||
// arrow, clip the title, and let the buttons size themselves in what is left.
|
||||
const phone = PHONE();
|
||||
assert.match(ruleIn(phone, "#holdemHeader") || "", /flex-wrap:\s*nowrap/,
|
||||
"a wrapping header is a two-row header");
|
||||
assert.match(ruleIn(phone, "#holdemHeaderLeft") || "", /display:\s*contents/,
|
||||
"the arrow has to be a flex item of the header, not a block inside a block");
|
||||
assert.ok(ruleIn(phone, ".backLinkWord"), "the word beside the arrow is not dropped");
|
||||
assert.ok(ruleIn(phone, "#holdemHeader h1"), "the title is not addressed on a phone");
|
||||
});
|
||||
|
||||
test("both narrow layouts clip the title rather than removing it", () => {
|
||||
// `display: none` would take the page's only <h1> off the accessibility tree, and
|
||||
// a page whose heading disappears below 700px is a page with no heading on the
|
||||
// device it is mostly read on.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, "#holdemHeader h1");
|
||||
assert.ok(rule, `${which} does not address the title`);
|
||||
assert.doesNotMatch(rule, /display:\s*none/,
|
||||
`${which} removes the page's heading instead of hiding it`);
|
||||
assert.match(rule, /clip-path:\s*inset\(50%\)/,
|
||||
`${which} should clip the heading the way .visuallyHidden does`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the actions row stops claiming a row of its own", () => {
|
||||
// `width: 100%` is what made it a second row: a flex child at full width cannot
|
||||
// share a line with anything, whatever the wrap setting says.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions") || "";
|
||||
assert.doesNotMatch(rule, /width:\s*100%/);
|
||||
assert.match(rule, /flex:\s*1/, "it should take the width the title gave up");
|
||||
});
|
||||
|
||||
test("and the buttons grow into that width without ever shrinking below their labels", () => {
|
||||
// `flex: 1 1 0` would divide the row into equal thirds and squeeze "Next Hand ⟳"
|
||||
// — 105px of text — into 99. These are `white-space: nowrap`, so what gives is
|
||||
// the text.
|
||||
const rule = ruleIn(PHONE(), "#holdemHeaderActions .holdemBtn") || "";
|
||||
assert.match(rule, /flex:\s*1\s+1\s+auto/, "an auto basis makes the label the floor");
|
||||
assert.match(rule, /white-space:\s*nowrap/);
|
||||
assert.match(rule, /min-height:\s*46px/, "across is the only direction that tightens");
|
||||
});
|
||||
|
||||
test("the arrow keeps a thumb-sized box once it loses its word", () => {
|
||||
// The word used to carry the width, and the padding only had to make up the
|
||||
// height. With the word gone the arrow is the whole target, and the first
|
||||
// attempt at 32px was measured by the layout sweep at 31x43 on every touch
|
||||
// viewport it runs — 56 scenarios, one control.
|
||||
[["upright", PHONE()], ["sideways", LANDSCAPE()]].forEach(([which, block]) => {
|
||||
const rule = ruleIn(block, ".holdemBackLink") || "";
|
||||
assert.match(rule, /min-width:\s*44px/, `${which}'s back arrow is under the touch floor`);
|
||||
// The negative margins that gave the hit area back to a two-row header would
|
||||
// now pull the arrow out of a row it is the first item of.
|
||||
assert.match(rule, /margin:\s*0/);
|
||||
});
|
||||
});
|
||||
|
||||
test("and it still says where it goes when the word is gone", () => {
|
||||
// An "←" on its own is announced as "left arrow" and names nothing. The label
|
||||
// contains the visible text on the sizes that still show it, which is what
|
||||
// WCAG 2.5.3 asks of a name that says more than the label does.
|
||||
const link = PAGE.match(/<a href="\/casino"[^>]*>/);
|
||||
assert.ok(link, "the back link is gone");
|
||||
const label = link[0].match(/aria-label="([^"]+)"/);
|
||||
assert.ok(label, "the back link has no accessible name for when the word is hidden");
|
||||
assert.match(label[1], /Casino/,
|
||||
"the visible word has to appear in the spoken name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand number found somewhere to live", () => {
|
||||
test("it is not a pill beside the page title any more", () => {
|
||||
assert.ok(!PAGE.includes("handCounterBadge"),
|
||||
"a counter read after the fact does not earn a line of a phone's header");
|
||||
});
|
||||
|
||||
test("it is in the row that already says what hand you are in", () => {
|
||||
// The street, the pot and the player count are the other three facts about the
|
||||
// current hand, and they are drawn together. This one costs nothing there: the
|
||||
// row was on the screen already.
|
||||
const source = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem", "actionBar.js"),
|
||||
"utf8");
|
||||
// Sliced from the top of renderStatus to the first mention of the street, so
|
||||
// finding the hand chip inside that slice is the ordering claim as well as the
|
||||
// presence one: it is appended before the street or it is not in here.
|
||||
const status = source.slice(source.indexOf("renderStatus()"), source.indexOf("actionStreet"));
|
||||
assert.match(status, /actionHand/,
|
||||
"the status row does not say which hand this is, or says it after the street");
|
||||
});
|
||||
|
||||
test("a table that has never dealt says #1, not #0 and not #undefined", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
delete state.handNumber;
|
||||
assert.equal(ActionBar.handLabel(state), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 0 }), "#1");
|
||||
assert.equal(ActionBar.handLabel({ handNumber: 12 }), "#12");
|
||||
assert.equal(ActionBar.handLabel(null), "#1");
|
||||
});
|
||||
|
||||
test("it is drawn quieter than the street beside it", () => {
|
||||
// The street is read every deal; this is read when somebody asks about a hand
|
||||
// that is already over. Same row, different job, and the colour says so.
|
||||
const rule = CSS.match(/\.actionHand\s*\{([^}]*)\}/);
|
||||
assert.ok(rule, ".actionHand has no rule");
|
||||
assert.match(rule[1], /color:\s*var\(--text-dim\)/);
|
||||
// A number that ticks over while the dealer is looking at it.
|
||||
assert.match(rule[1], /font-variant-numeric:\s*tabular-nums/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user