diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
index 5bf9e03..9708bf0 100644
--- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css
+++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css
@@ -5199,10 +5199,23 @@ body.hasActionBar #toastHost {
the worst any of these phones actually publishes is 142, which lays out fine.
Landscape overrides all of this outright, further down the file. */
+ /* The fallback is what the felt is drawn at before actionBar.js has measured
+ anything, which is a real frame a dealer sees — the scripts run at the end of the
+ body and the table is laid out inside main.js's DOMContentLoaded, so the browser
+ has already painted by the time --feltRoomPx exists.
+
+ `100vh` was the worst guess available: it says the whole window is free. Measured
+ at 360x640 nine-handed, the felt came up 336x504 and dropped to 336x374 as soon as
+ the bar was measured — 130px of table, with every seat on it, moving after it was
+ readable. This is a guess too, but a near one: 9rem is what two other rules in
+ this file already assume a bar costs before it can be asked, and 7rem is what sits
+ above and below the felt. It lands 10px over the truth at 360x640, and since this
+ is a ceiling rather than a height, over is a small shrink rather than a seat
+ behind the bar. Anything measured replaces it on the next frame. */
#pokerTableWrap {
--feltAspect: 1.5;
max-width: none;
- max-height: var(--feltRoomPx, 100vh);
+ max-height: var(--feltRoomPx, calc(100vh - var(--actionBarHeight, 9rem) - 7rem));
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
index 02d7e2c..d57838f 100644
--- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
+++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js
@@ -16,8 +16,14 @@ const ActionBar = {
state.actingSeatIndex !== null && state.actingSeatIndex !== undefined
? state.actingSeatIndex
: null;
+ // watchHeight first, because render() may reach for publishHeight; then publish
+ // once there is a bar to measure. The ResizeObserver does come round on its own
+ // when the markup's `hidden` comes off — but it arrives a frame later at best,
+ // and measured on a cold load with nine seats to lay out it was 875ms behind.
+ // The felt is drawn inside that gap.
this.watchHeight();
this.render();
+ this.publishHeight();
},
// The bar is fixed to the bottom of the screen, so everything else has to know how
@@ -31,13 +37,29 @@ const ActionBar = {
if (!host || this.heightWatcher) return;
const publish = () => {
- const visible = !host.hidden;
const rail = this.isRail(host);
+ // A bar nobody has drawn yet is not a bar of zero height. It is a bar whose
+ // height nobody knows, and saying zero is answering the question wrongly
+ // rather than declining it: the felt reads this to decide how tall to be,
+ // and zero tells it the whole window is free.
+ //
+ // It is the state this bar starts in — `
` in the
+ // markup, taken out by the first render() — and watchHeight() used to
+ // publish from inside that window, because mount() called it before render.
+ // Measured at 360x640 nine-handed: the table came up 336x504 against a
+ // published 525px of room, then collapsed to 336x374 sixty milliseconds
+ // later when the real 151px bar was finally measured. 1280x900 lost 13px
+ // the same way. A table that is drawn, read, and then redrawn at a
+ // different size is the layout shift this measurement exists to prevent.
+ //
+ // A rail is different, and still publishes: it is a real bar that genuinely
+ // takes none of the bottom, because it is off to the side.
+ if (host.hidden && !rail) return;
// On a phone held sideways the bar is a rail down the right-hand side, so it
// takes no height at all — and `--actionBarHeight` is what four other rules
// and the felt measurement all mean by "how much of the bottom is spoken
// for". Publishing the rail's height there would reserve the whole window.
- const height = visible && !rail ? host.getBoundingClientRect().height : 0;
+ const height = rail ? 0 : host.getBoundingClientRect().height;
document.documentElement.style.setProperty("--actionBarHeight", `${Math.round(height)}px`);
// What is under the felt is the bar in one layout and the two links in the
// other. Either way it is the same question — how much of the bottom is
diff --git a/tests/holdem/feltShape.test.js b/tests/holdem/feltShape.test.js
index d9c9bf9..a1bfc81 100644
--- a/tests/holdem/feltShape.test.js
+++ b/tests/holdem/feltShape.test.js
@@ -140,7 +140,18 @@ describe("what stops the oval", () => {
// The oval is a preference and the room is a fact. Whatever shape is asked for
// above, the felt gives way to the measurement rather than the other way round,
// because the thing on the other side of the cap is a seat behind the action bar.
- assert.equal(phoneRule, "var(--feltRoomPx, 100vh)");
+ assert.match(phoneRule, /^var\(--feltRoomPx,/,
+ "something other than the measured room decides how tall the felt may be");
+ });
+
+ test("and what it falls back on is a near guess rather than the whole window", () => {
+ // The fallback is only ever used for the frame before actionBar.js has measured
+ // anything — but that frame is one a dealer sees, because the scripts run at the
+ // end of the body. `100vh` was what stood here, and it says the whole window is
+ // free: measured at 360x640 nine-handed, the table came up 336x504 and dropped to
+ // 336x374 as soon as the bar was measured. See whatTheFeltIsToldFirst.
+ assert.doesNotMatch(phoneRule, /var\(--feltRoomPx,\s*100vh\)/);
+ assert.match(phoneRule, /var\(--actionBarHeight, 9rem\)/);
});
test("and it is not floored, however tempting that is", () => {
diff --git a/tests/holdem/whatTheFeltIsToldFirst.test.js b/tests/holdem/whatTheFeltIsToldFirst.test.js
new file mode 100644
index 0000000..5c20134
--- /dev/null
+++ b/tests/holdem/whatTheFeltIsToldFirst.test.js
@@ -0,0 +1,173 @@
+// The size the table is drawn at before anything has been measured.
+//
+// The felt is not sized by CSS alone. It is sized from `--feltRoomPx`, which actionBar.js
+// publishes from the bar's real height — because the bar is fixed to the bottom of the
+// screen and grows with the hand, so nothing else can know how much of the window is
+// left. That measurement is right. What it was measuring was not.
+//
+// `ActionBar.mount()` called `watchHeight()` before `render()`, and the bar ships
+// `
` in the markup. So the first publish measured a bar that
+// was not there yet and published `0px` — not "I don't know", but "the bar takes none of
+// the window", which is the one answer that makes the felt draw itself too big.
+//
+// Measured on a cold load, nine-handed:
+//
+// 360x640 --feltRoomPx 525px -> felt 336x504, then 336x374 at t=300ms
+// 1280x900 --feltRoomPx 755px -> felt 857x617, then 839x604
+//
+// A hundred and thirty pixels of table, with every seat on it, moving after it was on
+// screen and readable. The ResizeObserver does come round on its own once the markup's
+// `hidden` comes off — but a frame later at best, and measured with nine seats to lay
+// out it was 875ms behind.
+//
+// Two rules hold it shut, and they are separate on purpose: one stops the wrong number
+// being published at all, the other makes sure a right one is published straight away.
+const test = require("node:test");
+const { describe } = require("node:test");
+const assert = require("node:assert");
+const fs = require("fs");
+const path = require("path");
+const { ActionBar } = require("./load");
+
+const SRC = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "js", "casino", "holdem");
+const BAR = fs.readFileSync(path.join(SRC, "actionBar.js"), "utf8");
+const CSS = fs.readFileSync(
+ path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
+
+// A document with one action bar in it, and a note of everything published.
+const pageWith = ({ hidden, height, layout }) => {
+ const published = [];
+ const host = {
+ hidden,
+ getBoundingClientRect: () => ({ height, top: 0 }),
+ };
+ global.document = {
+ getElementById: id => (id === "actionBar" ? host : null),
+ documentElement: {
+ style: {
+ setProperty: (name, value) => published.push([name, value]),
+ getPropertyValue: () => "",
+ },
+ },
+ };
+ global.getComputedStyle = () => ({ getPropertyValue: () => layout });
+ global.window = { innerHeight: 844, scrollY: 0, addEventListener: () => {} };
+ return { host, published, barHeight: () => published.filter(p => p[0] === "--actionBarHeight") };
+};
+
+// watchHeight wires listeners and publishes once. The felt half of it is stubbed: it
+// reads a wrap that is not in this document, and what is under test here is the number
+// the bar decides to hand over.
+const publishOnce = page => {
+ const realFelt = ActionBar.publishFeltRoom;
+ const roomAsked = [];
+ ActionBar.publishFeltRoom = value => roomAsked.push(value);
+ ActionBar.heightWatcher = null;
+ try {
+ ActionBar.watchHeight();
+ } finally {
+ ActionBar.publishFeltRoom = realFelt;
+ }
+ return roomAsked;
+};
+
+test.afterEach(() => {
+ delete global.document;
+ delete global.getComputedStyle;
+ delete global.window;
+ ActionBar.heightWatcher = null;
+ delete ActionBar.publishHeight;
+});
+
+// ---------------------------------------------------------------------------
+
+describe("a bar nobody has drawn yet", () => {
+ test("says nothing, rather than saying nothing is there", () => {
+ // The distinction the whole fix rests on. Zero is an answer; "not yet" is not,
+ // and publishing the first as though it were the second is what made the felt
+ // believe it had the whole window.
+ const page = pageWith({ hidden: true, height: 0, layout: "bar" });
+ publishOnce(page);
+
+ assert.deepEqual(page.barHeight(), [],
+ "a hidden bar published a height, and the felt will be drawn against it");
+ });
+
+ test("and the felt is not asked to resize on the strength of it either", () => {
+ const page = pageWith({ hidden: true, height: 0, layout: "bar" });
+ const roomAsked = publishOnce(page);
+
+ assert.deepEqual(roomAsked, [], "the felt was handed a number measured off nothing");
+ });
+
+ test("a bar that has been drawn publishes what it measures", () => {
+ const page = pageWith({ hidden: false, height: 151, layout: "bar" });
+ publishOnce(page);
+
+ assert.deepEqual(page.barHeight(), [["--actionBarHeight", "151px"]]);
+ });
+});
+
+describe("a rail is not an undrawn bar", () => {
+ // A phone held sideways puts the bar down the right-hand side, where it genuinely
+ // takes none of the bottom of the window. That zero is a measurement and has to keep
+ // being published — otherwise the felt goes on reserving room for a bar that has
+ // moved out of its way.
+ test("it publishes zero, and it does so even while hidden", () => {
+ const page = pageWith({ hidden: true, height: 0, layout: "rail" });
+ publishOnce(page);
+
+ assert.deepEqual(page.barHeight(), [["--actionBarHeight", "0px"]],
+ "a rail stopped saying it takes no height");
+ });
+
+ test("and a drawn rail publishes zero rather than its own height", () => {
+ const page = pageWith({ hidden: false, height: 390, layout: "rail" });
+ publishOnce(page);
+
+ assert.deepEqual(page.barHeight(), [["--actionBarHeight", "0px"]],
+ "the rail's height went into the number that means 'how much of the bottom is gone'");
+ });
+});
+
+describe("and something is published as soon as there is a bar to measure", () => {
+ test("mount publishes after it has rendered, not only before", () => {
+ // watchHeight still goes first, because render() may reach for publishHeight.
+ // The publish that matters is the one after.
+ const mount = BAR.slice(BAR.indexOf("mount(state, hooks) {"), BAR.indexOf("watchHeight() {"));
+ const watch = mount.indexOf("this.watchHeight();");
+ const render = mount.indexOf("this.render();");
+ const publish = mount.indexOf("this.publishHeight();");
+
+ assert.ok(watch >= 0 && render >= 0 && publish >= 0,
+ "mount no longer does all three of these");
+ assert.ok(watch < render, "watchHeight has to be in place before render can call it");
+ assert.ok(render < publish,
+ "mount publishes before the bar has been drawn, which is the whole bug");
+ });
+});
+
+describe("what the felt falls back on before any of that", () => {
+ // The scripts are at the end of the body and the table is laid out inside main.js's
+ // DOMContentLoaded, so there is a real frame where --feltRoomPx does not exist yet.
+ // `100vh` was the worst guess available for it: it says the whole window is free.
+ test("a phone does not assume the whole window", () => {
+ // The phone's rule is the one that caps the felt's height; the wide layout caps
+ // its width instead, from the same number.
+ const found = CSS.match(/max-height:\s*var\(--feltRoomPx,([^)]*\)?[^;]*);/);
+ assert.ok(found, "nothing caps the felt's height by the measured room any more");
+ const fallback = found[1].trim();
+ assert.notEqual(fallback, "100vh",
+ "the fallback claims the whole window, so the first frame draws a table that has to shrink");
+ assert.match(fallback, /var\(--actionBarHeight, 9rem\)/,
+ "9rem is what the rest of this file assumes a bar costs before it can be asked");
+ });
+
+ test("and 9rem is a number this file already stands behind", () => {
+ // Two other rules had reached the same conclusion before this one did. If they
+ // ever disagree, the felt's first frame and the toast's first frame disagree.
+ const nines = CSS.match(/var\(--actionBarHeight, 9rem\)/g) || [];
+ assert.ok(nines.length >= 3,
+ `only ${nines.length} rules assume 9rem; the felt's guess should be the same guess as the rest`);
+ });
+});