Merge pull request 'Make the dealer table usable on a phone' (#16) from feature/casino-holdem-mobile-table into feature/casino-holdem-quick-split

Reviewed-on: #16
This commit was merged in pull request #16.
This commit is contained in:
2026-08-31 18:31:52 -06:00
5 changed files with 561 additions and 14 deletions
+19 -1
View File
@@ -38,7 +38,11 @@
<aside id="holdemSidebar">
<section id="seatEditorPanel" class="sidebarPanel seatEditorPanel">
<h2 id="seatEditorTitle">Select a seat</h2>
<div class="seatSheetGrip" aria-hidden="true"></div>
<div class="seatEditorHeader">
<h2 id="seatEditorTitle">Select a seat</h2>
<button id="closeSeatEditorBtn" class="seatSheetClose" aria-label="Done editing this seat">Done</button>
</div>
<div id="seatEditorBody">
<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>
</div>
@@ -206,6 +210,19 @@
</div>
</template>
<div id="toastHost" aria-live="polite"></div>
<div id="confirmOverlay" hidden>
<div id="confirmModal" role="alertdialog" aria-labelledby="confirmTitle" aria-describedby="confirmMessage" tabindex="-1">
<h2 id="confirmTitle"></h2>
<p id="confirmMessage"></p>
<div class="confirmActions">
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
<button id="confirmAcceptBtn" class="holdemBtn holdemBtnDanger" type="button">Confirm</button>
</div>
</div>
</div>
<div id="potWizardOverlay" hidden>
<div id="potWizardModal">
<div class="potWizardHeader">
@@ -222,6 +239,7 @@
@section Scripts {
<script src="~/js/casino/holdem/money.js?v=@ViewData["cssVersion"]"></script>
<script src="~/js/casino/holdem/feedback.js?v=@ViewData["cssVersion"]"></script>
<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>
+353
View File
@@ -376,6 +376,27 @@ body {
padding: 0.9rem;
}
.seatEditorHeader {
display: flex;
align-items: center;
gap: 0.6rem;
}
.seatEditorHeader h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Grip and Done button are the sheet's furniture — no reason for either on a
desktop sidebar, where the panel is just a panel. */
.seatSheetGrip,
.seatSheetClose {
display: none;
}
.seatEditorPanel h2 {
margin: 0 0 0.6rem;
padding-bottom: 0.5rem;
@@ -732,6 +753,135 @@ body {
/* ---------- Pot wizard ---------- */
/* ---------- Toasts ---------- */
#toastHost {
position: fixed;
left: 50%;
bottom: 1.25rem;
transform: translateX(-50%);
z-index: 80;
width: min(560px, calc(100vw - 1.5rem));
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: 0.75rem;
background: linear-gradient(180deg, #2b2b2b, #202020);
border: 1px solid #414141;
border-left: 3px solid #d8b46a;
border-radius: 10px;
padding: 0.7rem 0.8rem 0.7rem 0.9rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55);
pointer-events: auto;
opacity: 0;
transform: translateY(12px);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast.toastVisible {
opacity: 1;
transform: translateY(0);
}
.toastText {
flex: 1;
min-width: 0;
color: #e9e9e9;
font-size: 0.85rem;
line-height: 1.35;
}
.toastAction {
flex: none;
min-height: 38px;
padding: 0.35rem 0.9rem;
border-radius: 8px;
border: 1px solid #d8b46a;
background: rgba(216, 180, 106, 0.12);
color: #f0d9a8;
font-size: 0.85rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
.toastAction:hover {
background: rgba(216, 180, 106, 0.22);
}
.toastClose {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: transparent;
color: #888;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
.toastClose:hover {
background: #333;
color: #ddd;
}
/* ---------- Confirmation dialog ---------- */
#confirmOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
padding: 1.5rem;
}
#confirmOverlay[hidden] {
display: none;
}
#confirmModal {
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 12px;
width: 100%;
max-width: 420px;
padding: 1.3rem 1.4rem 1.2rem;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
outline: none;
}
#confirmTitle {
margin: 0 0 0.5rem;
font-size: 1.05rem;
font-weight: 500;
color: #f5f5f5;
}
#confirmMessage {
margin: 0 0 1.2rem;
color: #a5a5a5;
font-size: 0.85rem;
line-height: 1.5;
}
.confirmActions {
display: flex;
justify-content: flex-end;
gap: 0.6rem;
}
.confirmActions .holdemBtn {
min-height: 44px;
}
#potWizardOverlay {
position: fixed;
inset: 0;
@@ -843,3 +993,206 @@ body {
min-height: 44px;
}
}
/* ---------- Phones ----------
Two things break on a narrow screen: seat pucks ring the table on a radius
measured from the wrap, so the left- and right-most ones hang off the edge of
the viewport; and the seat editor lives far enough down the page that tapping a
seat looks like it did nothing. Inset the ring by half a puck, and float the
editor up as a bottom sheet over the table. */
@media (max-width: 700px) {
#holdemApp {
padding: 1rem 0.75rem 2rem;
}
#holdemHeader {
margin-bottom: 1rem;
gap: 0.6rem;
}
#holdemHeader h1 {
font-size: 1.4rem;
}
.holdemSubtitle {
font-size: 0.8rem;
}
#holdemHeaderActions {
width: 100%;
gap: 0.45rem;
}
#holdemHeaderActions .holdemBtn {
flex: 1 1 auto;
justify-content: center;
min-height: 46px;
padding: 0.5rem 0.7rem;
font-size: 0.82rem;
}
/* Keeps every puck inside the viewport: the ring is drawn within this box,
so half a puck of padding is exactly enough to stop the overhang. */
#seatsLayer {
inset: 26px 44px;
}
/* Narrower than the desktop puck: at a full ten-handed table the ring is
flattest at the top and bottom, and wide pucks are what made neighbours
overlap there. The puck is only a summary — the real form is the sheet. */
.seat {
width: 64px;
min-height: 42px;
padding: 0.25rem 0.3rem;
}
#tableWatermark {
font-size: 1.4rem;
}
#potBadge {
padding: 0.5rem 1.1rem;
}
#potBadgeAmount {
font-size: 1.2rem;
}
.tableHint {
margin-top: 0.75rem;
font-size: 0.75rem;
}
/* ---- Seat editor as a bottom sheet ---- */
#seatEditorPanel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
margin: 0;
max-height: 74vh;
overflow-y: auto;
border-radius: 16px 16px 0 0;
border-bottom: none;
padding: 0.35rem 1rem 1.25rem;
background: linear-gradient(180deg, #242424, #1a1a1a);
box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65);
transform: translateY(105%);
transition: transform 0.25s ease;
-webkit-overflow-scrolling: touch;
}
body.seatSheetOpen #seatEditorPanel {
transform: translateY(0);
}
.seatSheetGrip {
display: block;
width: 38px;
height: 4px;
margin: 0.35rem auto 0.5rem;
border-radius: 999px;
background: #4a4a4a;
}
.seatSheetClose {
display: inline-flex;
align-items: center;
flex: none;
min-height: 44px;
padding: 0.35rem 1.1rem;
border-radius: 8px;
border: 1px solid #3a3a3a;
background: linear-gradient(180deg, #2e2e2e, #232323);
color: #ddd;
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
}
/* The sheet covers the bottom of the page while it's open — give the sidebar
room so nothing is stranded underneath it. */
body.seatSheetOpen #holdemSidebar {
padding-bottom: 40vh;
}
/* The seat form was sized for a 320px desktop sidebar. In the sheet it has a
whole phone width to work with, so everything grows to thumb size — and
every field goes to 16px, below which iOS zooms the page on focus and the
dealer has to pinch back out mid-hand. */
.seatEditorForm .seatNameInput,
.seatEditorForm .amountOverrideInput,
.seatEditorForm .chipCountInput,
.seatEditorForm .customFieldLabel,
.seatEditorForm .customFieldValue {
font-size: 16px;
min-height: 44px;
padding: 0.4rem 0.6rem;
}
.seatEditorForm .chipCountInput {
width: 56px;
text-align: center;
}
.seatEditorForm .chipCounts {
gap: 0.6rem;
}
.seatEditorForm .chipSwatch {
width: 16px;
height: 16px;
}
.seatEditorForm .amountOverrideInput {
width: 110px;
}
.seatEditorForm .cardInput {
font-size: 1.35rem;
min-height: 58px;
}
.seatEditorForm .seatCardToggles {
gap: 1rem;
}
.seatEditorForm .inHandToggle,
.seatEditorForm .foldToggle {
min-height: 44px;
font-size: 0.95rem;
}
.seatEditorForm .inHandToggle input,
.seatEditorForm .foldToggle input {
width: 22px;
height: 22px;
}
.seatEditorForm .amountOverrideRow,
.seatEditorForm .seatTotal {
font-size: 0.95rem;
}
.seatEditorForm .addFieldBtn {
width: 100%;
justify-content: center;
min-height: 44px;
margin-top: 0.4rem;
}
.toastAction {
min-height: 44px;
}
.confirmActions {
flex-direction: column-reverse;
}
.confirmActions .holdemBtn {
width: 100%;
justify-content: center;
}
}
@@ -0,0 +1,115 @@
// Toasts and confirmation dialogs, so the table never has to fall back on the
// browser's own alert boxes. A system confirm() steals focus, looks nothing like
// the rest of the page, and on a phone it's a full-screen interruption — all of
// which is a bad trade for a dealer trying to keep a game moving.
// A short-lived message at the bottom of the screen, optionally with one action
// (in practice: Undo). Only one is ever on screen; a second replaces the first.
const Toast = {
DEFAULT_DURATION_MS: 8000,
timer: null,
show(message, options = {}) {
const host = document.getElementById("toastHost");
if (!host) return;
this.dismiss();
const toast = document.createElement("div");
toast.className = "toast";
toast.setAttribute("role", "status");
const text = document.createElement("span");
text.className = "toastText";
text.textContent = message;
toast.appendChild(text);
if (options.actionLabel && options.onAction) {
const action = document.createElement("button");
action.type = "button";
action.className = "toastAction";
action.textContent = options.actionLabel;
action.addEventListener("click", () => {
this.dismiss();
options.onAction();
});
toast.appendChild(action);
}
const close = document.createElement("button");
close.type = "button";
close.className = "toastClose";
close.setAttribute("aria-label", "Dismiss");
close.textContent = "✕";
close.addEventListener("click", () => this.dismiss());
toast.appendChild(close);
host.appendChild(toast);
// Next frame, so the entrance transition actually runs.
requestAnimationFrame(() => toast.classList.add("toastVisible"));
this.timer = setTimeout(
() => this.dismiss(),
options.duration || this.DEFAULT_DURATION_MS
);
},
dismiss() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const host = document.getElementById("toastHost");
if (host) host.innerHTML = "";
},
};
// Styled stand-in for confirm(). Resolves true if the dealer goes through with it.
const Dialog = {
confirm({ title, message, confirmLabel = "Confirm", cancelLabel = "Cancel", danger = false }) {
return new Promise(resolve => {
const overlay = document.getElementById("confirmOverlay");
const modal = overlay.querySelector("#confirmModal");
overlay.querySelector("#confirmTitle").textContent = title;
overlay.querySelector("#confirmMessage").textContent = message;
const confirmBtn = overlay.querySelector("#confirmAcceptBtn");
const cancelBtn = overlay.querySelector("#confirmCancelBtn");
confirmBtn.textContent = confirmLabel;
cancelBtn.textContent = cancelLabel;
confirmBtn.classList.toggle("holdemBtnDanger", danger);
confirmBtn.classList.toggle("holdemBtnAccent", !danger);
const finish = result => {
overlay.hidden = true;
confirmBtn.removeEventListener("click", onAccept);
cancelBtn.removeEventListener("click", onCancel);
overlay.removeEventListener("click", onBackdrop);
document.removeEventListener("keydown", onKey);
resolve(result);
};
const onAccept = () => finish(true);
const onCancel = () => finish(false);
const onBackdrop = e => {
if (e.target === overlay) finish(false);
};
const onKey = e => {
if (e.key === "Escape") finish(false);
if (e.key === "Enter") finish(true);
};
confirmBtn.addEventListener("click", onAccept);
cancelBtn.addEventListener("click", onCancel);
overlay.addEventListener("click", onBackdrop);
document.addEventListener("keydown", onKey);
overlay.hidden = false;
modal.focus();
confirmBtn.focus();
});
},
};
if (typeof module !== "undefined" && module.exports) {
module.exports = { Toast, Dialog };
}
+60 -12
View File
@@ -8,6 +8,29 @@ document.addEventListener("DOMContentLoaded", () => {
Render.mount(state, hooks);
// Moving to the next hand used to ask "are you sure?" every single time —
// the most-pressed button in a session, gated behind a system dialog. It now
// just happens, and the toast offers the way back for the few seconds anyone
// would notice a misclick. The snapshot is deliberately in-memory only: an
// undo is for the mistake you just made, not one from last night's session.
const runUndoable = (message, mutate) => {
const snapshot = JSON.parse(JSON.stringify(state));
mutate();
hooks.save(state);
Render.renderAll();
Toast.show(message, {
actionLabel: "Undo",
onAction: () => {
Object.keys(state).forEach(key => delete state[key]);
Object.assign(state, snapshot);
hooks.save(state);
Render.renderAll();
Toast.show("Undone.", { duration: 2500 });
},
});
};
document.getElementById("seatCountInput").addEventListener("change", e => {
HoldemState.resizeSeats(state, Number(e.target.value));
hooks.save(state);
@@ -49,38 +72,63 @@ document.addEventListener("DOMContentLoaded", () => {
});
document.getElementById("nextHandBtn").addEventListener("click", () => {
if (!confirm("Start the next hand? This moves the button, clears cards and chip counts for every seat.")) return;
HoldemState.startNextHand(state);
hooks.save(state);
Render.renderAll();
// startNextHand moves the button on by one; say where it landed so the
// dealer can confirm the rotation without hunting for the badge.
const nextDealer = HoldemState.nextSeatIndex(state, state.dealerSeatIndex, 1);
const where = state.dedicatedDealer
? `small blind on seat ${HoldemState.nextSeatIndex(state, nextDealer, 1) + 1}`
: `button on seat ${nextDealer + 1}`;
runUndoable(`Next hand — ${where}. Cards and chip counts cleared.`, () =>
HoldemState.startNextHand(state)
);
});
document.getElementById("resetTableBtn").addEventListener("click", () => {
if (!confirm("Reset the whole table? This clears every seat, chip legend and note.")) return;
document.getElementById("resetTableBtn").addEventListener("click", async () => {
const ok = await Dialog.confirm({
title: "Reset the whole table?",
message: "Every seat, chip colour and note goes back to defaults. The hand history and session standings are cleared too.",
confirmLabel: "Reset table",
danger: true,
});
if (!ok) return;
HoldemState.clear();
Object.assign(state, HoldemState.createDefault());
Render.mount(state, hooks);
Toast.show("Table reset.", { duration: 2500 });
});
document.getElementById("resolvePotBtn").addEventListener("click", () => {
PotWizard.open(state, payouts => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
HoldemState.startNextHand(state);
hooks.save(state);
Render.renderAll();
const paid = payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
runUndoable(paid ? `Paid ${paid}.` : "Hand closed with nothing to pay out.", () => {
if (payouts.length > 0) HoldemState.recordHand(state, payouts);
HoldemState.startNextHand(state);
});
});
});
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
document.getElementById("closePotWizardBtn").addEventListener("click", () => PotWizard.close());
document.getElementById("potWizardOverlay").addEventListener("click", e => {
if (e.target.id === "potWizardOverlay") PotWizard.close();
});
document.getElementById("clearHistoryBtn").addEventListener("click", () => {
if (!confirm("Clear the hand history and reset the hand counter to 1? This can't be undone.")) return;
document.getElementById("clearHistoryBtn").addEventListener("click", async () => {
const ok = await Dialog.confirm({
title: "Clear the hand history?",
message: "This wipes every logged hand and the session standings, and puts the hand counter back to #1. Export first if anyone might want the numbers.",
confirmLabel: "Clear history",
danger: true,
});
if (!ok) return;
HoldemState.clearHistory(state);
hooks.save(state);
Render.renderAll();
Toast.show("Hand history cleared.", { duration: 2500 });
});
document.getElementById("exportHistoryBtn").addEventListener("click", () => {
@@ -106,7 +106,10 @@ const Render = {
node.classList.toggle("seatSelected", index === this.selectedSeatIndex);
node.addEventListener("click", () => {
this.selectedSeatIndex = index;
// Tapping the open seat again closes it — on a phone the editor is a
// sheet over the table, and the seat you just tapped is the closest
// thing to hand for dismissing it.
this.selectedSeatIndex = this.selectedSeatIndex === index ? null : index;
this.renderSeats();
this.renderSeatEditor();
});
@@ -175,10 +178,20 @@ const Render = {
// ---- Seat editor panel (the full form for whichever seat is selected) ----
closeSeatEditor() {
this.selectedSeatIndex = null;
this.renderSeats();
this.renderSeatEditor();
},
renderSeatEditor() {
const title = document.getElementById("seatEditorTitle");
const body = document.getElementById("seatEditorBody");
// Drives the mobile bottom sheet: the panel only slides up over the table
// while a seat is actually being edited.
document.body.classList.toggle("seatSheetOpen", this.selectedSeatIndex !== null);
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>';