Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a488bbf832 | ||
|
|
9ee4816fa4 | ||
|
|
db208a1a86 |
@@ -138,7 +138,7 @@
|
||||
<details class="sidebarPanel">
|
||||
<summary>Table Notes</summary>
|
||||
<div class="panelBody">
|
||||
<textarea id="tableNotes" rows="4" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||
</div>
|
||||
</details>
|
||||
</aside>
|
||||
@@ -198,8 +198,8 @@
|
||||
|
||||
<template id="chipLegendRowTemplate">
|
||||
<div class="chipLegendRow">
|
||||
<input type="color" class="chipColorInput" />
|
||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" />
|
||||
<input type="color" class="chipColorInput" aria-label="Chip colour" />
|
||||
<input type="number" class="chipValueInput" min="0" step="0.01" placeholder="value" aria-label="Chip value" />
|
||||
<button class="holdemBtn holdemBtnSmall holdemBtnDanger removeChipColorBtn">×</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -253,7 +253,7 @@
|
||||
</div>
|
||||
<div class="addChipsActions">
|
||||
<button id="addChipsConfirmBtn" class="holdemBtn holdemBtnAccent" type="button">Add to stack</button>
|
||||
<button id="addChipsCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="addChipsCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +263,7 @@
|
||||
<div class="seatSheetGrip" aria-hidden="true"></div>
|
||||
<div class="rulingsHeader">
|
||||
<h2 id="rulingsTitle">Common rulings</h2>
|
||||
<button id="closeRulingsBtn" class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
<button id="closeRulingsBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
|
||||
<p class="rulingsIntro">
|
||||
@@ -314,7 +314,7 @@
|
||||
<span id="seatMenuSubtitle"></span>
|
||||
</div>
|
||||
<div id="seatMenuActions"></div>
|
||||
<button id="seatMenuCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="seatMenuCancelBtn" data-modal-close class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -324,7 +324,7 @@
|
||||
<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>
|
||||
<button id="cardPickerCloseBtn" data-modal-close class="holdemBtn holdemBtnSmall holdemBtnGhost" type="button">Done</button>
|
||||
</div>
|
||||
<div id="cardPickerSlots"></div>
|
||||
<div id="cardPickerGrid"></div>
|
||||
@@ -340,17 +340,17 @@
|
||||
<h2 id="confirmTitle"></h2>
|
||||
<p id="confirmMessage"></p>
|
||||
<div class="confirmActions">
|
||||
<button id="confirmCancelBtn" class="holdemBtn holdemBtnGhost" type="button">Cancel</button>
|
||||
<button id="confirmCancelBtn" data-modal-close 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 id="potWizardModal" role="dialog" aria-labelledby="potWizardTitle">
|
||||
<div class="potWizardHeader">
|
||||
<h2>Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" class="holdemBtn holdemBtnGhost">✕</button>
|
||||
<h2 id="potWizardTitle">Resolve the Pot</h2>
|
||||
<button id="closePotWizardBtn" data-modal-close class="holdemBtn holdemBtnGhost">✕</button>
|
||||
</div>
|
||||
<div id="potWizardBody"></div>
|
||||
</div>
|
||||
@@ -374,5 +374,6 @@
|
||||
<script src="~/js/casino/holdem/shortcuts.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/potWizard.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/handHistoryExport.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/modal.js?v=@ViewData["cssVersion"]"></script>
|
||||
<script src="~/js/casino/holdem/main.js?v=@ViewData["cssVersion"]"></script>
|
||||
}
|
||||
|
||||
@@ -219,7 +219,11 @@ button,
|
||||
padding: 0.6rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
/* Named rather than `all`, so the focus ring isn't animated. With `all` the
|
||||
outline faded in over 150ms, which on keyboard navigation reads as the ring
|
||||
lagging a step behind the Tab key. Nothing else here changes. */
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
|
||||
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1923,6 +1927,24 @@ body.hasActionBar #holdemSidebar {
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
|
||||
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
|
||||
the seat next to it. These step the size down so every digit stays on its own
|
||||
puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
|
||||
comfortably, but a dealer reading a rounded stack off the felt is a worse failure
|
||||
than a small one, and the exact figure is what the tool is for. */
|
||||
.seatPuckStack.puckAmountLong,
|
||||
.seatPuckTotal.puckAmountLong {
|
||||
font-size: 0.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.seatPuckStack.puckAmountHuge,
|
||||
.seatPuckTotal.puckAmountHuge {
|
||||
font-size: 0.44rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.blindsRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -2672,3 +2694,41 @@ body.hasActionBar #toastHost {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Keyboard focus ----------
|
||||
Three focus rules existed for the whole tool, so tabbing through it relied on
|
||||
whatever ring the browser draws by default — which on these dark, custom-painted
|
||||
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
|
||||
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
|
||||
.holdemBtn:focus-visible,
|
||||
.linkBtn:focus-visible,
|
||||
.seatMenuBtn:focus-visible,
|
||||
.seatSheetClose:focus-visible,
|
||||
.pickerCard:focus-visible,
|
||||
.actionBtn:focus-visible,
|
||||
.raiseQuickBtn:focus-visible,
|
||||
.actionUndo:focus-visible,
|
||||
.toastAction:focus-visible,
|
||||
.toastClose:focus-visible,
|
||||
.potWizardChoice:focus-visible,
|
||||
.cardSlot:focus-visible,
|
||||
.subPanel summary:focus-visible,
|
||||
.seat:focus-visible,
|
||||
button:focus-visible,
|
||||
[role="button"]:focus-visible,
|
||||
input:focus-visible,
|
||||
select:focus-visible,
|
||||
textarea:focus-visible {
|
||||
outline: 2px solid #7fc4ff;
|
||||
outline-offset: 2px;
|
||||
/* The ring has to sit above the neighbouring seat that overlaps this one. */
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
|
||||
That shouldn't paint a ring around the whole panel. */
|
||||
[role="dialog"]:focus,
|
||||
[role="alertdialog"]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@@ -39,11 +39,16 @@ const Betting = {
|
||||
return this.round((Number(player.amountOverride) || 0) + this.committed(player));
|
||||
},
|
||||
|
||||
// Never below zero. A seat can't have negative chips in front of it, and a typo in
|
||||
// the stack field used to come back as-is — which then flowed into putIn(), where
|
||||
// `Math.min(amount, -50)` moves a negative amount and *adds* chips to the table.
|
||||
stackOf(state, player) {
|
||||
const own = player.stack;
|
||||
if (own !== null && own !== undefined && String(own).trim() !== "") return this.round(own);
|
||||
if (own !== null && own !== undefined && String(own).trim() !== "") {
|
||||
return Math.max(0, this.round(own));
|
||||
}
|
||||
const fallback = Number(state.defaultStack);
|
||||
return Number.isNaN(fallback) ? 0 : this.round(fallback);
|
||||
return Number.isNaN(fallback) ? 0 : Math.max(0, this.round(fallback));
|
||||
},
|
||||
|
||||
toCall(state, player) {
|
||||
|
||||
@@ -15,6 +15,10 @@ const HandHistoryExport = {
|
||||
}
|
||||
},
|
||||
|
||||
// Money columns go in as bare numbers. Written as "$1,250,000.00" they import as
|
||||
// text, so the one thing anybody opens this file to do — total a column and see
|
||||
// who owes what — doesn't work without cleaning every cell first. The commas were
|
||||
// quoted correctly, so the file parsed; it just wouldn't add up.
|
||||
toCsv(handHistory, standings = []) {
|
||||
const rows = [["Hand #", "Time", "Pot", "Payouts"]];
|
||||
// handHistory is stored newest-first; the export reads better oldest-first.
|
||||
@@ -25,7 +29,7 @@ const HandHistoryExport = {
|
||||
rows.push([
|
||||
entry.handNumber,
|
||||
this.formatTimestamp(entry.timestamp),
|
||||
Money.format(entry.pot),
|
||||
Money.forSheet(entry.pot),
|
||||
payouts,
|
||||
]);
|
||||
});
|
||||
@@ -41,12 +45,12 @@ const HandHistoryExport = {
|
||||
standings.forEach(row => {
|
||||
rows.push([
|
||||
row.name,
|
||||
Money.format(row.in),
|
||||
Money.format(row.won),
|
||||
Money.formatSigned(row.net),
|
||||
Money.format(row.boughtIn || 0),
|
||||
Money.format(row.chipsNow || 0),
|
||||
Money.formatSigned(row.cashPosition || 0),
|
||||
Money.forSheet(row.in),
|
||||
Money.forSheet(row.won),
|
||||
Money.forSheet(row.net),
|
||||
Money.forSheet(row.boughtIn || 0),
|
||||
Money.forSheet(row.chipsNow || 0),
|
||||
Money.forSheet(row.cashPosition || 0),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const state = HoldemState.load();
|
||||
|
||||
// Focus, Tab-trapping and Escape for every overlay, wired once by watching the
|
||||
// `hidden` attribute they all already toggle.
|
||||
Modal.mount();
|
||||
|
||||
// Undo works by swapping the whole table back to a snapshot. `state` is handed out
|
||||
// by reference to every module at mount time, so it has to be refilled in place —
|
||||
// reassigning the variable would leave the renderer and the betting bar pointing at
|
||||
@@ -369,6 +373,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
if (e.key === "Enter") AddChips.confirm();
|
||||
});
|
||||
|
||||
// Escape is Modal's job now, for this sheet and the five others that never had it.
|
||||
const rulingsOverlay = document.getElementById("rulingsOverlay");
|
||||
const closeRulings = () => { rulingsOverlay.hidden = true; };
|
||||
document.getElementById("openRulingsBtn").addEventListener("click", () => { rulingsOverlay.hidden = false; });
|
||||
@@ -376,9 +381,6 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
rulingsOverlay.addEventListener("click", e => {
|
||||
if (e.target === rulingsOverlay) closeRulings();
|
||||
});
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape" && !rulingsOverlay.hidden) closeRulings();
|
||||
});
|
||||
|
||||
document.getElementById("seatMenuCancelBtn").addEventListener("click", () => SeatMenu.close());
|
||||
document.getElementById("seatMenuOverlay").addEventListener("click", e => {
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Keyboard and screen-reader behaviour for every overlay in the tool, in one place.
|
||||
//
|
||||
// There are six of them — seat menu, card picker, add chips, rulings, pot wizard,
|
||||
// confirm — opened and closed by five different modules, and each one was missing
|
||||
// the same three things: focus never moved into the dialog, Tab walked straight out
|
||||
// of it into the page behind the scrim, and Escape did nothing (only the rulings
|
||||
// sheet handled it). Measured before this: 0 of 5 moved focus in, 5 of 5 leaked Tab,
|
||||
// 1 of 5 closed on Escape.
|
||||
//
|
||||
// Rather than patch that into five modules, this watches the `hidden` attribute every
|
||||
// overlay already toggles. Nothing else had to change, and an overlay added later
|
||||
// gets the same behaviour without anyone remembering to wire it up — which is the
|
||||
// failure this is fixing in the first place.
|
||||
const Modal = {
|
||||
// `:not([hidden])` matters — a sheet that is itself hidden inside an open overlay
|
||||
// must not be a Tab stop.
|
||||
FOCUSABLE: [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
'input:not([disabled]):not([type="hidden"])',
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(","),
|
||||
|
||||
open: [],
|
||||
|
||||
mount() {
|
||||
document.querySelectorAll('[id$="Overlay"]').forEach(overlay => this.watch(overlay));
|
||||
// Capture phase: the dialog gets first refusal on Escape and Tab, before any
|
||||
// table-level shortcut sees them.
|
||||
document.addEventListener("keydown", e => this.onKeyDown(e), true);
|
||||
},
|
||||
|
||||
watch(overlay) {
|
||||
const sync = () => {
|
||||
const isOpen = !overlay.hidden;
|
||||
const known = this.open.some(entry => entry.overlay === overlay);
|
||||
if (isOpen && !known) this.onOpened(overlay);
|
||||
else if (!isOpen && known) this.onClosed(overlay);
|
||||
};
|
||||
new MutationObserver(sync).observe(overlay, { attributes: true, attributeFilter: ["hidden"] });
|
||||
sync();
|
||||
},
|
||||
|
||||
sheetOf(overlay) {
|
||||
return overlay.querySelector('[role="dialog"], [role="alertdialog"]') || overlay.firstElementChild || overlay;
|
||||
},
|
||||
|
||||
focusableIn(overlay) {
|
||||
return [...overlay.querySelectorAll(this.FOCUSABLE)].filter(el => {
|
||||
if (el.closest("[hidden]")) return false;
|
||||
const rect = el.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
});
|
||||
},
|
||||
|
||||
onOpened(overlay) {
|
||||
const sheet = this.sheetOf(overlay);
|
||||
// aria-modal is only true while the thing is actually open, so it's set here
|
||||
// rather than baked into the markup.
|
||||
if (sheet) sheet.setAttribute("aria-modal", "true");
|
||||
|
||||
this.open.push({ overlay, returnFocus: document.activeElement });
|
||||
|
||||
// Focus the first real control. A dialog that leaves focus on the button
|
||||
// behind it reads, to a screen reader, as though nothing happened at all.
|
||||
// Content-only sheets get focus on the sheet so the title is announced.
|
||||
const focusable = this.focusableIn(overlay);
|
||||
const target = focusable[0];
|
||||
if (target) {
|
||||
target.focus({ preventScroll: true });
|
||||
} else if (sheet) {
|
||||
if (!sheet.hasAttribute("tabindex")) sheet.setAttribute("tabindex", "-1");
|
||||
sheet.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
onClosed(overlay) {
|
||||
const index = this.open.findIndex(entry => entry.overlay === overlay);
|
||||
if (index === -1) return;
|
||||
const [entry] = this.open.splice(index, 1);
|
||||
|
||||
const sheet = this.sheetOf(overlay);
|
||||
if (sheet) sheet.removeAttribute("aria-modal");
|
||||
|
||||
// Put the dealer back where they were. Without this, closing the pot wizard
|
||||
// drops focus to the top of the document and the next Tab starts from the
|
||||
// page header.
|
||||
const back = entry.returnFocus;
|
||||
if (back && back.isConnected && typeof back.focus === "function") {
|
||||
back.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
|
||||
topmost() {
|
||||
return this.open.length ? this.open[this.open.length - 1] : null;
|
||||
},
|
||||
|
||||
// Escape presses the dialog's own cancel/close button rather than just hiding the
|
||||
// overlay, so whatever that button does — resolving the confirm promise, clearing
|
||||
// the add-chips draft — still happens. Only if there isn't one does it hide the
|
||||
// overlay directly.
|
||||
dismiss(overlay) {
|
||||
const closer = overlay.querySelector("[data-modal-close]");
|
||||
if (closer) closer.click();
|
||||
else overlay.hidden = true;
|
||||
},
|
||||
|
||||
onKeyDown(event) {
|
||||
const top = this.topmost();
|
||||
if (!top) return;
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dismiss(top.overlay);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") return;
|
||||
|
||||
// Keep Tab inside the dialog. Everything behind the scrim is unreachable by
|
||||
// mouse, so letting the keyboard wander back there is a trap with no way out.
|
||||
const focusable = this.focusableIn(top.overlay);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && (active === first || !top.overlay.contains(active))) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && (active === last || !top.overlay.contains(active))) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = Modal;
|
||||
}
|
||||
@@ -25,9 +25,27 @@ const Money = {
|
||||
return this.toCents(amount) / 100;
|
||||
},
|
||||
|
||||
// Cents only when there are cents.
|
||||
//
|
||||
// A chip game deals in whole numbers nearly all the time, and a trailing ".00"
|
||||
// is two characters that say nothing — measured at ~22px, against the 64px a
|
||||
// seat puck has to show a stack in. "$1,250,000.00" doesn't fit and wraps;
|
||||
// "$1,250,000" does. Amounts that genuinely carry cents still show them, so a
|
||||
// 25c-blind game reads exactly as it did.
|
||||
format(amount) {
|
||||
const value = Number(amount) || 0;
|
||||
return `$${value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
const hasCents = this.toCents(value) % 100 !== 0;
|
||||
return `$${value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: hasCents ? 2 : 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
},
|
||||
|
||||
// The same amount for a spreadsheet rather than for a person: no currency symbol
|
||||
// and no thousands separators, because those are what make a column of money
|
||||
// import as text and refuse to add up.
|
||||
forSheet(amount) {
|
||||
return (this.toCents(amount) / 100).toFixed(2);
|
||||
},
|
||||
|
||||
// "+$40.00" / "-$40.00" rather than "$-40.00" — a standings column is read at a
|
||||
|
||||
@@ -16,6 +16,10 @@ const Render = {
|
||||
this.state = state;
|
||||
this.hooks = hooks;
|
||||
this.selectedSeatIndex = null;
|
||||
// A reset hands over a different table entirely, so nothing cached about the
|
||||
// last one still applies.
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
@@ -261,11 +265,14 @@ const Render = {
|
||||
const total = handTotal > 0
|
||||
? handTotal
|
||||
: HoldemState.computeContribution(player, this.state.chipLegend, this.state.basePotAmount);
|
||||
node.querySelector(".seatPuckTotal").textContent = total > 0 ? Money.format(total) : "";
|
||||
const totalEl = node.querySelector(".seatPuckTotal");
|
||||
totalEl.textContent = total > 0 ? Money.format(total) : "";
|
||||
this.fitAmount(totalEl);
|
||||
|
||||
const stackEl = node.querySelector(".seatPuckStack");
|
||||
const stack = Betting.stackOf(this.state, player);
|
||||
stackEl.textContent = !empty && stack > 0 ? Money.format(stack) : "";
|
||||
this.fitAmount(stackEl);
|
||||
|
||||
node.classList.toggle("seatActing", index === this.state.actingSeatIndex);
|
||||
node.classList.toggle("seatEmpty", empty);
|
||||
@@ -274,6 +281,17 @@ const Render = {
|
||||
node.classList.toggle("seatLive", player.inHand && !player.folded);
|
||||
},
|
||||
|
||||
// A seat puck is 64px of usable width. A tournament stack doesn't fit in that and,
|
||||
// because the number has no spaces to break on, it doesn't wrap or ellipsise
|
||||
// either — it just draws straight out over the neighbouring seat. Stepping the
|
||||
// font down keeps every digit, which matters: a dealer misreading a stack is a
|
||||
// worse outcome than a small one, so this shrinks rather than abbreviating.
|
||||
fitAmount(el) {
|
||||
const length = (el.textContent || "").length;
|
||||
el.classList.toggle("puckAmountLong", length >= 8 && length < 10);
|
||||
el.classList.toggle("puckAmountHuge", length >= 10);
|
||||
},
|
||||
|
||||
// Cheaper than a full renderSeats() — used after edits so the seat editor's inputs
|
||||
// (which live in a separate panel) never lose focus.
|
||||
refreshPuck(index) {
|
||||
@@ -689,24 +707,57 @@ const Render = {
|
||||
|
||||
// ---- Hand counter + history ----
|
||||
|
||||
// toLocaleTimeString is the single most expensive call in the history panel, and a
|
||||
// logged hand's timestamp never changes, so the answer is worth keeping.
|
||||
handTimeCache: new Map(),
|
||||
|
||||
formatHandTime(timestamp) {
|
||||
if (this.handTimeCache.has(timestamp)) return this.handTimeCache.get(timestamp);
|
||||
let formatted = "";
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
formatted = new Date(timestamp).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
||||
} catch {
|
||||
return "";
|
||||
formatted = "";
|
||||
}
|
||||
this.handTimeCache.set(timestamp, formatted);
|
||||
return formatted;
|
||||
},
|
||||
|
||||
renderHandHistory() {
|
||||
// Cheap stand-in for "has the log changed". Entries are immutable once written and
|
||||
// only ever added at the front, so the length, the counter and the newest row
|
||||
// between them catch a recorded hand, a cleared history and an undo.
|
||||
historySignature() {
|
||||
const history = this.state.handHistory || [];
|
||||
const newest = history[0];
|
||||
return [
|
||||
history.length,
|
||||
this.state.handNumber || 1,
|
||||
newest ? newest.handNumber : "",
|
||||
newest ? newest.timestamp : "",
|
||||
newest ? newest.pot : "",
|
||||
].join("|");
|
||||
},
|
||||
|
||||
// Rebuilding this panel measured 25.7ms of the 30ms a full re-render costs — and a
|
||||
// full re-render runs after every fold, call and raise. The log only changes when a
|
||||
// hand is settled or the history is cleared, perhaps once every thirty taps, so it
|
||||
// is skipped unless it actually did change. Everything else on the table reflects
|
||||
// the current hand and still redraws every time.
|
||||
renderHandHistory(force = false) {
|
||||
const signature = this.historySignature();
|
||||
if (!force && signature === this.lastHistorySignature) return;
|
||||
this.lastHistorySignature = signature;
|
||||
|
||||
document.getElementById("handCounterBadge").textContent = `Hand #${this.state.handNumber || 1}`;
|
||||
|
||||
const list = document.getElementById("handHistoryList");
|
||||
const emptyHint = document.getElementById("handHistoryEmptyHint");
|
||||
const history = this.state.handHistory || [];
|
||||
list.innerHTML = "";
|
||||
emptyHint.hidden = history.length > 0;
|
||||
document.getElementById("exportHistoryBtn").disabled = history.length === 0;
|
||||
|
||||
// Built off-document so the panel is laid out once instead of once per row.
|
||||
const fragment = document.createDocumentFragment();
|
||||
const template = document.getElementById("handHistoryRowTemplate");
|
||||
history.forEach(entry => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
@@ -717,8 +768,10 @@ const Render = {
|
||||
const payoutsEl = row.querySelector(".handHistoryPayouts");
|
||||
payoutsEl.textContent = entry.payouts.map(p => `${p.label} ${Money.format(p.amount)}`).join(", ");
|
||||
|
||||
list.appendChild(row);
|
||||
fragment.appendChild(row);
|
||||
});
|
||||
list.innerHTML = "";
|
||||
list.appendChild(fragment);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -32,14 +32,12 @@ const Shortcuts = {
|
||||
return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable;
|
||||
},
|
||||
|
||||
// An overlay owns the keyboard while it's up: the confirm dialog already binds
|
||||
// Enter and Escape, and the card picker and seat menu are modal.
|
||||
// An overlay owns the keyboard while it's up. Asking the document which overlays
|
||||
// are open, rather than listing them, means a sheet added later can't quietly
|
||||
// start letting "f" fold the table behind it — the same reason Modal watches for
|
||||
// them instead of being told about each one.
|
||||
overlayOpen() {
|
||||
return ["confirmOverlay", "potWizardOverlay", "cardPickerOverlay", "seatMenuOverlay", "rulingsOverlay", "addChipsOverlay"]
|
||||
.some(id => {
|
||||
const el = document.getElementById(id);
|
||||
return el && !el.hidden;
|
||||
});
|
||||
return [...document.querySelectorAll('[id$="Overlay"]')].some(el => !el.hidden);
|
||||
},
|
||||
|
||||
onKey(e) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// The hand-history panel is only rebuilt when its signature changes, which takes a
|
||||
// full re-render from ~30ms to ~3ms. The whole saving depends on that signature
|
||||
// noticing every way the log can actually change — a stale panel would have the
|
||||
// dealer reading last hand's payouts while settling this one.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Render, HoldemState, makeTable } = require("./load");
|
||||
|
||||
// historySignature() reads Render.state, so point it at a table for each case.
|
||||
const signatureOf = state => {
|
||||
Render.state = state;
|
||||
return Render.historySignature();
|
||||
};
|
||||
|
||||
const tableWithHands = (count) => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
for (let i = 0; i < count; i++) {
|
||||
state.seats[0].amountOverride = 2;
|
||||
state.seats[1].amountOverride = 2;
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
describe("the history panel knows when to redraw", () => {
|
||||
test("nothing changes while a hand is being played", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
|
||||
// A whole street of betting touches no part of the log.
|
||||
state.seats[0].committed = 5;
|
||||
state.seats[1].folded = true;
|
||||
state.actingSeatIndex = 2;
|
||||
state.street = "flop";
|
||||
|
||||
assert.equal(signatureOf(state), before, "betting must not force a rebuild");
|
||||
});
|
||||
|
||||
test("settling a hand changes it", () => {
|
||||
const state = tableWithHands(3);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.recordHand(state, [{ id: state.seats[1].id, label: "Ben", amount: 12 }]);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("clearing the history changes it", () => {
|
||||
const state = tableWithHands(5);
|
||||
const before = signatureOf(state);
|
||||
HoldemState.clearHistory(state);
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("undoing back to a shorter log changes it", () => {
|
||||
const state = tableWithHands(2);
|
||||
const snapshot = JSON.parse(JSON.stringify(state));
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 30 }]);
|
||||
const afterRecord = signatureOf(state);
|
||||
|
||||
// The undo path swaps the state's contents in place.
|
||||
Object.keys(state).forEach(k => delete state[k]);
|
||||
Object.assign(state, snapshot);
|
||||
|
||||
assert.notEqual(signatureOf(state), afterRecord, "an undone hand must disappear from the panel");
|
||||
});
|
||||
|
||||
test("two hands that differ only in payout are told apart", () => {
|
||||
const a = tableWithHands(1);
|
||||
const b = tableWithHands(1);
|
||||
// Same count and hand number; different pot.
|
||||
b.handHistory[0].pot = a.handHistory[0].pot + 25;
|
||||
assert.notEqual(signatureOf(a), signatureOf(b));
|
||||
});
|
||||
|
||||
test("the hand counter moving on changes it", () => {
|
||||
// The counter badge is drawn by the same function, so a new hand number has
|
||||
// to invalidate even when the log itself is untouched.
|
||||
const state = tableWithHands(2);
|
||||
const before = signatureOf(state);
|
||||
state.handNumber += 1;
|
||||
assert.notEqual(signatureOf(state), before);
|
||||
});
|
||||
|
||||
test("an empty log has a stable signature of its own", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1] });
|
||||
const first = signatureOf(state);
|
||||
assert.equal(signatureOf(state), first);
|
||||
|
||||
HoldemState.recordHand(state, [{ id: state.seats[0].id, label: "Ann", amount: 4 }]);
|
||||
assert.notEqual(signatureOf(state), first, "the first hand ever must draw the panel");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the hand-time cache", () => {
|
||||
test("the same timestamp is only formatted once, and formats the same", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const stamp = Date.UTC(2026, 8, 1, 15, 4);
|
||||
const first = Render.formatHandTime(stamp);
|
||||
assert.equal(Render.handTimeCache.size, 1);
|
||||
|
||||
const second = Render.formatHandTime(stamp);
|
||||
assert.equal(second, first);
|
||||
assert.equal(Render.handTimeCache.size, 1, "not re-formatted");
|
||||
});
|
||||
|
||||
test("different timestamps still format differently", () => {
|
||||
Render.handTimeCache.clear();
|
||||
const a = Render.formatHandTime(Date.UTC(2026, 8, 1, 15, 4));
|
||||
const b = Render.formatHandTime(Date.UTC(2026, 8, 1, 18, 47));
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(Render.handTimeCache.size, 2);
|
||||
});
|
||||
|
||||
test("a nonsense timestamp doesn't throw", () => {
|
||||
Render.handTimeCache.clear();
|
||||
assert.doesNotThrow(() => Render.formatHandTime(undefined));
|
||||
assert.doesNotThrow(() => Render.formatHandTime("not a date"));
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,11 @@ const Money = load("money");
|
||||
const PotCalculator = load("potCalculator");
|
||||
const SplitCalculator = load("splitCalculator");
|
||||
const Betting = load("betting");
|
||||
const HandHistoryExport = load("handHistoryExport");
|
||||
|
||||
// render.js exports { Render } rather than the namespace directly.
|
||||
const Render = require(path.join(SRC, "render.js")).Render;
|
||||
globalThis.Render = Render;
|
||||
|
||||
// state.js exports `HoldemState`, which doesn't match its filename.
|
||||
const HoldemState = require(path.join(SRC, "state.js"));
|
||||
@@ -68,6 +73,8 @@ function contributions(state) {
|
||||
|
||||
module.exports = {
|
||||
Money,
|
||||
Render,
|
||||
HandHistoryExport,
|
||||
PotCalculator,
|
||||
SplitCalculator,
|
||||
Betting,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Money, HandHistoryExport } = require("./load");
|
||||
|
||||
describe("Money.format", () => {
|
||||
test("whole amounts drop the pointless cents", () => {
|
||||
assert.equal(Money.format(12), "$12");
|
||||
assert.equal(Money.format(450), "$450");
|
||||
assert.equal(Money.format(1250000), "$1,250,000");
|
||||
assert.equal(Money.format(0), "$0");
|
||||
});
|
||||
|
||||
test("amounts that carry cents still show them", () => {
|
||||
assert.equal(Money.format(12.5), "$12.50");
|
||||
assert.equal(Money.format(0.25), "$0.25");
|
||||
assert.equal(Money.format(3.01), "$3.01");
|
||||
});
|
||||
|
||||
test("a quarter-blind game reads exactly as it did", () => {
|
||||
// The case that would be ruined by dropping cents unconditionally.
|
||||
[0.25, 0.5, 1.75, 12.05].forEach(v => {
|
||||
assert.match(Money.format(v), /\.\d\d$/, `${v} lost its cents`);
|
||||
});
|
||||
});
|
||||
|
||||
test("floating-point dust doesn't invent cents", () => {
|
||||
assert.equal(Money.format(0.1 + 0.2), "$0.30");
|
||||
assert.equal(Money.format(1.0000000001), "$1");
|
||||
});
|
||||
|
||||
test("signed amounts put the sign out front, both scales", () => {
|
||||
assert.equal(Money.formatSigned(87500), "+$87,500");
|
||||
assert.equal(Money.formatSigned(-87500), "-$87,500");
|
||||
assert.equal(Money.formatSigned(-12.5), "-$12.50");
|
||||
assert.equal(Money.formatSigned(0), "$0");
|
||||
});
|
||||
|
||||
test("rubbish in doesn't produce rubbish out", () => {
|
||||
assert.equal(Money.format(null), "$0");
|
||||
assert.equal(Money.format(undefined), "$0");
|
||||
assert.equal(Money.format("nonsense"), "$0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Money.forSheet", () => {
|
||||
test("a bare number a spreadsheet will add up", () => {
|
||||
assert.equal(Money.forSheet(1250000), "1250000.00");
|
||||
assert.equal(Money.forSheet(12.5), "12.50");
|
||||
assert.equal(Money.forSheet(-87500), "-87500.00");
|
||||
});
|
||||
|
||||
test("no currency symbol and no thousands separators", () => {
|
||||
const written = Money.forSheet(1234567.89);
|
||||
assert.ok(!written.includes("$"), "a $ makes the cell text");
|
||||
assert.ok(!written.includes(","), "a comma makes the cell text");
|
||||
assert.equal(Number(written), 1234567.89, "and it parses straight back to a number");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the exported CSV", () => {
|
||||
const history = [
|
||||
{ handNumber: 2, timestamp: 0, pot: 1250000, payouts: [{ id: "a", label: "Alexandra", amount: 1250000 }] },
|
||||
{ handNumber: 1, timestamp: 0, pot: 24.5, payouts: [{ id: "b", label: "Bob", amount: 24.5 }] },
|
||||
];
|
||||
const standings = [
|
||||
{ id: "a", name: "Alexandra", in: 1250000, won: 1250000, net: 0, boughtIn: 3200000, chipsNow: 3200000, cashPosition: 0 },
|
||||
{ id: "b", name: "Bob", in: 24.5, won: 0, net: -24.5, boughtIn: 100, chipsNow: 75.5, cashPosition: -24.5 },
|
||||
];
|
||||
|
||||
// Splitting on commas isn't good enough: the timestamp column legitimately holds
|
||||
// one ("1/1/1970, 5:00:00 PM") and is quoted for exactly that reason.
|
||||
const splitRow = line => {
|
||||
const fields = [];
|
||||
let field = "";
|
||||
let quoted = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const c = line[i];
|
||||
if (quoted) {
|
||||
if (c === '"' && line[i + 1] === '"') { field += '"'; i++; }
|
||||
else if (c === '"') quoted = false;
|
||||
else field += c;
|
||||
} else if (c === '"') quoted = true;
|
||||
else if (c === ",") { fields.push(field); field = ""; }
|
||||
else field += c;
|
||||
}
|
||||
fields.push(field);
|
||||
return fields;
|
||||
};
|
||||
|
||||
test("every money cell parses as a number", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, standings);
|
||||
const lines = csv.split("\r\n");
|
||||
|
||||
lines.slice(1, 3).forEach(line => {
|
||||
const cell = splitRow(line)[2]; // Pot
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `pot cell "${cell}" isn't a number`);
|
||||
});
|
||||
|
||||
// Standings rows: every column after the name has to be numeric.
|
||||
const standingsStart = lines.findIndex(l => l.startsWith("Player,"));
|
||||
lines.slice(standingsStart + 1).filter(Boolean).forEach(line => {
|
||||
splitRow(line).slice(1).forEach(cell => {
|
||||
assert.ok(cell !== "" && !Number.isNaN(Number(cell)), `standings cell "${cell}" isn't a number`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("a million-dollar pot doesn't need quoting any more", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, []);
|
||||
assert.ok(csv.includes("1250000.00"), "written plainly");
|
||||
assert.ok(!csv.includes('"$1,250,000'), "not as a quoted, unsummable string");
|
||||
});
|
||||
|
||||
test("names and payout descriptions stay human", () => {
|
||||
const csv = HandHistoryExport.toCsv(history, standings);
|
||||
assert.ok(csv.includes("Alexandra"), "the name column is still a name");
|
||||
assert.ok(csv.includes("Alexandra: $1,250,000"), "the payout column still reads like a sentence");
|
||||
});
|
||||
|
||||
test("a name with a comma is still quoted properly", () => {
|
||||
const csv = HandHistoryExport.toCsv([], [
|
||||
{ id: "x", name: "Smith, John", in: 0, won: 0, net: 0, boughtIn: 0, chipsNow: 0, cashPosition: 0 },
|
||||
]);
|
||||
assert.ok(csv.includes('"Smith, John"'), "the CSV escaping still applies where it's needed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
// Values a dealer can actually produce with a typo, and the ones a corrupted save
|
||||
// can produce on its own. None of these should be able to move chips.
|
||||
const { test, describe } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load");
|
||||
|
||||
describe("a stack can never go negative", () => {
|
||||
test("a mistyped negative stack reads as empty, not as owing chips", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
assert.equal(Betting.stackOf(state, state.seats[0]), 0);
|
||||
});
|
||||
|
||||
test("a negative stack can't be bet with", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
const before = state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0);
|
||||
|
||||
const moved = Betting.putIn(state, 0, 25);
|
||||
|
||||
assert.equal(moved, 0, "nothing moves out of an empty stack");
|
||||
assert.ok(Betting.stackOf(state, state.seats[0]) >= 0);
|
||||
assert.equal(state.seats[0].committed, 0, "and nothing appears in front of them");
|
||||
assert.ok(state.seats.reduce((s, p) => s + Math.max(0, Number(p.stack) || 0), 0) <= before);
|
||||
});
|
||||
|
||||
test("a negative table default doesn't hand everyone a negative stack", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats.forEach(p => { p.stack = null; });
|
||||
state.defaultStack = -100;
|
||||
state.seats.forEach(p => assert.equal(Betting.stackOf(state, p), 0));
|
||||
});
|
||||
|
||||
test("a negative stack doesn't make a player look like they can still bet", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.seats[0].stack = -50;
|
||||
assert.ok(!Betting.seatsWithChips(state).some(s => s.index === 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe("blinds that can't be posted", () => {
|
||||
test("negative blinds post nothing rather than paying players to sit down", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.smallBlind = -5;
|
||||
state.bigBlind = -10;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
state.seats.forEach(p => assert.equal(p.committed, 0));
|
||||
assert.equal(chipsOnTable(state), before);
|
||||
});
|
||||
|
||||
test("an unreadable blind doesn't wedge the hand", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2] });
|
||||
state.bigBlind = NaN;
|
||||
state.smallBlind = 1;
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.ok(Number.isFinite(state.currentBet), "the bet to match is still a number");
|
||||
assert.ok(!Number.isNaN(Betting.potSoFar(state)));
|
||||
});
|
||||
|
||||
test("blinds nobody can cover put those players all-in and move on", () => {
|
||||
const state = makeTable({ seatCount: 4, seated: [0, 1, 2], stack: 10 });
|
||||
state.smallBlind = 500;
|
||||
state.bigBlind = 1000;
|
||||
const before = chipsOnTable(state);
|
||||
|
||||
Betting.beginHand(state);
|
||||
|
||||
assert.equal(chipsOnTable(state), before, "no chips invented covering a blind");
|
||||
state.seats.forEach(p => assert.ok((Number(p.stack) || 0) >= 0));
|
||||
assert.ok(state.actingSeatIndex === null || state.seats[state.actingSeatIndex].inHand);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a saved table that comes back damaged", () => {
|
||||
test("unreadable storage falls back to a fresh table", () => {
|
||||
// load() has to survive whatever is in localStorage, because the alternative
|
||||
// is a dealer opening the tool mid-session to a blank screen.
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => "{not json at all",
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.equal(state.seats.length, HoldemState.DEFAULT_SEAT_COUNT);
|
||||
assert.equal(state.handNumber, 1);
|
||||
});
|
||||
|
||||
test("a save missing its seats is treated as no save at all", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({ handNumber: 7, smallBlind: 3 }),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.ok(Array.isArray(state.seats) && state.seats.length > 0);
|
||||
});
|
||||
|
||||
test("a save from before a field existed gets the field, not undefined", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => JSON.stringify({
|
||||
seats: [{ id: "a", name: "Ann", inHand: true }, { id: "b", name: "Ben", inHand: true }],
|
||||
handNumber: 4,
|
||||
}),
|
||||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.load();
|
||||
globalThis.localStorage = original;
|
||||
|
||||
assert.equal(state.blindOverride, null);
|
||||
assert.equal(state.street, "preflop");
|
||||
state.seats.forEach(p => {
|
||||
assert.equal(p.committed, 0);
|
||||
assert.equal(p.hasActed, false);
|
||||
assert.equal(p.buyIns, 0);
|
||||
});
|
||||
// And it has to be playable, not just parseable.
|
||||
assert.doesNotThrow(() => Betting.beginHand(state));
|
||||
});
|
||||
|
||||
test("storage that refuses to be written doesn't take the table down", () => {
|
||||
const original = globalThis.localStorage;
|
||||
globalThis.localStorage = {
|
||||
getItem: () => null,
|
||||
setItem: () => { throw new Error("QuotaExceededError"); },
|
||||
removeItem: () => {},
|
||||
};
|
||||
const state = HoldemState.createDefault();
|
||||
assert.doesNotThrow(() => HoldemState.save(state), "a full disk must not break the hand in progress");
|
||||
globalThis.localStorage = original;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user