Pick hole cards from a grid instead of typing them #22

Merged
jheaps merged 1 commits from feature/casino-holdem-card-picker into feature/casino-holdem-live-betting 2026-08-31 23:34:00 -06:00
6 changed files with 348 additions and 21 deletions
+16 -2
View File
@@ -167,8 +167,8 @@
</label>
</div>
<div class="seatCards">
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
</div>
<label class="fieldRow stackRow">
<span>Stack</span>
@@ -230,6 +230,19 @@
</div>
</template>
<div id="cardPickerOverlay" hidden>
<div id="cardPickerSheet" role="dialog" aria-labelledby="cardPickerTitle">
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="cardPickerHeader">
<h2 id="cardPickerTitle"></h2>
<button id="cardPickerClearBtn" class="holdemBtn holdemBtnSmall" type="button">Clear</button>
<button id="cardPickerCloseBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
</div>
<div id="cardPickerSlots"></div>
<div id="cardPickerGrid"></div>
</div>
</div>
<div id="actionBar" aria-live="polite" hidden></div>
<div id="toastHost" aria-live="polite"></div>
@@ -265,6 +278,7 @@
<script src="~/js/casino/holdem/potCalculator.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/state.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/render.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/cardPicker.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/betting.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/actionBar.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
+164 -6
View File
@@ -481,24 +481,175 @@ body {
margin-bottom: 0.4rem;
}
.cardInput {
/* The two hole-card slots: they look like cards face-up on the felt, and tapping
one opens the picker rather than a keyboard. */
.cardSlot {
width: 100%;
min-height: 52px;
text-align: center;
text-transform: uppercase;
background: #fdfdfd;
color: #111;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 0.35rem 0;
font-size: 1.25rem;
font-weight: 700;
font-family: var(--font-mono, monospace);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.cardInput.cardRed {
.cardSlot:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
}
.cardSlot.cardRed {
color: #c0392b;
}
.cardSlot.cardSlotEmpty {
background: #191919;
color: #5c5c5c;
border-style: dashed;
border-color: #3f3f3f;
box-shadow: none;
font-weight: 400;
}
/* ---------- Card picker ---------- */
#cardPickerOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 95;
}
#cardPickerOverlay[hidden] {
display: none;
}
#cardPickerSheet {
width: 100%;
max-width: 460px;
max-height: 88vh;
overflow-y: auto;
background: linear-gradient(180deg, #242424, #1a1a1a);
border: 1px solid #3a3a3a;
border-bottom: none;
border-radius: 16px 16px 0 0;
padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
}
.cardPickerHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.6rem;
}
.cardPickerHeader h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: 0.95rem;
font-weight: 500;
color: #f5f5f5;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardPickerHeader .holdemBtn {
min-height: 40px;
}
#cardPickerSlots {
display: flex;
gap: 0.5rem;
margin-bottom: 0.7rem;
}
.pickerSlot {
flex: 1;
min-height: 46px;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: #191919;
color: #8a8a8a;
font-family: var(--font-mono, monospace);
font-size: 1.1rem;
font-weight: 700;
cursor: pointer;
}
/* The slot the next tap will fill. */
.pickerSlot.active {
border-color: #d8b46a;
background: #241d0f;
color: #f0d9a8;
}
.pickerSlot.cardRed {
color: #e07b7b;
}
#cardPickerGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.35rem;
}
.pickerCard {
display: flex;
align-items: center;
justify-content: center;
gap: 0.1rem;
min-height: 44px;
border-radius: 7px;
border: 1px solid #d5d5d5;
background: #fdfdfd;
color: #111;
font-family: var(--font-mono, monospace);
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: transform 0.1s ease;
}
.pickerCard:hover:not(:disabled) {
transform: translateY(-1px);
}
.pickerCard.cardRed {
color: #c0392b;
}
.pickerCard.selected {
outline: 3px solid #d8b46a;
outline-offset: -1px;
}
/* Already dealt somewhere else at the table — visibly out of play rather than
silently accepting a duplicate. */
.pickerCard:disabled {
background: #1d1d1d;
border-color: #303030;
color: #4a4a4a;
cursor: not-allowed;
}
.pickerSuit {
font-size: 1.05em;
line-height: 1;
}
.seatContribution {
border-top: 1px dashed #3a3a3a;
padding-top: 0.4rem;
@@ -1420,9 +1571,16 @@ body.hasActionBar #toastHost {
width: 110px;
}
.seatEditorForm .cardInput {
font-size: 1.35rem;
min-height: 58px;
.seatEditorForm .cardSlot {
font-size: 1.5rem;
min-height: 62px;
}
/* Thirteen rows of cards need to stay reachable without the sheet running off
the bottom of a phone. */
.pickerCard {
min-height: 46px;
font-size: 1.05rem;
}
.seatEditorForm .seatCardToggles {
@@ -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);
});
});