From e9ec4afafe8af3c5652e5616d2e07a085b0c2030 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Mon, 14 Sep 2026 01:03:45 -0600 Subject: [PATCH] Ask a card the two questions it is, not one out of fifty-two MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker put the whole deck on the screen at once — four suits across, thirteen ranks down — and that grid is 646px tall. Measured with the board tracked and seven cards already out: 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold 667x375 816 in 330, 40 below 844x390 774 in 343, 36 below 375x667 816 in 587, 20 below 1280x900 774 in 775, none So on every phone the tool is actually used on, entering the flop began with a scroll, and a dealer looking for the nine of diamonds was reading down a column to find it. No arrangement fixes that: fifty-two targets at the 44px this sweep holds everything else to need 572px of width before gaps if they are thirteen across, which is wider than any phone in portrait, and thirteen rows deep if they are four. A card is not one choice out of fifty-two. It is two — a suit and a rank — and that is how a dealer says it out loud. So the sheet asks them in that order: four suits on a row, then the thirteen ranks of the one chosen. Measured afterwards at eight viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every button still at least 44x44 and still hit-testing as itself. Portrait content went 816 -> 372, landscape 816 -> 322. The second tap is not a second cost in the usual case. The suit stays where it was put, so a second card of the same suit is one tap — and one tap is what picking a card used to cost *plus* a scroll through thirteen rows. Opening on a slot that already holds a card shows that card's suit with it under the cursor, which is the case where the dealer came to change something rather than add it. Three things that had to move with it: - the cursor is a rank now, 0-12, and changing suit deliberately leaves it alone: the same rank in another suit is the card beside the one you were on. - the arrows read the column count off the layout instead of assuming SUITS.length. The rank row is auto-fill — five across on a 360px phone, seven at 460 — and the last time this number disagreed with the grid it slid the cursor into a column nobody asked for. - the suits are a radiogroup, not a tablist: the grid below is not another panel of content, it is the same thirteen controls filtered, and the unchosen red suits stay red, because reading four shapes to find the hearts is the search this sheet exists to remove. deck() still returns all fifty-two and the duplicate check still reads all of it, so splitting the grid by suit cannot let a card be dealt twice. 1276 tests pass; the layout sweep reports clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml | 11 +- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 70 ++++- .../wwwroot/js/casino/holdem/cardPicker.js | 149 +++++++++-- tests/holdem/theDeckInTwoQuestions.test.js | 245 ++++++++++++++++++ tests/holdem/theKeyboardInASheet.test.js | 54 ++-- 5 files changed, 492 insertions(+), 37 deletions(-) create mode 100644 tests/holdem/theDeckInTwoQuestions.test.js diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 3519795..ba75b70 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -760,7 +760,16 @@ that filling it doesn't shove fifty-two cards down the screen under a thumb that is already reaching for one. *@

