Pick hole cards from a grid instead of typing them

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 deals the same
card to two players.

The two card fields become face-up card slots. Tapping one opens a 52-card grid
— four suit columns, thirteen rank rows, one tap per card — and picking the first
card moves straight on to the second, because a dealer picking hole cards is
always picking two. Cards already dealt elsewhere at the table are greyed out and
say who has them, so a duplicate is visibly out of play rather than silently
accepted.

Cards are still stored as the same two plain characters everything has always
read; the suit only becomes a symbol on its way to a human. That formatting is
now shared, so the table pucks, the seat editor, the pot wizard, the hand history
and the CSV all say A♥ K♦ rather than three of them saying AH KD.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-08-31 21:32:43 -06:00
co-authored by Claude Sonnet 5
parent 7bedee2934
commit 0759d89d13
6 changed files with 348 additions and 21 deletions
@@ -0,0 +1,131 @@
// Tap a card instead of typing it.
//
// 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.
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 },
],
state: null,
hooks: null,
seatIndex: null,
cardIndex: 0,
open(state, hooks, seatIndex, cardIndex) {
this.state = state;
this.hooks = hooks;
this.seatIndex = seatIndex;
this.cardIndex = cardIndex;
this.render();
document.getElementById("cardPickerOverlay").hidden = false;
},
close() {
document.getElementById("cardPickerOverlay").hidden = true;
},
// Every card sitting in front of anyone else. The slot being edited is excluded,
// so re-picking the card that's already there isn't treated as a duplicate.
cardsInUse() {
const used = new Map();
this.state.seats.forEach((player, seat) => {
(player.cards || []).forEach((card, slot) => {
if (!card) return;
if (seat === this.seatIndex && slot === this.cardIndex) return;
used.set(card.toUpperCase(), HoldemState.displayName(player, seat));
});
});
return used;
},
assign(card) {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = card;
this.hooks.onPick(this.seatIndex, this.cardIndex, card);
// Straight on to the second card — a dealer picking hole cards is always
// picking two, and making them re-open the picker doubles the taps.
if (this.cardIndex === 0 && !player.cards[1]) {
this.cardIndex = 1;
this.render();
return;
}
this.close();
},
clearSlot() {
const player = this.state.seats[this.seatIndex];
player.cards[this.cardIndex] = "";
this.hooks.onPick(this.seatIndex, this.cardIndex, "");
this.render();
},
render() {
const player = this.state.seats[this.seatIndex];
const used = this.cardsInUse();
document.getElementById("cardPickerTitle").textContent =
`${HoldemState.displayName(player, this.seatIndex)} — card ${this.cardIndex + 1}`;
// Which slot the next tap fills, switchable without closing the sheet.
const slots = document.getElementById("cardPickerSlots");
slots.innerHTML = "";
[0, 1].forEach(slot => {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerSlot";
btn.classList.toggle("active", slot === this.cardIndex);
const value = player.cards[slot] || "";
btn.textContent = value || "—";
btn.classList.toggle("cardRed", /[HD]$/i.test(value));
btn.addEventListener("click", () => {
this.cardIndex = slot;
this.render();
});
slots.appendChild(btn);
});
const grid = document.getElementById("cardPickerGrid");
grid.innerHTML = "";
this.RANKS.forEach(rank => {
this.SUITS.forEach(suit => {
const card = `${rank}${suit.code}`;
const takenBy = used.get(card);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "pickerCard";
btn.classList.toggle("cardRed", suit.red);
btn.disabled = !!takenBy;
if (takenBy) btn.title = `Already dealt to ${takenBy}`;
if ((player.cards[this.cardIndex] || "").toUpperCase() === card) {
btn.classList.add("selected");
}
const rankEl = document.createElement("span");
rankEl.className = "pickerRank";
rankEl.textContent = rank;
const suitEl = document.createElement("span");
suitEl.className = "pickerSuit";
suitEl.textContent = suit.symbol;
btn.appendChild(rankEl);
btn.appendChild(suitEl);
btn.addEventListener("click", () => this.assign(card));
grid.appendChild(btn);
});
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = CardPicker;
}
@@ -144,6 +144,12 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("cardPickerCloseBtn").addEventListener("click", () => CardPicker.close());
document.getElementById("cardPickerClearBtn").addEventListener("click", () => CardPicker.clearSlot());
document.getElementById("cardPickerOverlay").addEventListener("click", e => {
if (e.target.id === "cardPickerOverlay") CardPicker.close();
});
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close();
@@ -38,7 +38,7 @@ const PotWizard = {
labelFor(player, seatIndex) {
const name = HoldemState.displayName(player, seatIndex);
const cards = (player.cards || []).filter(Boolean).join(" ");
const cards = (player.cards || []).filter(Boolean).map(c => Render.formatCard(c)).join(" ");
return cards ? `${name} (${cards})` : name;
},
@@ -132,6 +132,25 @@ const Render = {
return /[HD]$/i.test(card || "");
},
SUIT_SYMBOLS: { S: "♠", H: "♥", D: "♦", C: "♣" },
// "KD" -> "K♦". Stored cards stay two plain characters so every existing reader
// keeps working; the suit only becomes a symbol on its way to a human.
formatCard(card) {
const value = (card || "").toUpperCase();
if (!value) return "";
return `${value[0]}${this.SUIT_SYMBOLS[value[1]] || value[1] || ""}`;
},
// An empty slot has to look like something you can tap, so it gets a placeholder
// rather than going blank.
paintCardSlot(slot, card) {
const value = (card || "").toUpperCase();
slot.classList.toggle("cardRed", this.isRedSuit(value));
slot.classList.toggle("cardSlotEmpty", !value);
slot.textContent = value ? this.formatCard(value) : "+";
},
// A chair nobody has claimed: no name typed, not dealt in, nothing in the pot.
// Worth calling out, because at a short-handed table the open seats are what
// the dealer is looking for.
@@ -154,7 +173,7 @@ const Render = {
if (i > 0) cardsEl.appendChild(document.createTextNode(" "));
const span = document.createElement("span");
if (this.isRedSuit(card)) span.className = "cardRed";
span.textContent = card;
span.textContent = this.formatCard(card);
cardsEl.appendChild(span);
});
// What's in front of them this street reads as the live number during betting;
@@ -288,17 +307,16 @@ const Render = {
this.commitSeatChange(form, player, index);
});
const card1 = form.querySelector(".card1Input");
const card2 = form.querySelector(".card2Input");
card1.value = player.cards[0] || "";
card2.value = player.cards[1] || "";
[card1, card2].forEach((input, cardIdx) => {
input.classList.toggle("cardRed", this.isRedSuit(input.value));
input.addEventListener("input", () => {
input.value = input.value.toUpperCase();
input.classList.toggle("cardRed", this.isRedSuit(input.value));
player.cards[cardIdx] = input.value;
this.commitSeatChange(form, player, index);
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
slots.forEach((slot, cardIdx) => {
this.paintCardSlot(slot, player.cards[cardIdx]);
slot.addEventListener("click", () => {
CardPicker.open(this.state, {
onPick: (seatIndex, pickedIdx, card) => {
this.paintCardSlot(slots[pickedIdx], card);
this.commitSeatChange(form, player, seatIndex);
},
}, index, cardIdx);
});
});