Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
419852a1f4 | ||
|
|
8a2fc3e540 | ||
|
|
b64da7a787 |
@@ -155,6 +155,17 @@
|
|||||||
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
<button id="exportHistoryBtn" class="holdemBtn holdemBtnSmall">Export CSV</button>
|
||||||
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
<button id="clearHistoryBtn" class="holdemBtn holdemBtnSmall holdemBtnDanger">Clear history</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="panelSubhead">The table itself</h3>
|
||||||
|
<p class="fieldHint">
|
||||||
|
The night lives in this browser and nowhere else. Save it to a file to carry it
|
||||||
|
to another device, or to have something to come back to if this one dies.
|
||||||
|
</p>
|
||||||
|
<div class="handHistoryActions">
|
||||||
|
<button id="saveTableBtn" class="holdemBtn holdemBtnSmall">Save table file</button>
|
||||||
|
<button id="loadTableBtn" class="holdemBtn holdemBtnSmall">Load table file</button>
|
||||||
|
<input id="loadTableInput" type="file" accept="application/json,.json" hidden />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -3138,7 +3138,18 @@ body.hasActionBar #toastHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.panelBody .holdemBtnSmall,
|
.panelBody .holdemBtnSmall,
|
||||||
.handHistoryActions .holdemBtn {
|
.handHistoryActions .holdemBtn,
|
||||||
|
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
||||||
|
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
||||||
|
player's cards, which is not a thing to make a dealer aim at. */
|
||||||
|
.cardPickerHeader .holdemBtn,
|
||||||
|
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
||||||
|
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
||||||
|
short of a fingertip. Growing the box costs bar height, which comes out of the
|
||||||
|
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
||||||
|
is room for it. A hit-area pad was tried first and does not work here: the pill
|
||||||
|
clips its overflow to keep a long label on one line, which clips the pad too. */
|
||||||
|
.actionUndo {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3525,7 +3536,9 @@ textarea:focus-visible {
|
|||||||
|
|
||||||
.panelBody .holdemBtnSmall,
|
.panelBody .holdemBtnSmall,
|
||||||
.handHistoryActions .holdemBtn,
|
.handHistoryActions .holdemBtn,
|
||||||
.toastAction {
|
.toastAction,
|
||||||
|
.cardPickerHeader .holdemBtn,
|
||||||
|
.actionUndo {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -598,6 +598,72 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
Toast.show("Hand history cleared.", { duration: 2500 });
|
Toast.show("Hand history cleared.", { duration: 2500 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById("saveTableBtn").addEventListener("click", () => {
|
||||||
|
const blob = new Blob([HoldemState.toBackup(state)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = HoldemState.backupFileName();
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
Toast.show(`Table saved as ${link.download}.`, { duration: 3500 });
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("loadTableBtn").addEventListener("click", () => {
|
||||||
|
document.getElementById("loadTableInput").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("loadTableInput").addEventListener("change", async e => {
|
||||||
|
const file = e.target.files && e.target.files[0];
|
||||||
|
// Cleared straight away so picking the same file twice still fires a change.
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
let text;
|
||||||
|
try {
|
||||||
|
text = await file.text();
|
||||||
|
} catch {
|
||||||
|
Toast.show("That file couldn't be read.", { duration: 4000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = HoldemState.fromBackup(text);
|
||||||
|
if (result.error) {
|
||||||
|
Toast.show(result.error, { duration: 5000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading replaces everything: stacks, buy-ins, the button, the log. Naming
|
||||||
|
// what is about to be lost is the difference between a dealer choosing that
|
||||||
|
// and discovering it — and the current table might be the live one.
|
||||||
|
const here = HoldemState.sessionStandings(state).length;
|
||||||
|
const incoming = HoldemState.sessionStandings(result.table).length;
|
||||||
|
const when = result.savedAt ? new Date(result.savedAt) : null;
|
||||||
|
const saidWhen = when && !Number.isNaN(when.getTime())
|
||||||
|
? ` It was saved ${when.toLocaleString()}.`
|
||||||
|
: "";
|
||||||
|
const ok = await Dialog.confirm({
|
||||||
|
title: "Replace this table?",
|
||||||
|
message:
|
||||||
|
`The file has a table with ${incoming} ${incoming === 1 ? "player" : "players"} in it.${saidWhen} ` +
|
||||||
|
`Loading it replaces the table on screen — ${here} ${here === 1 ? "player" : "players"}, ` +
|
||||||
|
`${(state.handHistory || []).length} logged ${(state.handHistory || []).length === 1 ? "hand" : "hands"}. ` +
|
||||||
|
`Undo will bring it back for a few seconds; after that it is gone.`,
|
||||||
|
confirmLabel: "Load it",
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
|
||||||
|
runUndoable("loading a saved table", () => {
|
||||||
|
// Replace the contents rather than the reference: everything on the page
|
||||||
|
// holds a pointer to this object.
|
||||||
|
Object.keys(state).forEach(key => { delete state[key]; });
|
||||||
|
Object.assign(state, result.table);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// The filter is how the dealer looks at the log, not something about the table, so
|
// The filter is how the dealer looks at the log, not something about the table, so
|
||||||
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
// it lives on the view: it isn't saved, isn't undoable, and doesn't reach the other
|
||||||
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
// tab. Clearing the history clears it too, since there is nothing left to filter.
|
||||||
|
|||||||
@@ -164,13 +164,18 @@ const HoldemState = {
|
|||||||
return this.createDefault();
|
return this.createDefault();
|
||||||
},
|
},
|
||||||
|
|
||||||
load() {
|
// Everything a table has to be put through before it can be trusted, whichever
|
||||||
let raw = null;
|
// direction it arrived from.
|
||||||
try {
|
//
|
||||||
raw = localStorage.getItem(this.STORAGE_KEY);
|
// Pulled out of load() so a table read from a file goes through exactly the same
|
||||||
if (!raw) return this.createDefault();
|
// checks as one read from storage. A second, looser path for imports is how a
|
||||||
const parsed = JSON.parse(raw);
|
// format grows two meanings of the same field.
|
||||||
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
//
|
||||||
|
// Returns null for anything that isn't a table at all; every other repair is
|
||||||
|
// silent and forward, the way a save from an older version is treated.
|
||||||
|
migrate(parsed) {
|
||||||
|
if (!parsed || !Array.isArray(parsed.seats)) return null;
|
||||||
|
{
|
||||||
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
if (typeof parsed.handNumber !== "number" || parsed.handNumber < 1) parsed.handNumber = 1;
|
||||||
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
if (!Array.isArray(parsed.handHistory)) parsed.handHistory = [];
|
||||||
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
if (typeof parsed.discarded !== "number" || !(parsed.discarded >= 0)) parsed.discarded = 0;
|
||||||
@@ -215,12 +220,85 @@ const HoldemState = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
load() {
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||||
|
if (!raw) return this.createDefault();
|
||||||
|
const migrated = this.migrate(JSON.parse(raw));
|
||||||
|
if (!migrated) return this.failedLoad(raw);
|
||||||
|
return migrated;
|
||||||
} catch {
|
} catch {
|
||||||
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||||
return this.failedLoad(raw);
|
return this.failedLoad(raw);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Taking the table off this device ------------------------------------
|
||||||
|
//
|
||||||
|
// A night lives in one browser's localStorage and nowhere else. A dealer whose
|
||||||
|
// phone dies, or who wants to carry on from the tablet after the break, loses the
|
||||||
|
// stacks, the buy-ins, where the button is and the whole hand log — and the CSV
|
||||||
|
// export can't bring any of that back, because it records what happened rather
|
||||||
|
// than where the table stands.
|
||||||
|
//
|
||||||
|
// So the table can be written out as a file and read back in. Same shape that goes
|
||||||
|
// into storage, wrapped in enough to recognise it later.
|
||||||
|
BACKUP_KIND: "casino.holdem.table",
|
||||||
|
BACKUP_VERSION: 1,
|
||||||
|
|
||||||
|
toBackup(state) {
|
||||||
|
return JSON.stringify({
|
||||||
|
kind: this.BACKUP_KIND,
|
||||||
|
version: this.BACKUP_VERSION,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
table: state,
|
||||||
|
}, null, 2);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reads a file back. Returns { table } on success, or { error } with something a
|
||||||
|
// dealer can act on — never a half-loaded table, because the failure mode there is
|
||||||
|
// a night that looks restored and isn't.
|
||||||
|
fromBackup(text) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return { error: "That file isn't a saved table — it couldn't be read as JSON." };
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object") {
|
||||||
|
return { error: "That file isn't a saved table." };
|
||||||
|
}
|
||||||
|
// A bare table, from someone who copied the storage value out by hand, is
|
||||||
|
// still a table. The envelope is a convenience, not a password.
|
||||||
|
const table = parsed.kind === this.BACKUP_KIND || parsed.table ? parsed.table : parsed;
|
||||||
|
if (parsed.kind && parsed.kind !== this.BACKUP_KIND) {
|
||||||
|
return { error: "That file is a save from something else, not a Hold'em table." };
|
||||||
|
}
|
||||||
|
const migrated = this.migrate(table);
|
||||||
|
if (!migrated) {
|
||||||
|
return { error: "That file doesn't have a table in it — no seats were found." };
|
||||||
|
}
|
||||||
|
if (parsed.version > this.BACKUP_VERSION) {
|
||||||
|
// Newer than this build understands. Loading it anyway risks reading a
|
||||||
|
// field that has changed meaning, and the dealer would find out from the
|
||||||
|
// money, so say so instead.
|
||||||
|
return { error: "That table was saved by a newer version of this tool. Update, then load it." };
|
||||||
|
}
|
||||||
|
return { table: migrated, savedAt: parsed.savedAt || null };
|
||||||
|
},
|
||||||
|
|
||||||
|
// What the file is called. Dated, because a dealer who saves twice in a night
|
||||||
|
// should not have to guess which is which.
|
||||||
|
backupFileName(now = new Date()) {
|
||||||
|
const pad = n => String(n).padStart(2, "0");
|
||||||
|
return `holdem-table-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
|
||||||
|
`-${pad(now.getHours())}${pad(now.getMinutes())}.json`;
|
||||||
|
},
|
||||||
|
|
||||||
// True once a save has failed. The UI reads this to keep saying so, because a
|
// True once a save has failed. The UI reads this to keep saying so, because a
|
||||||
// toast is a moment and this is a fact about the rest of the night.
|
// toast is a moment and this is a fact about the rest of the night.
|
||||||
saveFailed: false,
|
saveFailed: false,
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// Taking the table off this device.
|
||||||
|
//
|
||||||
|
// A night lives in one browser's localStorage and nowhere else. A dealer whose phone
|
||||||
|
// dies, or who wants to carry on from the tablet after the break, loses the stacks,
|
||||||
|
// the buy-ins, where the button is and the whole hand log — and the CSV export can't
|
||||||
|
// bring any of that back, because it records what happened rather than where the
|
||||||
|
// table stands.
|
||||||
|
const test = require("node:test");
|
||||||
|
const { describe } = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState, Betting, makeTable } = require("./load");
|
||||||
|
|
||||||
|
const aNight = () => {
|
||||||
|
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3], stack: 200, dealerSeatIndex: 1 });
|
||||||
|
["Ann", "Ben", "Cal", "Dee"].forEach((name, i) => {
|
||||||
|
state.seats[i].name = name;
|
||||||
|
state.seats[i].buyIns = 200;
|
||||||
|
});
|
||||||
|
state.seats[2].stack = 340;
|
||||||
|
state.notes = "Cal owes the bar $12";
|
||||||
|
Betting.beginHand(state);
|
||||||
|
HoldemState.recordHand(state, [{ id: state.seats[2].id, label: "Cal", amount: 60 }]);
|
||||||
|
return state;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("a table written out and read back", () => {
|
||||||
|
test("comes back the same table", () => {
|
||||||
|
const before = aNight();
|
||||||
|
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||||
|
|
||||||
|
assert.deepEqual(after.seats.map(p => p.name), before.seats.map(p => p.name));
|
||||||
|
assert.deepEqual(after.seats.map(p => p.stack), before.seats.map(p => p.stack));
|
||||||
|
assert.deepEqual(after.seats.map(p => p.buyIns), before.seats.map(p => p.buyIns));
|
||||||
|
assert.equal(after.dealerSeatIndex, before.dealerSeatIndex);
|
||||||
|
assert.equal(after.handNumber, before.handNumber);
|
||||||
|
assert.equal(after.notes, before.notes);
|
||||||
|
assert.equal(after.handHistory.length, before.handHistory.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps the things a CSV can't carry", () => {
|
||||||
|
// The whole reason this exists: an export records what happened, not where the
|
||||||
|
// table stands.
|
||||||
|
const before = aNight();
|
||||||
|
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||||
|
|
||||||
|
assert.equal(after.bettingOpen, before.bettingOpen, "a hand in progress is still in progress");
|
||||||
|
assert.deepEqual(after.postedBlinds, before.postedBlinds, "so the next rotation is right");
|
||||||
|
assert.equal(after.street, before.street);
|
||||||
|
assert.deepEqual(after.seats.map(p => p.committed), before.seats.map(p => p.committed));
|
||||||
|
assert.deepEqual(after.chipLegend, before.chipLegend);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the standings and the bank check agree across the trip", () => {
|
||||||
|
const before = aNight();
|
||||||
|
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||||
|
|
||||||
|
assert.deepEqual(HoldemState.sessionStandings(after), HoldemState.sessionStandings(before));
|
||||||
|
assert.deepEqual(HoldemState.bankReconciliation(after), HoldemState.bankReconciliation(before));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("it can be dealt from straight away", () => {
|
||||||
|
const before = aNight();
|
||||||
|
HoldemState.startNextHand(before);
|
||||||
|
const after = HoldemState.fromBackup(HoldemState.toBackup(before)).table;
|
||||||
|
|
||||||
|
Betting.beginHand(after);
|
||||||
|
assert.equal(after.bettingOpen, true);
|
||||||
|
assert.ok(after.actingSeatIndex !== null, "somebody is on the clock");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what it refuses", () => {
|
||||||
|
const refuses = (text, because) => {
|
||||||
|
const result = HoldemState.fromBackup(text);
|
||||||
|
assert.ok(result.error, because);
|
||||||
|
assert.equal(result.table, undefined, "and never hands back half a table");
|
||||||
|
};
|
||||||
|
|
||||||
|
test("something that isn't JSON", () => {
|
||||||
|
refuses("not a file", "unreadable");
|
||||||
|
refuses("", "empty");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("JSON that isn't a table", () => {
|
||||||
|
refuses('{"hello":"world"}', "no seats in it");
|
||||||
|
refuses("[1,2,3]", "an array of nothing");
|
||||||
|
refuses("null", "null");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a save from something else that happens to be JSON", () => {
|
||||||
|
refuses('{"kind":"some.other.tool","table":{"seats":[]}}', "wrong kind");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a file from a newer version, rather than guessing at it", () => {
|
||||||
|
// Loading it risks reading a field whose meaning has changed, and the dealer
|
||||||
|
// would find out from the money.
|
||||||
|
const backup = JSON.parse(HoldemState.toBackup(aNight()));
|
||||||
|
backup.version = HoldemState.BACKUP_VERSION + 1;
|
||||||
|
const result = HoldemState.fromBackup(JSON.stringify(backup));
|
||||||
|
assert.match(result.error, /newer version/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what it accepts anyway", () => {
|
||||||
|
test("a bare table, with no envelope around it", () => {
|
||||||
|
// Somebody who copied the storage value out by hand. The envelope is a
|
||||||
|
// convenience, not a password.
|
||||||
|
const state = aNight();
|
||||||
|
const result = HoldemState.fromBackup(JSON.stringify(state));
|
||||||
|
assert.ok(!result.error);
|
||||||
|
assert.deepEqual(result.table.seats.map(p => p.name), state.seats.map(p => p.name));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a table from before fields that exist now, migrated on the way in", () => {
|
||||||
|
// Same repairs a save from an older version gets, because it is the same
|
||||||
|
// function — a second, looser path for imports is how a format grows two
|
||||||
|
// meanings of the same field.
|
||||||
|
const state = aNight();
|
||||||
|
const raw = JSON.parse(JSON.stringify(state));
|
||||||
|
delete raw.deadButton;
|
||||||
|
delete raw.postedBlinds;
|
||||||
|
delete raw.dealtLastHand;
|
||||||
|
delete raw.discarded;
|
||||||
|
raw.seats.forEach(p => { delete p.buyIns; });
|
||||||
|
|
||||||
|
const { table, error } = HoldemState.fromBackup(JSON.stringify({
|
||||||
|
kind: HoldemState.BACKUP_KIND, version: 1, table: raw,
|
||||||
|
}));
|
||||||
|
assert.ok(!error);
|
||||||
|
assert.equal(table.deadButton, false);
|
||||||
|
assert.deepEqual(table.dealtLastHand, []);
|
||||||
|
assert.equal(table.discarded, 0);
|
||||||
|
assert.equal(table.seats[0].buyIns, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the time it was saved comes back with it", () => {
|
||||||
|
const result = HoldemState.fromBackup(HoldemState.toBackup(aNight()));
|
||||||
|
assert.ok(result.savedAt, "so the dealer can tell two files apart");
|
||||||
|
assert.ok(!Number.isNaN(new Date(result.savedAt).getTime()));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the file itself", () => {
|
||||||
|
test("is named with the date and time, so two saves in a night are told apart", () => {
|
||||||
|
const at = new Date(2026, 8, 2, 21, 5);
|
||||||
|
assert.equal(HoldemState.backupFileName(at), "holdem-table-2026-09-02-2105.json");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("says what it is, for anyone finding it in a downloads folder", () => {
|
||||||
|
const parsed = JSON.parse(HoldemState.toBackup(aNight()));
|
||||||
|
assert.equal(parsed.kind, "casino.holdem.table");
|
||||||
|
assert.equal(parsed.version, 1);
|
||||||
|
assert.ok(parsed.table.seats.length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+82
-4
@@ -93,10 +93,44 @@ const LayoutAudit = {
|
|||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The modal that is currently on top, if any.
|
||||||
|
//
|
||||||
|
// Every check below asks "is this thing reachable", and while a sheet or dialog is
|
||||||
|
// open the answer for everything behind it is legitimately no — that is what a
|
||||||
|
// modal is for. Without knowing that, the audit reported the pot wizard covering
|
||||||
|
// the seats as ten faults and the rulings sheet as seven, which is the noise that
|
||||||
|
// made it useless in exactly the states nobody had audited. Now it scopes to what
|
||||||
|
// is actually on top and says so.
|
||||||
|
MODAL_SELECTOR: "#potWizardOverlay, #rulingsOverlay, #seatMenuOverlay, #cardPickerOverlay, " +
|
||||||
|
"#confirmOverlay, #addChipsOverlay, #seatEditorOverlay",
|
||||||
|
|
||||||
|
openModal() {
|
||||||
|
const open = [...document.querySelectorAll(this.MODAL_SELECTOR)]
|
||||||
|
.filter(el => !el.hidden && getComputedStyle(el).display !== "none");
|
||||||
|
// Last in the DOM wins, which is how they stack.
|
||||||
|
return open.length > 0 ? open[open.length - 1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
// A seat sheet on a phone isn't an overlay element, it's a body class — but it
|
||||||
|
// covers the felt just the same.
|
||||||
|
modalScope() {
|
||||||
|
const overlay = this.openModal();
|
||||||
|
if (overlay) return overlay;
|
||||||
|
if (document.body.classList.contains("seatSheetOpen")) {
|
||||||
|
return document.getElementById("seatEditorPanel") || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
occlusion(selector = "#actionBar button, #holdemHeader button, .seat") {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
[...document.querySelectorAll(selector)].forEach(el => {
|
// While something is open on top, only what is inside it can be judged. The
|
||||||
|
// rest of the page is meant to be unreachable.
|
||||||
|
const scope = this.modalScope();
|
||||||
|
const candidates = [...document.querySelectorAll(selector)]
|
||||||
|
.filter(el => !scope || scope.contains(el));
|
||||||
|
candidates.forEach(el => {
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
if (rect.width === 0 || rect.height === 0) return;
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
|
|
||||||
@@ -504,10 +538,25 @@ const LayoutAudit = {
|
|||||||
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
if (!enforceOnPointerDevices && matchMedia("(hover: hover)").matches) {
|
||||||
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
return { name: `touch targets at least ${minimum}px`, offenders, skipped: "pointer device" };
|
||||||
}
|
}
|
||||||
|
const unmeasured = [];
|
||||||
this.visibleControls()
|
this.visibleControls()
|
||||||
.filter(el => el.type !== "color")
|
.filter(el => el.type !== "color")
|
||||||
.filter(el => !(exempt && el.matches(exempt)))
|
.filter(el => !(exempt && el.matches(exempt)))
|
||||||
.forEach(el => {
|
.forEach(el => {
|
||||||
|
// A control inside a scrolling sheet can sit below its container's
|
||||||
|
// visible area while still having a viewport rect. elementFromPoint at
|
||||||
|
// its centre then returns the sheet, effectiveTarget gives up, and the
|
||||||
|
// check used to pass it in silence — which is how "Confirm Winner(s)",
|
||||||
|
// the button that pays the pot out, went unmeasured for 36 rounds.
|
||||||
|
// Bring it into view before probing.
|
||||||
|
const scroller = el.closest("[style*='overflow'], .modalBody, #potWizardBody, #rulingsSheet, #seatEditorPanel");
|
||||||
|
if (scroller && scroller.scrollHeight > scroller.clientHeight) {
|
||||||
|
const box = scroller.getBoundingClientRect();
|
||||||
|
const own = el.getBoundingClientRect();
|
||||||
|
if (own.bottom > box.bottom || own.top < box.top) {
|
||||||
|
el.scrollIntoView({ block: "center" });
|
||||||
|
}
|
||||||
|
}
|
||||||
const rect = el.getBoundingClientRect();
|
const rect = el.getBoundingClientRect();
|
||||||
// Drawn big enough is big enough — no need to probe, and probing wide
|
// Drawn big enough is big enough — no need to probe, and probing wide
|
||||||
// elements is where the cap above used to produce nonsense. Seats sit
|
// elements is where the cap above used to produce nonsense. Seats sit
|
||||||
@@ -515,15 +564,35 @@ const LayoutAudit = {
|
|||||||
// measuring their reach would report the crowding as a fault.
|
// measuring their reach would report the crowding as a fault.
|
||||||
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
if (rect.width >= minimum - 1 && rect.height >= minimum - 1) return;
|
||||||
|
|
||||||
|
// A checkbox inside a label is tapped by tapping the label, so the
|
||||||
|
// label is the target. Measured rather than probed, because probing
|
||||||
|
// fails on anything scrolled off the viewport — which is how a 24px
|
||||||
|
// checkbox with a 335x44 label around it came up as unprobeable
|
||||||
|
// instead of as the perfectly good target it is.
|
||||||
|
const label = el.closest("label");
|
||||||
|
if (label) {
|
||||||
|
const lr = label.getBoundingClientRect();
|
||||||
|
if (lr.width >= minimum - 1 && lr.height >= minimum - 1) return;
|
||||||
|
}
|
||||||
|
|
||||||
const size = this.effectiveTarget(el);
|
const size = this.effectiveTarget(el);
|
||||||
if (!size) return;
|
// Still can't measure it. That is not a pass — it is the check failing
|
||||||
|
// to run, and quietly returning here is exactly the kind of exemption
|
||||||
|
// that hides real faults. Say so instead.
|
||||||
|
if (!size) {
|
||||||
|
unmeasured.push(
|
||||||
|
`${this.describe(el)} :: could not be probed` +
|
||||||
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
if (size.width >= minimum - 1 && size.height >= minimum - 1) return;
|
||||||
offenders.push(
|
offenders.push(
|
||||||
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
`${this.describe(el)} :: hittable ${size.width}x${size.height}` +
|
||||||
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
` (drawn ${Math.round(rect.width)}x${Math.round(rect.height)})`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
return { name: `touch targets at least ${minimum}px`, offenders };
|
return { name: `touch targets at least ${minimum}px`, offenders, unmeasured };
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tapping the middle of a seat must give you that seat.
|
// Tapping the middle of a seat must give you that seat.
|
||||||
@@ -537,6 +606,13 @@ const LayoutAudit = {
|
|||||||
// Reported separately from occlusion because the cause is different: not an
|
// Reported separately from occlusion because the cause is different: not an
|
||||||
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
// overlay on top of the table, but two pieces of the table fighting over a tap.
|
||||||
seatOwnership() {
|
seatOwnership() {
|
||||||
|
// Seats behind an open sheet aren't fighting each other for taps — nothing is
|
||||||
|
// meant to reach them at all. Asking the question there produced six offenders
|
||||||
|
// per modal, every one of them the modal doing its job.
|
||||||
|
if (this.modalScope()) {
|
||||||
|
return { name: "a seat's centre belongs to that seat", offenders: [],
|
||||||
|
skipped: "a sheet is open over the table" };
|
||||||
|
}
|
||||||
const seats = [...document.querySelectorAll(".seat")];
|
const seats = [...document.querySelectorAll(".seat")];
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
|
|
||||||
@@ -617,7 +693,9 @@ const LayoutAudit = {
|
|||||||
viewport: `${innerWidth}x${innerHeight}`,
|
viewport: `${innerWidth}x${innerHeight}`,
|
||||||
failed: checks.filter(c => c.offenders.length > 0),
|
failed: checks.filter(c => c.offenders.length > 0),
|
||||||
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
// Things worth knowing that aren't faults — see occlusion() on toasts.
|
||||||
warnings: checks.flatMap(c => c.warnings || []),
|
// Controls a check couldn't probe go here too: not a fault, but not a pass
|
||||||
|
// either, and a silent skip is how faults hide.
|
||||||
|
warnings: checks.flatMap(c => [...(c.warnings || []), ...(c.unmeasured || [])]),
|
||||||
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
passed: checks.filter(c => c.offenders.length === 0).map(c => c.name),
|
||||||
clean: checks.every(c => c.offenders.length === 0),
|
clean: checks.every(c => c.offenders.length === 0),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user