-
+ @* The first of the two questions a card is. A radiogroup rather than a tablist: + the grid below is not a different panel of content, it is the same thirteen + controls filtered, and single-select filtering is what a radio group is for. + The suits are a fixed set written by paint(), not here, so that the one place + that knows what a suit looks like stays CardPicker.SUITS. *@ +
+ @* Which suit's thirteen these are, said once for a screen reader rather than + left to be inferred from the chosen radio two elements up. *@ + +
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 155dccb..7e7fdf7 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -2664,9 +2664,70 @@ input[type="checkbox"] { color: #e07b7b; } +/* The first of the two questions. Four, side by side, in the same four places every + time the sheet opens — a row a thumb can learn, rather than a control to read. */ +#cardPickerSuits { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--sp-2); + margin-bottom: var(--sp-2); +} + +.pickerSuitTab { + display: flex; + align-items: center; + justify-content: center; + min-height: 48px; + border-radius: var(--r-md); + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--text-bright); + font-family: inherit; + /* Larger than any type on the sheet. The glyph is the whole of the control, and at + body size four of them across a phone read as decoration on a row of blank + buttons rather than as the thing being chosen. */ + font-size: var(--fs-xl); + line-height: 1; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +/* Red whether or not it is the chosen one. Drawing the three unchosen suits in one grey + made a dealer reaching for the hearts read four glyphs to find them, which is the + search this sheet exists to remove — and it is the one thing about a suit that can be + seen before the shape is. The same red the slots above already use. */ +.pickerSuitTab.cardRed { + color: #e07b7b; +} + +/* The chosen suit is drawn as the cards below it are — white face, dark ink — because + it is a statement about them: these thirteen are that suit. Anything quieter and the + row reads as four buttons with one of them slightly darker. */ +.pickerSuitTab.chosen { + border-color: #d5d5d5; + background: #fdfdfd; + color: #111; +} + +.pickerSuitTab.cardRed.chosen { + color: #b32020; +} + +@media (hover: hover) { + .pickerSuitTab:hover:not(.chosen) { + border-color: var(--line-strong); + color: var(--text-bright); + } +} + +/* As many ranks across as the row holds, rather than a fixed count that is either too + many for a 360px phone or too few for one held sideways. 3.25rem is the floor a card + is allowed to reach: 52px, comfortably over the 44 a thumb needs, which comes out at + five across in portrait and nine or more in landscape. The arrows read the column + count back off this rule rather than assuming one — see CardPicker.gridStride. */ #cardPickerGrid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr)); gap: var(--sp-1); } @@ -6050,6 +6111,13 @@ body.hasActionBar #toastHost { column-gap: var(--sp-3); } + /* The card picker is six pixels over on the shortest phone in the list — 322px of + content in the 317 that 88vh comes to at 360. The same seven points the seat menu + takes buy 14, which is the whole of it. */ + #cardPickerSheet { + max-height: 92vh; + } + /* The move picker is a list of chairs and has no hand tier at all, so there is nothing to put in the right-hand column and a column reserved for nothing would narrow the names for no reason. One column, and the chairs spread across it. */ diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js index 117bfc0..6faf216 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js @@ -2,23 +2,51 @@ // // Typing "AH" into a two-character box is fine at a desk and miserable at a table: // it needs a keyboard, it accepts nonsense like "ZZ", and it happily lets the same -// card land in two players' hands. The picker is one tap per card, greys out cards -// already dealt somewhere else at the table, and stores exactly the same two-character -// string the rest of the tool has always read. +// card land in two players' hands. The picker greys out cards already dealt somewhere +// else at the table and stores exactly the same two-character string the rest of the +// tool has always read. +// +// It used to put all fifty-two on the screen at once, four suits across and thirteen +// ranks down, and that grid is 646px tall. Measured with the board tracked and seven +// cards out: +// +// 640x360 816px of content in a 317px sheet — 40 of the 52 below the fold +// 667x375 816 in 330 — 40 below +// 844x390 774 in 343 — 36 below +// 375x667 816 in 587 — 20 below +// 1280x900 774 in 775 — none +// +// So on every phone the tool is actually used on, entering the flop began with a +// scroll, and a dealer looking for the nine of diamonds was reading down a column. +// Fifty-two 44px targets do not fit a phone in any arrangement: thirteen across is +// 572px of grid before gaps, which is wider than any phone in portrait. +// +// A card is not one choice out of fifty-two. It is two: a suit and a rank. So the sheet +// asks them in that order — four suits on a row, then the thirteen ranks of the one +// chosen — which is seventeen targets instead of fifty-two, all of them on the screen +// at once on every phone, and none of them smaller than they were. The suit stays put +// between picks, so a second card of the same suit is still a single tap. const CardPicker = { RANKS: ["A", "K", "Q", "J", "T", "9", "8", "7", "6", "5", "4", "3", "2"], SUITS: [ - { code: "S", symbol: "♠", red: false }, - { code: "H", symbol: "♥", red: true }, - { code: "D", symbol: "♦", red: true }, - { code: "C", symbol: "♣", red: false }, + { code: "S", symbol: "♠", name: "Spades", red: false }, + { code: "H", symbol: "♥", name: "Hearts", red: true }, + { code: "D", symbol: "♦", name: "Diamonds", red: true }, + { code: "C", symbol: "♣", name: "Clubs", red: false }, ], - // Which card in the grid the arrows are holding, as a flat index into - // RANKS x SUITS. Kept across a redraw so the grid doesn't send the dealer back to - // the ace of spades every time they pick something. + // Which card in the grid the arrows are holding, as an index into the thirteen + // ranks on show. Kept across a redraw so the grid doesn't send the dealer back to + // the ace every time they pick something — and kept across a change of suit, which + // is the same rank in a different suit rather than a new place to be. cursor: 0, + // Which suit's thirteen are on the grid. Deliberately not reset when the sheet + // opens: a dealer entering a flush board, or two suited hole cards, picks the suit + // once. Where the slot being filled already holds a card, that card's suit wins — + // the sheet should open showing what it is about to replace. + suit: "S", + state: null, hooks: null, // Which set of slots is being filled: a seat's two hole cards, or the five @@ -36,6 +64,7 @@ const CardPicker = { this.seatIndex = seatIndex; this.cardIndex = cardIndex; this.cursor = 0; + this.aimSuit(); this.render(); document.getElementById("cardPickerOverlay").hidden = false; }, @@ -51,6 +80,7 @@ const CardPicker = { this.boardLimit = Math.max(1, Math.min(5, limit)); this.cardIndex = Math.min(cardIndex, this.boardLimit - 1); this.cursor = 0; + this.aimSuit(); this.render(); document.getElementById("cardPickerOverlay").hidden = false; }, @@ -145,10 +175,10 @@ const CardPicker = { return ["Flop 1", "Flop 2", "Flop 3", "Turn", "River"][slot] || `Card ${slot + 1}`; }, - // Every card in the deck, in the order the grid draws them: four suits across, - // thirteen ranks down. The arrow keys and the roving tabindex both work in this - // order, so it is written once rather than rebuilt by two nested loops that have to - // agree. + // Every card in the deck, ace down to two, each rank's four suits together. The + // grid shows one suit's worth of this at a time — see gridCards — and the duplicate + // check reads the whole of it, so the order is written once here rather than + // rebuilt by two nested loops that have to agree. deck() { const cards = []; this.RANKS.forEach(rank => { @@ -157,6 +187,64 @@ const CardPicker = { return cards; }, + suitOf(code) { + return this.SUITS.find(suit => suit.code === code) || this.SUITS[0]; + }, + + // The thirteen on the grid: one suit's worth of deck(), in deck order, so the ranks + // read A down to 2 exactly as they always have. + gridCards() { + return this.deck().filter(entry => entry.suit.code === this.suit); + }, + + // Open on the card that is already there. A slot holding the nine of diamonds + // should show the diamonds with the nine under the cursor — the dealer came here to + // change that card, and hunting for where it lives is work the sheet can do. An + // empty slot changes nothing, which is what keeps the suit across a flush board. + aimSuit() { + const current = (this.slotValues()[this.cardIndex] || "").toUpperCase(); + if (!/^[AKQJT2-9][SHDC]$/.test(current)) return; + this.suit = current.slice(1); + const rank = this.RANKS.indexOf(current.slice(0, 1)); + if (rank >= 0) this.cursor = rank; + }, + + // Changing suit is not moving: the cursor is a rank, and the same rank in another + // suit is the card directly beside the one you were on. Coming back with the cursor + // reset to the ace would undo the only thing the dealer had told the sheet. + setSuit(code, { focus = false } = {}) { + if (this.suit === code) return; + this.suit = code; + this.render(); + if (focus) { + const chosen = document.querySelector(`#cardPickerSuits [data-suit="${code}"]`); + if (chosen) chosen.focus({ preventScroll: true }); + } + }, + + // Arrow keys along the suits, the way the APG's radio group asks for: one Tab stop + // for the set, arrows to move within it, and moving is choosing. + onSuitKey(event) { + const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key]; + if (step === undefined) return; + event.preventDefault(); + const at = this.SUITS.findIndex(suit => suit.code === this.suit); + const to = (at + step + this.SUITS.length) % this.SUITS.length; + this.setSuit(this.SUITS[to].code, { focus: true }); + }, + + // How far Down and Up move, asked of the grid rather than assumed. The row is as + // many ranks as the width holds — five on a 360px phone, nine on a phone held + // sideways — so a constant here would be a lie on most screens, and the last time + // this number disagreed with the layout the cursor slid into a column nobody asked + // for. Outside a browser there is nothing to measure and nothing to move. + gridStride() { + const grid = typeof document !== "undefined" && document.getElementById("cardPickerGrid"); + if (!grid || typeof getComputedStyle !== "function") return this.RANKS.length; + const columns = getComputedStyle(grid).gridTemplateColumns.split(/\s+/).filter(Boolean).length; + return columns > 0 ? columns : this.RANKS.length; + }, + // Where the arrows can go: one card, in the direction asked for, and nowhere if // that runs off the end of the grid. Wrapping would put the dealer on a different // rank without them asking to move rank. @@ -197,7 +285,7 @@ const CardPicker = { }, onGridKey(event) { - const stride = this.SUITS.length; + const stride = this.gridStride(); const step = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: stride, ArrowUp: -stride }[event.key]; if (step !== undefined) { event.preventDefault(); @@ -266,9 +354,38 @@ const CardPicker = { slots.appendChild(btn); }); + // The first question. Four of them, always in the same four places, so the one + // the dealer wants is somewhere their thumb already knows. + const suits = document.getElementById("cardPickerSuits"); + suits.innerHTML = ""; + this.SUITS.forEach(suit => { + const chosen = suit.code === this.suit; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "pickerSuitTab"; + btn.classList.toggle("cardRed", suit.red); + btn.classList.toggle("chosen", chosen); + btn.textContent = suit.symbol; + btn.setAttribute("role", "radio"); + btn.setAttribute("aria-checked", chosen ? "true" : "false"); + // The glyph is the whole of the control on the eye, and "♦, radio button" + // is not a suit out loud. The word is. + btn.setAttribute("aria-label", suit.name); + btn.dataset.suit = suit.code; + // One Tab stop for the four, like the grid below it has one for thirteen. + btn.tabIndex = chosen ? 0 : -1; + btn.dataset.focusKey = `suit-${suit.code}`; + btn.addEventListener("click", () => this.setSuit(suit.code, { focus: true })); + btn.addEventListener("keydown", event => this.onSuitKey(event)); + suits.appendChild(btn); + }); + + const gridLabel = document.getElementById("cardPickerGridLabel"); + if (gridLabel) gridLabel.textContent = `${this.suitOf(this.suit).name} — pick a rank`; + const grid = document.getElementById("cardPickerGrid"); grid.innerHTML = ""; - this.deck().forEach(({ rank, suit, card }) => { + this.gridCards().forEach(({ rank, suit, card }) => { const takenBy = used.get(card); const btn = document.createElement("button"); diff --git a/tests/holdem/theDeckInTwoQuestions.test.js b/tests/holdem/theDeckInTwoQuestions.test.js new file mode 100644 index 0000000..9c70a92 --- /dev/null +++ b/tests/holdem/theDeckInTwoQuestions.test.js @@ -0,0 +1,245 @@ +// Fifty-two cards on a phone. +// +// The picker put the whole deck on the screen at once — four suits across, thirteen +// ranks down — and that grid is 646px tall. Measured with the board tracked and seven +// cards already out: +// +// 640x360 816px of content in a 317px sheet, 40 of the 52 below the fold +// 667x375 816 in 330, 40 below +// 844x390 774 in 343, 36 below +// 375x667 816 in 587, 20 below +// 1280x900 774 in 775, none +// +// So on every phone the tool is actually used on, entering the flop began with a +// scroll, and a dealer looking for the nine of diamonds read down a column to find it. +// There is no arrangement that fixes that: fifty-two targets at the 44px this sweep +// holds everything else to need 572px of width before gaps if they are thirteen across, +// which is wider than any phone in portrait, and thirteen rows deep if they are four. +// +// A card is not one choice out of fifty-two. It is two — a suit and a rank — and that +// is how a dealer says it out loud. So the sheet asks them in that order: four suits on +// a row, then the thirteen ranks of the one chosen. Seventeen targets instead of +// fifty-two, none of them smaller than they were, and measured afterwards at eight +// viewports from 360x640 to 1280x900: nothing below the fold, nothing scrolling, every +// button still at least 44x44 and still hit-testing as itself. +// +// The second tap is not a second cost in the usual case. The suit stays where it was +// put, so a second card of the same suit is one tap — and picking one card used to be +// one tap plus a scroll through thirteen rows. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); +const { CardPicker } = require("./load"); + +const ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net"); +const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8"); +const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8"); +const SRC = fs.readFileSync(path.join(ROOT, "wwwroot", "js", "casino", "holdem", "cardPicker.js"), "utf8"); + +const ruleFor = selector => { + const at = CSS.indexOf(`\n${selector} {`); + assert.ok(at >= 0, `there is no \`${selector}\` rule`); + return CSS.slice(at, CSS.indexOf("}", at)); +}; + +const methodBody = name => { + const at = SRC.indexOf(`\n ${name}`); + assert.ok(at >= 0, `cardPicker has no ${name}`); + return SRC.slice(at, SRC.indexOf("\n },", at)); +}; + +// The picker reaches for the global document in two places. Neither is exercised here — +// these tests ask what it decides, not what it draws — but aimSuit reads the slot it is +// about to fill, so it needs the state and nothing else. +function aimedAt(board, cardIndex, { suit = "S", cursor = 0 } = {}) { + CardPicker.state = { board: board.slice(), seats: [] }; + CardPicker.target = "board"; + CardPicker.boardLimit = board.length; + CardPicker.cardIndex = cardIndex; + CardPicker.suit = suit; + CardPicker.cursor = cursor; + CardPicker.aimSuit(); + return { suit: CardPicker.suit, cursor: CardPicker.cursor }; +} + +// --------------------------------------------------------------------------- + +describe("the deck is still the deck", () => { + test("all fifty-two of it, ace down to two", () => { + const deck = CardPicker.deck(); + assert.equal(deck.length, 52); + assert.equal(new Set(deck.map(entry => entry.card)).size, 52); + assert.equal(deck[0].card, "AS"); + assert.equal(deck[51].card, "2C"); + }); + + // Which is what the duplicate check reads, and why splitting the *grid* by suit + // cannot let the same card be dealt twice. + test("and a card already out is still a card already out, in any suit", () => { + CardPicker.state = { + seats: [{ name: "Mike", cards: ["AS", "KH"] }], + board: ["7D", "", "", "", ""], + }; + CardPicker.target = "board"; + CardPicker.boardLimit = 5; + CardPicker.cardIndex = 1; + const used = CardPicker.cardsInUse(); + assert.equal(used.get("AS"), "Mike"); + assert.equal(used.get("KH"), "Mike"); + assert.equal(used.get("7D"), "the board"); + assert.equal(used.size, 3); + }); +}); + +describe("one suit at a time", () => { + test("the grid is thirteen ranks of the chosen suit, in deck order", () => { + CardPicker.suit = "D"; + const cards = CardPicker.gridCards(); + assert.equal(cards.length, 13); + assert.deepEqual(cards.map(entry => entry.card).slice(0, 3), ["AD", "KD", "QD"]); + assert.equal(cards[12].card, "2D"); + assert.ok(cards.every(entry => entry.suit.code === "D")); + }); + + test("every suit gets the same thirteen", () => { + CardPicker.SUITS.forEach(suit => { + CardPicker.suit = suit.code; + assert.deepEqual(CardPicker.gridCards().map(entry => entry.rank), CardPicker.RANKS); + }); + }); + + // The glyph is the whole of the control on the eye, and "♦, radio button" is not a + // suit out loud. + test("and a suit has a name to be called by", () => { + assert.deepEqual(CardPicker.SUITS.map(suit => suit.name), + ["Spades", "Hearts", "Diamonds", "Clubs"]); + assert.match(methodBody("paint()"), /aria-label", suit\.name/); + }); + + test("an unknown suit code falls back rather than emptying the grid", () => { + assert.equal(CardPicker.suitOf("Z").code, "S"); + assert.equal(CardPicker.suitOf("H").symbol, "♥"); + }); +}); + +describe("where the sheet opens", () => { + // The dealer came here to change that card. Hunting for where it lives is work the + // sheet can do. + test("on the suit of the card already in the slot, with it under the cursor", () => { + assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 0), { suit: "H", cursor: 7 }); + assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 1), { suit: "D", cursor: 12 }); + assert.deepEqual(aimedAt(["7H", "2D", "TS", "", ""], 2), { suit: "S", cursor: 4 }); + }); + + // Which is what makes a flush board, or two suited hole cards, one tap each after + // the first. + test("and an empty slot leaves the suit where the dealer put it", () => { + assert.deepEqual(aimedAt(["7H", "", "", "", ""], 1, { suit: "H", cursor: 7 }), + { suit: "H", cursor: 7 }); + }); + + test("junk in a slot is left alone rather than read as a suit", () => { + assert.deepEqual(aimedAt(["ZZ", "", "", "", ""], 0, { suit: "C", cursor: 3 }), + { suit: "C", cursor: 3 }); + assert.deepEqual(aimedAt(["", "", "", "", ""], 0, { suit: "C", cursor: 3 }), + { suit: "C", cursor: 3 }); + }); + + test("both ways in ask the question before they draw", () => { + ["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"] + .forEach(signature => { + const at = SRC.indexOf(signature); + assert.ok(at >= 0, `there is no \`${signature}\``); + assert.match(SRC.slice(at, SRC.indexOf("this.render();", at)), /this\.aimSuit\(\);/); + }); + }); +}); + +describe("changing suit is not moving", () => { + // The cursor is a rank. The same rank in another suit is the card beside the one you + // were on, so coming back to the ace would undo the only thing the dealer had said. + test("the rank under the cursor survives the change", () => { + const body = methodBody("setSuit(code, { focus = false } = {}) {"); + assert.ok(!/cursor/.test(body), "setSuit still has an opinion about where the cursor is"); + }); + + test("and asking for the suit already showing redraws nothing", () => { + const body = methodBody("setSuit(code, { focus = false } = {}) {"); + assert.match(body, /if \(this\.suit === code\) return;/); + }); + + // The APG's radio group: one Tab stop for the set, arrows to move within it, and + // moving is choosing. Wrapping is right here and wrong in the grid below — four + // suits are a ring with no ends, thirteen ranks have two. + test("the arrows run round the four suits", () => { + const body = methodBody("onSuitKey(event)"); + assert.match(body, /ArrowRight: 1.*ArrowDown: 1.*ArrowLeft: -1.*ArrowUp: -1/s); + assert.match(body, /% this\.SUITS\.length/); + assert.match(body, /event\.preventDefault\(\)/); + }); +}); + +describe("what the markup and the stylesheet have to agree about", () => { + // Not a tablist: the grid below is not another panel of content, it is the same + // thirteen controls filtered, and single-select filtering is what a radio group is. + test("the suits are a radio group with a name", () => { + const at = PAGE.indexOf('id="cardPickerSuits"'); + assert.ok(at >= 0, "there is no suit row"); + const tag = PAGE.slice(PAGE.lastIndexOf("<", at), PAGE.indexOf(">", at)); + assert.match(tag, /role="radiogroup"/); + assert.match(tag, /aria-label="Suit"/); + }); + + test("each suit says whether it is the chosen one, and only one is a Tab stop", () => { + const body = methodBody("paint()"); + assert.match(body, /setAttribute\("role", "radio"\)/); + assert.match(body, /aria-checked", chosen \? "true" : "false"/); + assert.match(body, /tabIndex = chosen \? 0 : -1/); + }); + + test("and the grid says which suit's thirteen these are", () => { + const at = PAGE.indexOf('id="cardPickerGrid"'); + assert.ok(at >= 0); + assert.match(PAGE.slice(at, PAGE.indexOf(">", at)), /aria-labelledby="cardPickerGridLabel"/); + assert.match(methodBody("paint()"), /gridLabel\.textContent = `\$\{this\.suitOf\(this\.suit\)\.name\}/); + }); + + // A row of four, in the same four places every time, so the one the dealer wants is + // somewhere their thumb already knows. + test("the suit row is four fixed columns, not as many as fit", () => { + assert.match(ruleFor("#cardPickerSuits"), /grid-template-columns:\s*repeat\(4, minmax\(0, 1fr\)\)/); + }); + + test("a suit is a target before it is a glyph", () => { + assert.match(ruleFor(".pickerSuitTab"), /min-height:\s*48px/); + }); + + // Drawing the three unchosen suits in one grey made a dealer reaching for the hearts + // read four shapes to find them, which is the search this sheet exists to remove. + test("red is red whether or not it is the one chosen", () => { + assert.match(ruleFor(".pickerSuitTab.cardRed"), /color:\s*#e07b7b/); + assert.match(ruleFor(".pickerSuitTab.chosen"), /background:\s*#fdfdfd/); + assert.match(ruleFor(".pickerSuitTab.cardRed.chosen"), /color:\s*#b32020/); + }); + + // Five across on a 360px phone, seven at 460, more on a phone held sideways. + test("the ranks take as many columns as the row holds", () => { + assert.match(ruleFor("#cardPickerGrid"), + /grid-template-columns:\s*repeat\(auto-fill, minmax\(3\.25rem, 1fr\)\)/); + }); + + // Which is why the arrows cannot assume a number any more. + test("so the arrows read the column count back off the layout", () => { + assert.match(methodBody("onGridKey(event)"), /const stride = this\.gridStride\(\)/); + assert.match(methodBody("gridStride()"), /getComputedStyle\(grid\)\.gridTemplateColumns/); + }); + + test("what was measured is written beside the code", () => { + const note = SRC.slice(0, SRC.indexOf("const CardPicker = {")); + assert.match(note, /646px/); + assert.match(note, /40 of the 52/); + assert.match(note, /572px/); + }); +}); diff --git a/tests/holdem/theKeyboardInASheet.test.js b/tests/holdem/theKeyboardInASheet.test.js index ca46083..844975b 100644 --- a/tests/holdem/theKeyboardInASheet.test.js +++ b/tests/holdem/theKeyboardInASheet.test.js @@ -258,12 +258,11 @@ describe("a redraw that does not drop the dealer", () => { }); }); -describe("fifty-two cards, one Tab stop", () => { - test("the deck is the grid's order, written once", () => { +describe("thirteen cards, one Tab stop", () => { + test("the deck is written once, ace down to two", () => { const deck = CardPicker.deck(); assert.equal(deck.length, 52); assert.equal(new Set(deck.map(c => c.card)).size, 52); - // Four suits across, thirteen ranks down — which is what the arrow keys step by. assert.deepEqual(deck.slice(0, 4).map(c => c.card), ["AS", "AH", "AD", "AC"]); assert.equal(deck[4].card, "KS"); assert.equal(deck[51].card, "2C"); @@ -285,7 +284,7 @@ describe("fifty-two cards, one Tab stop", () => { }; test("a step is one card, whether or not that card is already out", () => { - // 7C is dealt. Stepping down the clubs from the eight lands on it and says so, + // The seven is dealt. Stepping along from the eight lands on it and says so, // rather than quietly arriving at the six. withGrid(["8C", "7C!", "6C"], () => { assert.equal(CardPicker.nextCursor(0, 1), 1); @@ -294,28 +293,29 @@ describe("fifty-two cards, one Tab stop", () => { // The whole reason the skipping had to go. Hunting for the next card that could take // focus did the exact thing the no-wrap rule below was written to prevent: it moved - // the dealer sideways without being asked. Right from the ace of spades over a dealt - // ace of hearts landed on the ace of diamonds, so the column silently became the one - // to its right, and the next press of Down walked the king of diamonds. + // the dealer sideways without being asked. Right from the ace over a dealt king + // landed on the queen, so the column silently became the one to its right, and the + // next press of Down walked a rank nobody had asked for. test("and it never slides the dealer into a column they didn't ask for", () => { - withGrid(["AS", "AH!", "AD", "AC", "KS", "KH", "KD", "KC"], () => { + withGrid(["AS", "KS!", "QS", "JS", "TS", "9S", "8S", "7S"], () => { const afterRight = CardPicker.nextCursor(0, 1); - assert.equal(afterRight, 1, "a dealt ace of hearts is still the cell to the right of the ace of spades"); - assert.equal(CardPicker.nextCursor(afterRight, 4), 5, "and down from it is the king of the same suit"); + assert.equal(afterRight, 1, "a dealt king is still the cell to the right of the ace"); + assert.equal(CardPicker.nextCursor(afterRight, 4), 5, + "and down a row of four from it is the nine, four ranks along"); }); }); - // Wrapping would answer "move me down one rank" by moving the dealer to a different - // suit, which is not what they asked and not where they would look. + // Wrapping would answer "move me down a row" by moving the dealer to the other end + // of the grid, which is not what they asked and not where they would look. test("the edge of the grid is the edge, not a way round to the other side", () => { - withGrid(["AS", "AH", "AD", "AC"], () => { + withGrid(["AS", "KS", "QS", "JS"], () => { assert.equal(CardPicker.nextCursor(3, 1), 3); assert.equal(CardPicker.nextCursor(0, -1), 0); }); }); test("only one card is in the Tab order at a time", () => { - withGrid(["AS", "AH", "AD", "AC"], nodes => { + withGrid(["AS", "KS", "QS", "JS"], nodes => { CardPicker.aimCursor(2); assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, 0, -1]); }); @@ -325,7 +325,7 @@ describe("fifty-two cards, one Tab stop", () => { // become a dealt card. It stays there, because that cell is still a cell and its // neighbours are still where they were a moment ago. test("a cursor left on a dealt card stays on it", () => { - withGrid(["AS!", "AH", "AD", "AC"], nodes => { + withGrid(["AS!", "KS", "QS", "JS"], nodes => { CardPicker.aimCursor(0); assert.equal(CardPicker.cursor, 0); assert.deepEqual(nodes.map(n => n.tabIndex), [0, -1, -1, -1]); @@ -333,7 +333,7 @@ describe("fifty-two cards, one Tab stop", () => { }); test("and a grid with nothing left in it still has somewhere to put the cursor", () => { - withGrid(["2S!", "2H!", "2D!", "2C!"], nodes => { + withGrid(["5S!", "4S!", "3S!", "2S!"], nodes => { CardPicker.aimCursor(3); assert.equal(CardPicker.cursor, 3); assert.deepEqual(nodes.map(n => n.tabIndex), [-1, -1, -1, 0]); @@ -344,23 +344,39 @@ describe("fifty-two cards, one Tab stop", () => { // Home and End lean on: they ask for one card past either end and take what they // are given. test("Home and End ask past the ends and land on them", () => { - withGrid(["AS", "AH", "AD", "AC"], () => { + withGrid(["AS", "KS", "QS", "JS"], () => { assert.equal(CardPicker.nextCursor(-1, 1), 0); assert.equal(CardPicker.nextCursor(4, -1), 3); }); }); - test("the arrows step by a row of suits, not by a guess", () => { + // It used to step by `SUITS.length`, because the grid was four suits wide at every + // size. The grid is thirteen ranks of one suit now and takes as many columns as the + // width holds — five on a 360px phone, seven at 460 — so a constant would be wrong + // on most screens, which is the thing that slid the cursor into the wrong column + // the last time these two disagreed. It asks the layout instead. + test("the arrows step by the row the grid actually has, not by a guess", () => { const picker = src("cardPicker"); const at = picker.indexOf("onGridKey(event)"); assert.ok(at >= 0, "the grid has no arrow keys"); const body = picker.slice(at, picker.indexOf("\n },", at)); - assert.match(body, /const stride = this\.SUITS\.length/); + assert.match(body, /const stride = this\.gridStride\(\)/); assert.match(body, /ArrowRight: 1.*ArrowLeft: -1.*ArrowDown: stride.*ArrowUp: -stride/s); assert.match(body, /"Home"/); assert.match(body, /"End"/); }); + test("and the row it actually has is read off the grid", () => { + const picker = src("cardPicker"); + const at = picker.indexOf("gridStride() {"); + assert.ok(at >= 0, "there is no gridStride"); + const body = picker.slice(at, picker.indexOf("\n },", at)); + assert.match(body, /getComputedStyle\(grid\)\.gridTemplateColumns/); + // Outside a browser there is nothing to measure, and thirteen ranks in one row + // is the shape that makes Up and Down do nothing rather than something wrong. + assert.match(body, /return this\.RANKS\.length/); + }); + test("a fresh sheet starts at the top of the deck", () => { const picker = src("cardPicker"); ["open(state, hooks, seatIndex, cardIndex) {", "openBoard(state, hooks, cardIndex, limit = 5) {"]