Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0cfd24647 | ||
|
|
95fd46ee99 | ||
|
|
4cc8faf8cb | ||
|
|
cfb83a2ba6 |
@@ -25,7 +25,6 @@
|
|||||||
<section id="tableSection">
|
<section id="tableSection">
|
||||||
<div id="pokerTableWrap">
|
<div id="pokerTableWrap">
|
||||||
<div id="pokerTable">
|
<div id="pokerTable">
|
||||||
<div id="tableWatermark" aria-hidden="true">Texas Hold'em</div>
|
|
||||||
<div id="potBadge">
|
<div id="potBadge">
|
||||||
<span id="potBadgeLabel">Pot</span>
|
<span id="potBadgeLabel">Pot</span>
|
||||||
<span id="potBadgeAmount">$0</span>
|
<span id="potBadgeAmount">$0</span>
|
||||||
|
|||||||
@@ -357,21 +357,18 @@ button,
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tableWatermark {
|
/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
|
||||||
position: absolute;
|
from behind the pot badge in two pieces — measured at every seat count from two to
|
||||||
inset: 0;
|
ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
|
||||||
display: flex;
|
placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
|
||||||
align-items: center;
|
and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
|
||||||
justify-content: center;
|
20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
|
||||||
color: rgba(255, 255, 255, 0.06);
|
fell off the oval entirely at 360px. A square felt with six to nine seats has no
|
||||||
font-size: 2.4rem;
|
clear band wide enough for a word.
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.15em;
|
So it's gone. A watermark that looks broken is worse than no watermark, and the felt
|
||||||
text-transform: uppercase;
|
has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
|
||||||
text-align: center;
|
header saying "Texas Hold'em Dealer" a few inches above it. */
|
||||||
pointer-events: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#potBadge {
|
#potBadge {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -513,13 +510,53 @@ button,
|
|||||||
|
|
||||||
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
|
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
|
||||||
the name has to stay readable rather than fading almost to the felt. */
|
the name has to stay readable rather than fading almost to the felt. */
|
||||||
|
/* Out of the hand, still readable.
|
||||||
|
|
||||||
|
Both of these used to fade the whole seat — opacity 0.5 folded, 0.58 sitting out —
|
||||||
|
and opacity on a box fades the text you still have to read along with the box you
|
||||||
|
wanted to push back. Measured off the rendered pixels at 11.5px text, where AA asks
|
||||||
|
for 4.5:1:
|
||||||
|
|
||||||
|
live name 13.28 stack 4.75
|
||||||
|
folded name 4.75 stack 2.22
|
||||||
|
sat out name 7.00 stack 3.22
|
||||||
|
|
||||||
|
A folded player's stack is not decoration. It's what they'll have next hand and the
|
||||||
|
answer to "how much has Ben got?", and at 2.22:1 it was barely there.
|
||||||
|
|
||||||
|
So the box recedes and the text doesn't: a flatter, darker background and a muted
|
||||||
|
border say "not in this hand", while the name and the stack keep colours that
|
||||||
|
measure. The folded seat keeps its red border and the line through the name, which
|
||||||
|
is the part that actually names the state. */
|
||||||
|
.seat.seatInactive,
|
||||||
|
.seat.seatFolded {
|
||||||
|
background: linear-gradient(160deg, #232323, #171717);
|
||||||
|
border-color: rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A folded seat already says so twice — a red border and a line through the name — but
|
||||||
|
a seat that's sitting out said it only by being faint, and taking the faintness away
|
||||||
|
took the whole signal with it. A dashed edge is the same thing an empty chair wears,
|
||||||
|
which is right: neither is in this hand. The name and the stack tell them apart. */
|
||||||
.seat.seatInactive {
|
.seat.seatInactive {
|
||||||
opacity: 0.58;
|
border-style: dashed;
|
||||||
|
border-color: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat.seatInactive .seatPuckName,
|
||||||
|
.seat.seatFolded .seatPuckName {
|
||||||
|
color: #b0b0b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seat.seatInactive .seatPuckStack,
|
||||||
|
.seat.seatFolded .seatPuckStack,
|
||||||
|
.seat.seatInactive .seatPuckTotal,
|
||||||
|
.seat.seatFolded .seatPuckTotal {
|
||||||
|
color: #8f8f8f;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat.seatFolded {
|
.seat.seatFolded {
|
||||||
border-color: rgba(224, 123, 123, 0.4);
|
border-color: rgba(224, 123, 123, 0.4);
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.seat.seatFolded .seatPuckName {
|
.seat.seatFolded .seatPuckName {
|
||||||
@@ -2172,11 +2209,12 @@ body.hasActionBar #holdemSidebar {
|
|||||||
|
|
||||||
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
|
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
|
||||||
.actionUndoKey {
|
.actionUndoKey {
|
||||||
|
/* See .actionKey below for why these are not fainter. */
|
||||||
margin-left: 0.4rem;
|
margin-left: 0.4rem;
|
||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
opacity: 0.55;
|
opacity: 0.75;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -2184,6 +2222,10 @@ body.hasActionBar #holdemSidebar {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A key hint is small — 9.6px — so AA asks 4.5:1 of it, and at the opacity these used
|
||||||
|
to carry they measured 2.97 to 3.33 against their buttons. That's the same fault as
|
||||||
|
the folded seats above: a fade chosen for looks pushing text a dealer is meant to
|
||||||
|
read under the threshold. They're still quieter than the label they sit beside. */
|
||||||
.actionKey {
|
.actionKey {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
@@ -2191,7 +2233,7 @@ body.hasActionBar #holdemSidebar {
|
|||||||
padding: 0 0.25rem;
|
padding: 0 0.25rem;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid currentColor;
|
border: 1px solid currentColor;
|
||||||
opacity: 0.4;
|
opacity: 0.75;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
@@ -2845,10 +2887,6 @@ body.hasActionBar #toastHost {
|
|||||||
padding: 0.25rem 0.3rem;
|
padding: 0.25rem 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
#tableWatermark {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#potBadge {
|
#potBadge {
|
||||||
padding: 0.5rem 1.1rem;
|
padding: 0.5rem 1.1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,6 +461,17 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// The one time the browser refuses to save, say so out loud as well as leaving the
|
// The one time the browser refuses to save, say so out loud as well as leaving the
|
||||||
// standing warning in the Session panel. Set before the first render so a failure
|
// standing warning in the Session panel. Set before the first render so a failure
|
||||||
// during start-up is caught too.
|
// during start-up is caught too.
|
||||||
|
// Another tab writing to the same table. Said once, with the way out attached —
|
||||||
|
// reloading here adopts whatever that tab has done, which is the only resolution
|
||||||
|
// that doesn't throw somebody's work away.
|
||||||
|
HoldemState.onOtherTab = () => {
|
||||||
|
Toast.show(
|
||||||
|
"This table is open in another tab. Whichever acts last wins.",
|
||||||
|
{ actionLabel: "Reload", onAction: () => window.location.reload(), duration: 20000 });
|
||||||
|
Render.renderStorageWarning();
|
||||||
|
};
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
HoldemState.onSaveFailed = () => {
|
HoldemState.onSaveFailed = () => {
|
||||||
Toast.show(
|
Toast.show(
|
||||||
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
|
"This table isn't being saved — refreshing would lose it. Keep this tab open.",
|
||||||
@@ -571,4 +582,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
if ((state.handHistory || []).length === 0) return;
|
if ((state.handHistory || []).length === 0) return;
|
||||||
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
HandHistoryExport.download(state.handHistory, HoldemState.sessionStandings(state));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The load happens on the first line of this file, long before there's a Toast to
|
||||||
|
// show it in, so this is checked here rather than wired as a callback. The standing
|
||||||
|
// warning in the Session panel doesn't need it — that reads the flag every render —
|
||||||
|
// but an empty table is a thing a dealer needs told, not left to notice.
|
||||||
|
if (HoldemState.loadFailed) {
|
||||||
|
Toast.show(
|
||||||
|
"The saved table couldn't be read, so this one started empty.",
|
||||||
|
{ duration: 12000 });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -761,11 +761,31 @@ const Render = {
|
|||||||
renderStorageWarning() {
|
renderStorageWarning() {
|
||||||
const host = document.getElementById("storageWarning");
|
const host = document.getElementById("storageWarning");
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
host.hidden = !HoldemState.saveFailed;
|
host.hidden = !HoldemState.saveFailed && !HoldemState.loadFailed && !HoldemState.otherTabActive;
|
||||||
if (host.hidden) return;
|
if (host.hidden) return;
|
||||||
host.textContent =
|
|
||||||
"This table isn't being saved — the browser refused. It'll keep working, " +
|
// Any of these can be true at once — a browser having trouble with storage
|
||||||
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.";
|
// tends to have it in both directions — and the one that explains an empty
|
||||||
|
// table goes first, because that's the question the dealer is standing there
|
||||||
|
// asking.
|
||||||
|
const lines = [];
|
||||||
|
if (HoldemState.otherTabActive) {
|
||||||
|
lines.push(
|
||||||
|
"This table is open in another tab, and both are writing to it — " +
|
||||||
|
"whichever acts last wins, so work done in the other one can vanish. " +
|
||||||
|
"Close one, then reload this tab to pick up the table as it stands.");
|
||||||
|
}
|
||||||
|
if (HoldemState.loadFailed) {
|
||||||
|
lines.push(
|
||||||
|
"The saved table couldn't be read, so this one started empty. " +
|
||||||
|
"The unreadable copy has been kept rather than written over.");
|
||||||
|
}
|
||||||
|
if (HoldemState.saveFailed) {
|
||||||
|
lines.push(
|
||||||
|
"This table isn't being saved — the browser refused. It'll keep working, " +
|
||||||
|
"but closing or refreshing this tab loses the session. Export CSV below keeps a copy.");
|
||||||
|
}
|
||||||
|
host.textContent = lines.join(" ");
|
||||||
},
|
},
|
||||||
|
|
||||||
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
// The last step the standings stopped short of. Six signed numbers at 2am is not
|
||||||
|
|||||||
@@ -81,12 +81,69 @@ const HoldemState = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Where an unreadable save is kept instead of being written over.
|
||||||
|
RECOVERY_KEY: "casino.holdem.state.v1.unreadable",
|
||||||
|
|
||||||
|
// True once another tab has written to this table. Two tabs each hold their own
|
||||||
|
// copy in memory and each save writes the whole thing, so the second one to act
|
||||||
|
// silently throws the first one's work away — measured: tab A deals and takes a
|
||||||
|
// call, tab B renames a seat, and the deal, the blinds and the call are gone with
|
||||||
|
// nothing said in either tab.
|
||||||
|
//
|
||||||
|
// Watched rather than merged. Replacing the table under a dealer's hands mid-hand
|
||||||
|
// would be its own hazard; knowing there are two of them is what they need.
|
||||||
|
otherTabActive: false,
|
||||||
|
onOtherTab: null,
|
||||||
|
|
||||||
|
// The browser fires this in every *other* tab, never the one that wrote, so our
|
||||||
|
// own saves can't trip it.
|
||||||
|
watchOtherTabs() {
|
||||||
|
if (typeof window === "undefined" || this.tabWatcher) return;
|
||||||
|
this.tabWatcher = event => {
|
||||||
|
if (event.key !== this.STORAGE_KEY) return;
|
||||||
|
if (this.otherTabActive) return;
|
||||||
|
this.otherTabActive = true;
|
||||||
|
if (typeof this.onOtherTab === "function") this.onOtherTab();
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", this.tabWatcher);
|
||||||
|
},
|
||||||
|
|
||||||
|
// True when there was a saved table and it couldn't be read. Same pair as
|
||||||
|
// saveFailed above, for the same reason: the UI has to be able to say so.
|
||||||
|
loadFailed: false,
|
||||||
|
onLoadFailed: null,
|
||||||
|
|
||||||
|
// A saved table that won't parse becomes an empty table, which is the only thing
|
||||||
|
// it can become — but it used to become one silently, and then the first edit
|
||||||
|
// saved over the top of it. A dealer mid-session saw an empty felt with no idea
|
||||||
|
// whether they'd mis-tapped Reset or the browser had lost the night, and whatever
|
||||||
|
// a person might have picked out of the raw JSON was gone a tap later.
|
||||||
|
//
|
||||||
|
// So the unreadable value is moved aside first. It is not offered as a restore —
|
||||||
|
// half a JSON document isn't a table, and pretending otherwise would be worse than
|
||||||
|
// an empty one — but it stops being destroyed, and somebody who wants the
|
||||||
|
// standings out of it still can.
|
||||||
|
failedLoad(raw) {
|
||||||
|
if (!this.loadFailed) {
|
||||||
|
this.loadFailed = true;
|
||||||
|
try {
|
||||||
|
if (raw) localStorage.setItem(this.RECOVERY_KEY, raw);
|
||||||
|
} catch {
|
||||||
|
// No room to keep it. Nothing more to try, and the warning below is
|
||||||
|
// the part that actually matters.
|
||||||
|
}
|
||||||
|
if (typeof this.onLoadFailed === "function") this.onLoadFailed();
|
||||||
|
}
|
||||||
|
return this.createDefault();
|
||||||
|
},
|
||||||
|
|
||||||
load() {
|
load() {
|
||||||
|
let raw = null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(this.STORAGE_KEY);
|
raw = localStorage.getItem(this.STORAGE_KEY);
|
||||||
if (!raw) return this.createDefault();
|
if (!raw) return this.createDefault();
|
||||||
const parsed = JSON.parse(raw);
|
const parsed = JSON.parse(raw);
|
||||||
if (!parsed || !Array.isArray(parsed.seats)) return this.createDefault();
|
if (!parsed || !Array.isArray(parsed.seats)) return this.failedLoad(raw);
|
||||||
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 = [];
|
||||||
|
|
||||||
@@ -118,7 +175,8 @@ const HoldemState = {
|
|||||||
|
|
||||||
return parsed;
|
return parsed;
|
||||||
} catch {
|
} catch {
|
||||||
return this.createDefault();
|
// Malformed JSON — a truncated write, another tab, a browser hiccup.
|
||||||
|
return this.failedLoad(raw);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// What happens when the saved table can't be read.
|
||||||
|
//
|
||||||
|
// An unreadable save can only become an empty table. What it must not do is become
|
||||||
|
// one silently and then get written over by the first edit.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState } = require("./load");
|
||||||
|
|
||||||
|
function withStored(value, { readOnly = false } = {}) {
|
||||||
|
const store = new Map();
|
||||||
|
if (value !== undefined) store.set(HoldemState.STORAGE_KEY, value);
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem: key => (store.has(key) ? store.get(key) : null),
|
||||||
|
setItem(key, val) {
|
||||||
|
if (readOnly) throw new Error("QuotaExceededError");
|
||||||
|
store.set(key, val);
|
||||||
|
},
|
||||||
|
removeItem: key => store.delete(key),
|
||||||
|
};
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
HoldemState.loadFailed = false;
|
||||||
|
HoldemState.onLoadFailed = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
delete globalThis.localStorage;
|
||||||
|
HoldemState.loadFailed = false;
|
||||||
|
HoldemState.onLoadFailed = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nothing stored is a new table, not a failure", () => {
|
||||||
|
withStored(undefined);
|
||||||
|
const state = HoldemState.load();
|
||||||
|
|
||||||
|
assert.equal(HoldemState.loadFailed, false);
|
||||||
|
assert.ok(Array.isArray(state.seats));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a good save loads and reports nothing", () => {
|
||||||
|
const store = withStored(undefined);
|
||||||
|
const original = HoldemState.createDefault();
|
||||||
|
original.handNumber = 34;
|
||||||
|
HoldemState.save(original);
|
||||||
|
|
||||||
|
const state = HoldemState.load();
|
||||||
|
assert.equal(HoldemState.loadFailed, false);
|
||||||
|
assert.equal(state.handNumber, 34);
|
||||||
|
assert.equal(store.has(HoldemState.RECOVERY_KEY), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a truncated save is reported rather than silently becoming an empty table", () => {
|
||||||
|
const good = JSON.stringify(HoldemState.createDefault());
|
||||||
|
withStored(good.slice(0, Math.floor(good.length * 0.7)));
|
||||||
|
|
||||||
|
const state = HoldemState.load();
|
||||||
|
assert.equal(HoldemState.loadFailed, true);
|
||||||
|
assert.ok(Array.isArray(state.seats), "it still hands back a usable table");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("valid JSON of the wrong shape counts as unreadable too", () => {
|
||||||
|
withStored(JSON.stringify({ hello: "world" }));
|
||||||
|
|
||||||
|
HoldemState.load();
|
||||||
|
assert.equal(HoldemState.loadFailed, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the unreadable copy is kept instead of being written over", () => {
|
||||||
|
// This is the half that matters: whatever a person could have picked out of the
|
||||||
|
// raw JSON used to be gone the moment the dealer touched a seat.
|
||||||
|
const broken = '{"seats":[{"name":"Ann","stack":250},{"name":"Ben"';
|
||||||
|
const store = withStored(broken);
|
||||||
|
|
||||||
|
HoldemState.load();
|
||||||
|
|
||||||
|
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a later save doesn't touch the copy that was set aside", () => {
|
||||||
|
const broken = '{"seats":[{"name":"Ann"';
|
||||||
|
const store = withStored(broken);
|
||||||
|
|
||||||
|
const state = HoldemState.load();
|
||||||
|
state.handNumber = 2;
|
||||||
|
HoldemState.save(state);
|
||||||
|
|
||||||
|
assert.equal(store.get(HoldemState.RECOVERY_KEY), broken);
|
||||||
|
assert.notEqual(store.get(HoldemState.STORAGE_KEY), broken);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the listener fires once, not on every read", () => {
|
||||||
|
withStored("{not json");
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onLoadFailed = () => { told++; };
|
||||||
|
|
||||||
|
HoldemState.load();
|
||||||
|
HoldemState.load();
|
||||||
|
HoldemState.load();
|
||||||
|
|
||||||
|
assert.equal(told, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no room to keep the copy is not a crash", () => {
|
||||||
|
// Storage that refuses writes is exactly the storage most likely to hand back a
|
||||||
|
// half-written value in the first place.
|
||||||
|
withStored("{not json", { readOnly: true });
|
||||||
|
|
||||||
|
let state;
|
||||||
|
assert.doesNotThrow(() => { state = HoldemState.load(); });
|
||||||
|
assert.ok(Array.isArray(state.seats));
|
||||||
|
assert.equal(HoldemState.loadFailed, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Noticing that the same table is open twice.
|
||||||
|
//
|
||||||
|
// Two tabs each hold their own copy in memory and each save writes the whole thing,
|
||||||
|
// so the second one to act throws the first one's work away. This doesn't merge them
|
||||||
|
// — replacing a table under a dealer's hands mid-hand would be its own hazard — it
|
||||||
|
// notices, which is what lets them decide.
|
||||||
|
const test = require("node:test");
|
||||||
|
const assert = require("node:assert");
|
||||||
|
const { HoldemState } = require("./load");
|
||||||
|
|
||||||
|
function withWindow() {
|
||||||
|
const listeners = [];
|
||||||
|
globalThis.window = {
|
||||||
|
addEventListener(type, fn) { if (type === "storage") listeners.push(fn); },
|
||||||
|
removeEventListener(type, fn) {
|
||||||
|
const i = listeners.indexOf(fn);
|
||||||
|
if (i >= 0) listeners.splice(i, 1);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
listeners,
|
||||||
|
fire(key) { listeners.forEach(fn => fn({ key })); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test.beforeEach(() => {
|
||||||
|
HoldemState.otherTabActive = false;
|
||||||
|
HoldemState.onOtherTab = null;
|
||||||
|
HoldemState.tabWatcher = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test.after(() => {
|
||||||
|
delete globalThis.window;
|
||||||
|
HoldemState.otherTabActive = false;
|
||||||
|
HoldemState.onOtherTab = null;
|
||||||
|
HoldemState.tabWatcher = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a write to this table from elsewhere is noticed", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onOtherTab = () => { told++; };
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
win.fire(HoldemState.STORAGE_KEY);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.otherTabActive, true);
|
||||||
|
assert.equal(told, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("somebody else's storage key is not our business", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onOtherTab = () => { told++; };
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
win.fire("some.other.app.state");
|
||||||
|
win.fire(HoldemState.RECOVERY_KEY);
|
||||||
|
|
||||||
|
assert.equal(HoldemState.otherTabActive, false);
|
||||||
|
assert.equal(told, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("told once, however many times the other tab acts", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
let told = 0;
|
||||||
|
HoldemState.onOtherTab = () => { told++; };
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) win.fire(HoldemState.STORAGE_KEY);
|
||||||
|
|
||||||
|
assert.equal(told, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("watching twice doesn't listen twice", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
assert.equal(win.listeners.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no listener set is not an error", () => {
|
||||||
|
const win = withWindow();
|
||||||
|
HoldemState.onOtherTab = null;
|
||||||
|
HoldemState.watchOtherTabs();
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => win.fire(HoldemState.STORAGE_KEY));
|
||||||
|
assert.equal(HoldemState.otherTabActive, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no window at all is not an error", () => {
|
||||||
|
// The same modules are loaded in Node by the test harness.
|
||||||
|
delete globalThis.window;
|
||||||
|
HoldemState.tabWatcher = null;
|
||||||
|
|
||||||
|
assert.doesNotThrow(() => HoldemState.watchOtherTabs());
|
||||||
|
assert.equal(HoldemState.otherTabActive, false);
|
||||||
|
});
|
||||||
+31
-1
@@ -180,6 +180,12 @@ const LayoutAudit = {
|
|||||||
collapsedColumns() {
|
collapsedColumns() {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
[...document.querySelectorAll("[class*='Row'], [class*='row']")].forEach(row => {
|
||||||
|
// Nothing inside something the page has hidden. A hidden element's children
|
||||||
|
// all measure zero wide, so the standings header — which is hidden along
|
||||||
|
// with its table until somebody has bought in — was reported as three
|
||||||
|
// collapsed columns on every empty table. A column nobody can see hasn't
|
||||||
|
// collapsed; it isn't there.
|
||||||
|
if (row.closest("[hidden]")) return;
|
||||||
if (getComputedStyle(row).display !== "grid") return;
|
if (getComputedStyle(row).display !== "grid") return;
|
||||||
[...row.children].forEach(cell => {
|
[...row.children].forEach(cell => {
|
||||||
if (getComputedStyle(cell).display === "none") return;
|
if (getComputedStyle(cell).display === "none") return;
|
||||||
@@ -343,6 +349,16 @@ const LayoutAudit = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Every element between this one and the document, so an opacity set on a
|
||||||
|
// container can be seen from the text inside it.
|
||||||
|
*ancestors(el) {
|
||||||
|
let node = el.parentElement;
|
||||||
|
while (node) {
|
||||||
|
yield node;
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
contrast({ minimum = 4.5, largeMinimum = 3, selector = null } = {}) {
|
||||||
const offenders = [];
|
const offenders = [];
|
||||||
const unmeasured = [];
|
const unmeasured = [];
|
||||||
@@ -359,7 +375,21 @@ const LayoutAudit = {
|
|||||||
if (rect.width === 0 || rect.height === 0) return;
|
if (rect.width === 0 || rect.height === 0) return;
|
||||||
const style = getComputedStyle(el);
|
const style = getComputedStyle(el);
|
||||||
if (style.visibility === "hidden" || style.display === "none") return;
|
if (style.visibility === "hidden" || style.display === "none") return;
|
||||||
if (parseFloat(style.opacity) < 0.99) return; // faded on purpose
|
// Faded text used to be skipped outright as "faded on purpose", and that
|
||||||
|
// exemption hid a real fault: the folded and sat-out seats dimmed their
|
||||||
|
// whole box with opacity, so a folded player's stack measured 2.22:1 at
|
||||||
|
// 11.5px and this check never looked at it. Something invisible is still
|
||||||
|
// exempt — there's nothing to read at zero — but anything in between is
|
||||||
|
// reported as unmeasured rather than passed over in silence, because a
|
||||||
|
// number nobody checked is not the same as a number that's fine.
|
||||||
|
const opacity = parseFloat(style.opacity);
|
||||||
|
if (opacity <= 0.02) return;
|
||||||
|
const faded = opacity < 0.99 ||
|
||||||
|
[...this.ancestors(el)].some(a => parseFloat(getComputedStyle(a).opacity) < 0.99);
|
||||||
|
if (faded) {
|
||||||
|
unmeasured.push(`${this.describe(el)} :: faded (opacity ${opacity}), contrast not checked`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Decoration is exempt by definition: the watermark on the felt is meant
|
// Decoration is exempt by definition: the watermark on the felt is meant
|
||||||
// to be barely there, and holding it to a reading threshold would mean
|
// to be barely there, and holding it to a reading threshold would mean
|
||||||
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
// making it not a watermark. Marked aria-hidden so screen readers skip it
|
||||||
|
|||||||
Reference in New Issue
Block a user