Files
JoshHeaps.Net/tests/holdem/whatTheFeltIsToldFirst.test.js
jheapsandClaude Opus 5 6a59b0d5ba Stop the table being drawn from a measurement of a bar that isn't there
The felt is sized from --feltRoomPx, which actionBar.js publishes from the
bar's real height, because the bar is fixed to the bottom and grows with the
hand so nothing else can know how much window is left. That measurement is
right. What it was measuring was not.

ActionBar.mount() called watchHeight() before render(), and the bar ships
`<div id="actionBar" hidden>`. 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. Traced on a cold load, nine-handed:

  t=181ms   --actionBarHeight 0px     bar hidden, 0 tall
  t=186ms   --feltRoomPx      721px
  t=1056ms  --actionBarHeight 151px   bar visible, 151 tall — ResizeObserver
  t=1059ms  --feltRoomPx      570px

The observer does come round on its own once `hidden` comes off, but a frame
later at best, and with nine seats to lay out it was 875ms behind. What a
dealer saw at 360x640 was a table drawn 336x504 and collapsing to 336x374
after it was on screen and readable: 130px of felt with every seat on it.
1280x900 lost 13px the same way.

Two rules, separate on purpose. publish() now declines rather than guessing
while the bar is hidden and not a rail — a rail still publishes its honest
zero, because it is a real bar that has moved out of the way. And mount()
publishes once render() has given it something to measure.

That leaves the frame before any of it, which is real: the scripts are at the
end of the body and the table is laid out inside DOMContentLoaded, so the
browser has painted by the time --feltRoomPx exists. The fallback there was
`100vh` — the worst guess available, since it says the whole window is free.
It is now the same guess two other rules in this file already make, 9rem for
the bar plus 7rem for what sits above and below, which lands 10px over the
truth at 360x640; and it is a ceiling, so over is a small shrink rather than a
seat behind the bar.

Measured after, on both phones: one draw, no jump, and a layout-shift score
of 0 where the felt used to move 130px.

1362 tests pass, 8 of them new — 5 fail against the old code. One existing
test pinned the literal fallback string; its claim is about the cap being the
measured room, and it now says that. The sweep is clean across 428 audits;
contrast unchanged.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-15 05:59:54 -06:00

174 lines
8.0 KiB
JavaScript

// 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
// `<div id="actionBar" hidden>` 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`);
});
});