Merge pull request 'Fix Texas Hold'em dealer page: script timing crash and seat overlap' (#11) from feature/casino-texas-holdem into master
Build and Deploy / build-deploy (push) Successful in 1m8s

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-08-31 15:03:37 -06:00
4 changed files with 315 additions and 189 deletions
+42 -28
View File
@@ -34,6 +34,13 @@
</section> </section>
<aside id="holdemSidebar"> <aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<h2 id="seatEditorTitle">Select a seat</h2>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
</section>
<details class="sidebarPanel" open> <details class="sidebarPanel" open>
<summary>Table Setup</summary> <summary>Table Setup</summary>
<div class="panelBody"> <div class="panelBody">
@@ -81,35 +88,42 @@
<button class="dealerChip" title="Make this seat the dealer button">D</button> <button class="dealerChip" title="Make this seat the dealer button">D</button>
<span class="blindBadge sbBadge">SB</span> <span class="blindBadge sbBadge">SB</span>
<span class="blindBadge bbBadge">BB</span> <span class="blindBadge bbBadge">BB</span>
<div class="seatCard"> <div class="seatPuckName"></div>
<div class="seatCardTop"> <div class="seatPuckCards"></div>
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" /> <div class="seatPuckTotal"></div>
</div> </div>
<div class="seatCardToggles"> </template>
<label class="inHandToggle" title="This seat is playing the current hand">
<input type="checkbox" class="inHandCheckbox" /> <template id="seatEditorTemplate">
<span>In hand</span> <div class="seatEditorForm">
</label> <label class="fieldRow">
<label class="foldToggle" title="Folded"> <span>Name</span>
<input type="checkbox" class="foldCheckbox" /> <input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
<span>Fold</span> </label>
</label> <div class="seatCardToggles">
</div> <label class="inHandToggle" title="This seat is playing the current hand">
<div class="seatCards"> <input type="checkbox" class="inHandCheckbox" />
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" /> <span>In hand</span>
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" /> </label>
</div> <label class="foldToggle" title="Folded">
<div class="seatContribution"> <input type="checkbox" class="foldCheckbox" />
<div class="chipCounts"></div> <span>Fold</span>
<label class="fieldRow amountOverrideRow"> </label>
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div> </div>
<div class="seatCards">
<input type="text" class="cardInput card1Input" maxlength="2" placeholder="__" />
<input type="text" class="cardInput card2Input" maxlength="2" placeholder="__" />
</div>
<div class="seatContribution">
<div class="chipCounts"></div>
<label class="fieldRow amountOverrideRow">
<span>$ amount</span>
<input type="number" class="amountOverrideInput" min="0" step="1" placeholder="auto" />
</label>
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
</div>
<div class="customFields"></div>
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
</div> </div>
</template> </template>
+83 -38
View File
@@ -117,7 +117,7 @@ body {
#pokerTableWrap { #pokerTableWrap {
position: relative; position: relative;
width: 100%; width: 100%;
padding-bottom: 62%; padding-bottom: 72%;
} }
#pokerTable { #pokerTable {
@@ -161,19 +161,21 @@ body {
inset: 0; inset: 0;
} }
/* ---------- Seats ---------- */ /* ---------- Seats (compact pucks on the table) ---------- */
.seat { .seat {
position: absolute; position: absolute;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
width: 190px; width: 96px;
min-height: 54px;
background: #232323; background: #232323;
border: 1px solid #3a3a3a; border: 2px solid #3a3a3a;
border-radius: 10px; border-radius: 10px;
padding: 0.6rem; padding: 0.3rem 0.4rem;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
cursor: grab; cursor: grab;
z-index: 1; z-index: 1;
text-align: center;
} }
.seat.dragging { .seat.dragging {
@@ -189,30 +191,60 @@ body {
opacity: 0.45; opacity: 0.45;
} }
.seat.seatFolded .seatCard { .seat.seatFolded {
opacity: 0.55; border-style: dashed;
opacity: 0.6;
}
.seat.seatSelected {
border-color: #09ff00;
box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.35), 0 4px 10px rgba(0, 0, 0, 0.4);
} }
.seatChairNumber { .seatChairNumber {
position: absolute;
top: 2px;
left: 5px;
font-size: 0.6rem;
color: #777;
}
.seatPuckName {
font-size: 0.72rem;
font-weight: 600;
color: #eee;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 0.6rem;
}
.seatPuckCards {
font-size: 0.65rem; font-size: 0.65rem;
color: #888; font-family: var(--font-mono, monospace);
text-transform: uppercase; color: #bbb;
letter-spacing: 0.05em; min-height: 0.9em;
margin-bottom: 0.3rem; }
.seatPuckTotal {
font-size: 0.7rem;
font-weight: 700;
color: #9be6a8;
min-height: 1em;
} }
.dealerChip { .dealerChip {
position: absolute; position: absolute;
top: -10px; top: -10px;
right: -10px; right: -10px;
width: 26px; width: 22px;
height: 26px; height: 22px;
border-radius: 50%; border-radius: 50%;
background: #444; background: #444;
color: #ccc; color: #ccc;
border: 2px solid #666; border: 2px solid #666;
font-weight: 700; font-weight: 700;
font-size: 0.75rem; font-size: 0.68rem;
cursor: pointer; cursor: pointer;
z-index: 2; z-index: 2;
} }
@@ -226,10 +258,10 @@ body {
.blindBadge { .blindBadge {
display: none; display: none;
position: absolute; position: absolute;
top: -10px; top: -9px;
font-size: 0.65rem; font-size: 0.58rem;
font-weight: 700; font-weight: 700;
padding: 0.1rem 0.4rem; padding: 0.05rem 0.3rem;
border-radius: 8px; border-radius: 8px;
z-index: 2; z-index: 2;
} }
@@ -245,15 +277,43 @@ body {
} }
.bbBadge { .bbBadge {
left: 26px; left: 22px;
background: #b7791f; background: #b7791f;
color: #fff7e6; color: #fff7e6;
} }
.seatCardTop { /* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
padding: 0.9rem;
}
.seatEditorPanel h2 {
margin: 0 0 0.6rem;
font-size: 0.95rem;
font-weight: 500;
color: #f5f5f5;
}
.seatEditorForm {
display: flex; display: flex;
gap: 0.4rem; flex-direction: column;
margin-bottom: 0.35rem; gap: 0.6rem;
}
.seatCardToggles {
display: flex;
gap: 0.6rem;
font-size: 0.8rem;
color: #aaa;
}
.inHandToggle,
.foldToggle {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
} }
.seatNameInput { .seatNameInput {
@@ -267,22 +327,6 @@ body {
font-size: 0.8rem; font-size: 0.8rem;
} }
.seatCardToggles {
display: flex;
gap: 0.6rem;
margin-bottom: 0.4rem;
font-size: 0.7rem;
color: #aaa;
}
.inHandToggle,
.foldToggle {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
}
.seatCards { .seatCards {
display: flex; display: flex;
gap: 0.35rem; gap: 0.35rem;
@@ -598,6 +642,7 @@ body {
} }
.seat { .seat {
width: 150px; width: 78px;
min-height: 44px;
} }
} }
@@ -1,4 +1,4 @@
(function () { document.addEventListener("DOMContentLoaded", () => {
const state = HoldemState.load(); const state = HoldemState.load();
const hooks = { const hooks = {
@@ -67,4 +67,4 @@
document.getElementById("potWizardOverlay").addEventListener("click", e => { document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close(); if (e.target.id === "potWizardOverlay") PotWizard.close();
}); });
})(); });
+188 -121
View File
@@ -1,19 +1,30 @@
// All DOM rendering + drag/drop for the table. Talks to the outside world only through // All DOM rendering + drag/drop for the table. Talks to the outside world only through
// the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender() // the `hooks` object passed to mount(): hooks.save(state) persists, hooks.rerender()
// re-draws everything (used after structural changes like resizing seats or the chip // re-draws everything (used after structural changes like resizing seats or the chip
// legend), hooks.rerenderPotBadge(state) just updates the pot total display. // legend).
//
// The table itself only shows compact seat "pucks" — name, cards, total, blind/dealer
// badges — because a full data-entry form (chip counts, custom fields, etc.) doesn't fit
// next to nine others arranged in a circle at any screen size. Clicking a puck opens the
// full form in the seat editor panel instead.
const Render = { const Render = {
state: null, state: null,
hooks: null, hooks: null,
selectedSeatIndex: null,
mount(state, hooks) { mount(state, hooks) {
this.state = state; this.state = state;
this.hooks = hooks; this.hooks = hooks;
this.selectedSeatIndex = null;
this.renderAll(); this.renderAll();
}, },
renderAll() { renderAll() {
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
this.selectedSeatIndex = null;
}
this.renderSeats(); this.renderSeats();
this.renderSeatEditor();
this.renderChipLegend(); this.renderChipLegend();
this.renderNotes(); this.renderNotes();
this.updatePotBadge(); this.updatePotBadge();
@@ -36,29 +47,22 @@ const Render = {
document.getElementById("potBadgeAmount").textContent = Money.format(total); document.getElementById("potBadgeAmount").textContent = Money.format(total);
}, },
commitAndRedrawSeat(seatEl, seatIndex) { // ---- Seats around the table (compact pucks) ----
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(seatEl, this.state.seats[seatIndex]);
},
renderSeatTotal(seatEl, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
seatEl.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
// ---- Seats around the table ----
seatPosition(index, count) { seatPosition(index, count) {
const angle = -90 + (360 / count) * index; const angle = -90 + (360 / count) * index;
const radians = (angle * Math.PI) / 180; const radians = (angle * Math.PI) / 180;
const rx = 44; const rx = 44;
const ry = 40; const ry = 42;
const left = 50 + rx * Math.cos(radians); const left = 50 + rx * Math.cos(radians);
const top = 50 + ry * Math.sin(radians); const top = 50 + ry * Math.sin(radians);
return { left, top }; return { left, top };
}, },
puckLabel(player, seatIndex) {
return player.name && player.name.trim() ? player.name.trim() : `Seat ${seatIndex + 1}`;
},
renderSeats() { renderSeats() {
const layer = document.getElementById("seatsLayer"); const layer = document.getElementById("seatsLayer");
layer.innerHTML = ""; layer.innerHTML = "";
@@ -72,11 +76,12 @@ const Render = {
node.style.top = `${pos.top}%`; node.style.top = `${pos.top}%`;
node.dataset.seatIndex = String(index); node.dataset.seatIndex = String(index);
node.querySelector(".seatChairNumber").textContent = `Seat ${index + 1}`; node.querySelector(".seatChairNumber").textContent = index + 1;
const dealerChip = node.querySelector(".dealerChip"); const dealerChip = node.querySelector(".dealerChip");
dealerChip.classList.toggle("active", index === dealerIndex); dealerChip.classList.toggle("active", index === dealerIndex);
dealerChip.addEventListener("click", () => { dealerChip.addEventListener("click", e => {
e.stopPropagation();
this.state.dealerSeatIndex = index; this.state.dealerSeatIndex = index;
this.hooks.save(this.state); this.hooks.save(this.state);
this.renderSeats(); this.renderSeats();
@@ -85,12 +90,14 @@ const Render = {
node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex); node.querySelector(".sbBadge").classList.toggle("visible", index === sbIndex);
node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex); node.querySelector(".bbBadge").classList.toggle("visible", index === bbIndex);
this.wireSeatFields(node, player, index); this.updatePuckContent(node, player, index);
this.renderChipCounts(node, player); node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
this.renderCustomFields(node, player);
this.renderSeatTotal(node, player); node.addEventListener("click", () => {
node.classList.toggle("seatInactive", !player.inHand); this.selectedSeatIndex = index;
node.classList.toggle("seatFolded", player.folded); this.renderSeats();
this.renderSeatEditor();
});
this.wireDragAndDrop(node, index); this.wireDragAndDrop(node, index);
@@ -98,106 +105,21 @@ const Render = {
}); });
}, },
wireSeatFields(node, player, index) { updatePuckContent(node, player, index) {
const nameInput = node.querySelector(".seatNameInput"); node.querySelector(".seatPuckName").textContent = player.name && player.name.trim() ? player.name.trim() : "";
nameInput.value = player.name; const cards = (player.cards || []).filter(Boolean).join(" ");
nameInput.addEventListener("input", () => { node.querySelector(".seatPuckCards").textContent = cards;
player.name = nameInput.value; const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
this.hooks.save(this.state); node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
}); node.classList.toggle("seatInactive", !player.inHand);
node.classList.toggle("seatFolded", player.folded);
const inHandCheckbox = node.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
this.renderSeats();
this.hooks.save(this.state);
this.updatePotBadge();
});
const foldCheckbox = node.querySelector(".foldCheckbox");
foldCheckbox.checked = player.folded;
foldCheckbox.disabled = !player.inHand;
foldCheckbox.addEventListener("change", () => {
player.folded = foldCheckbox.checked;
node.classList.toggle("seatFolded", player.folded);
this.hooks.save(this.state);
});
const card1 = node.querySelector(".card1Input");
const card2 = node.querySelector(".card2Input");
card1.value = player.cards[0] || "";
card2.value = player.cards[1] || "";
[card1, card2].forEach((input, cardIdx) => {
input.addEventListener("input", () => {
input.value = input.value.toUpperCase();
player.cards[cardIdx] = input.value;
this.hooks.save(this.state);
});
});
const amountInput = node.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitAndRedrawSeat(node, index);
});
const addFieldBtn = node.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(node, player);
this.hooks.save(this.state);
});
}, },
renderChipCounts(node, player) { // Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
const container = node.querySelector(".chipCounts"); // (which live in a separate panel) never lose focus.
container.innerHTML = ""; refreshPuck(index) {
const template = document.getElementById("chipCountTemplate"); const node = document.querySelector(`.seat[data-seat-index="${index}"]`);
if (node) this.updatePuckContent(node, this.state.seats[index], index);
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
row.title = `$${chip.value} chips`;
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitAndRedrawSeat(node, Number(node.dataset.seatIndex));
});
container.appendChild(row);
});
},
renderCustomFields(node, player) {
const container = node.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(node, player);
this.hooks.save(this.state);
});
container.appendChild(row);
});
}, },
wireDragAndDrop(node, index) { wireDragAndDrop(node, index) {
@@ -222,6 +144,151 @@ const Render = {
[seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]]; [seats[sourceIndex], seats[targetIndex]] = [seats[targetIndex], seats[sourceIndex]];
this.hooks.save(this.state); this.hooks.save(this.state);
this.renderSeats(); this.renderSeats();
if (this.selectedSeatIndex === sourceIndex || this.selectedSeatIndex === targetIndex) {
this.renderSeatEditor();
}
});
},
// ---- Seat editor panel (the full form for whichever seat is selected) ----
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
if (this.selectedSeatIndex === null) {
title.textContent = "Select a seat";
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
return;
}
const index = this.selectedSeatIndex;
const player = this.state.seats[index];
title.textContent = this.puckLabel(player, index);
body.innerHTML = "";
const template = document.getElementById("seatEditorTemplate");
const form = template.content.firstElementChild.cloneNode(true);
this.wireSeatFields(form, player, index);
this.renderChipCounts(form, player, index);
this.renderCustomFields(form, player, index);
this.renderSeatTotal(form, player);
body.appendChild(form);
},
renderSeatTotal(form, player) {
const total = HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
form.querySelector(".seatTotalAmount").textContent = Money.format(total);
},
commitSeatChange(form, player, index) {
this.hooks.save(this.state);
this.updatePotBadge();
this.renderSeatTotal(form, player);
this.refreshPuck(index);
},
wireSeatFields(form, player, index) {
const nameInput = form.querySelector(".seatNameInput");
nameInput.value = player.name;
nameInput.addEventListener("input", () => {
player.name = nameInput.value;
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
this.commitSeatChange(form, player, index);
});
const inHandCheckbox = form.querySelector(".inHandCheckbox");
inHandCheckbox.checked = player.inHand;
inHandCheckbox.addEventListener("change", () => {
player.inHand = inHandCheckbox.checked;
if (!player.inHand) player.folded = false;
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
this.commitSeatChange(form, player, index);
});
const foldCheckboxRef = form.querySelector(".foldCheckbox");
foldCheckboxRef.checked = player.folded;
foldCheckboxRef.disabled = !player.inHand;
foldCheckboxRef.addEventListener("change", () => {
player.folded = foldCheckboxRef.checked;
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.addEventListener("input", () => {
input.value = input.value.toUpperCase();
player.cards[cardIdx] = input.value;
this.commitSeatChange(form, player, index);
});
});
const amountInput = form.querySelector(".amountOverrideInput");
amountInput.value = player.amountOverride ?? "";
amountInput.addEventListener("input", () => {
player.amountOverride = amountInput.value === "" ? null : amountInput.value;
this.commitSeatChange(form, player, index);
});
const addFieldBtn = form.querySelector(".addFieldBtn");
addFieldBtn.addEventListener("click", () => {
player.customFields.push({ label: "", value: "" });
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
},
renderChipCounts(form, player, index) {
const container = form.querySelector(".chipCounts");
container.innerHTML = "";
const template = document.getElementById("chipCountTemplate");
this.state.chipLegend.forEach(chip => {
const row = template.content.firstElementChild.cloneNode(true);
row.querySelector(".chipSwatch").style.background = chip.color;
row.title = `$${chip.value} chips`;
const input = row.querySelector(".chipCountInput");
input.value = player.chipCounts[chip.id] || 0;
input.addEventListener("input", () => {
const count = Math.max(0, Math.floor(Number(input.value)) || 0);
player.chipCounts[chip.id] = count;
this.commitSeatChange(form, player, index);
});
container.appendChild(row);
});
},
renderCustomFields(form, player, index) {
const container = form.querySelector(".customFields");
container.innerHTML = "";
const template = document.getElementById("customFieldTemplate");
player.customFields.forEach((field, fieldIdx) => {
const row = template.content.firstElementChild.cloneNode(true);
const labelInput = row.querySelector(".customFieldLabel");
const valueInput = row.querySelector(".customFieldValue");
labelInput.value = field.label;
valueInput.value = field.value;
labelInput.addEventListener("input", () => {
field.label = labelInput.value;
this.hooks.save(this.state);
});
valueInput.addEventListener("input", () => {
field.value = valueInput.value;
this.hooks.save(this.state);
});
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
player.customFields.splice(fieldIdx, 1);
this.renderCustomFields(form, player, index);
this.hooks.save(this.state);
});
container.appendChild(row);
}); });
}, },
@@ -242,12 +309,12 @@ const Render = {
colorInput.addEventListener("input", () => { colorInput.addEventListener("input", () => {
chip.color = colorInput.value; chip.color = colorInput.value;
this.hooks.save(this.state); this.hooks.save(this.state);
this.renderSeats();
}); });
valueInput.addEventListener("input", () => { valueInput.addEventListener("input", () => {
chip.value = Number(valueInput.value) || 0; chip.value = Number(valueInput.value) || 0;
this.hooks.save(this.state); this.hooks.save(this.state);
this.renderSeats(); this.renderSeats();
this.renderSeatEditor();
this.updatePotBadge(); this.updatePotBadge();
}); });
row.querySelector(".removeChipColorBtn").addEventListener("click", () => { row.querySelector(".removeChipColorBtn").addEventListener("click", () => {