Say on each shut sidebar panel the one thing you'd open it for

The sidebar is eight <details>, and every one measured exactly 60px shut with
nothing on it but its own label — 480px of a phone's screen, and 480px of a 660px
desktop column, spent saying "Standings", "Hands", "Waiting list". Whether the box
balances, whether anyone is waiting for a seat, how many hands have been played:
the page had all three worked out already, and charged a tap for each of them, on
rows two thirds of which were empty.

That is the disclosure pattern's named failure — a header you have to open to find
out whether it was worth opening — and the answer the design systems give is the
same one: put the status on the header. The panel keeps the sentence. "Off by
$300" is the alarm; which seat is holding chips nobody wrote down is a paragraph,
and that is the half that belongs behind the tap.

Two rules, and panelGlance.test.js checks both. A glance appears only when it has
something a dealer would act on — eight rows that always say something are eight
rows that say nothing again, and a row reading "Nobody waiting" all night is the
clutter this went in to remove. And a glance never says something the panel under
it would contradict: it is read off the same state, never off the panel's DOM,
which for a shut <details> isn't rendered at all.

Absolutely positioned rather than floated. A <summary> only draws its disclosure
triangle while it is display: list-item, so it cannot be a flex row; the float
that replaced it kept the row at 60px but aligned to the label's line box, and a
12px string in a box sized for 15px text rode 2.5px low on all six panels. Out of
flow is also the smaller promise — an absolutely positioned box cannot make its
row taller at all.

Three paths deliberately skip renderAll, because it would take the field out from
under whoever is typing in it, and all three move something a glance reports. The
notes textarea, the chip legend's value fields and commitSeatChange each call
renderPanelGlances on their own instead; without that the note you had just typed
was summarised by the note before it until the next tap landed. A space between
the label and the glance, too: the summary is announced as one button, and without
it a screen reader was given "Table Setup9 seats".

Costs 0.08ms of a 4.79ms renderAll on a twelve-handed phone. Four injected faults
in the wording are caught one test each.

glancesFitTheirRows took three goes. The first asked only whether a row had grown
taller than a bare one, and a glance floated back into flow and allowed to wrap
sailed straight through it, because a float does not expand the box it sits in.
The second was caught by the sweep on its first clean run — 94 failing configs, 91
of the offenders this check's own: it takes the shortest row as its idea of a bare
one, and the Keyboard panel is display: none under pointer: coarse, so on every
viewport visited with a thumb the bar was a row that wasn't drawn, and all seven
real rows read as grown. Rows that aren't rendered aren't rows. The four broken
layouts are re-run with that panel hidden and each is still caught.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-06 01:56:58 -06:00
co-authored by Claude Opus 5
parent e7ddff2fed
commit 4a1cb842cb
7 changed files with 547 additions and 6 deletions
+62
View File
@@ -509,6 +509,68 @@ Which is the finding. On a crowded table **keeping the seats off the rail and ke
off each other are in genuine conflict**, and resolving it needs the two settled together
rather than one bolted onto the other.
### And then measured again, and dropped
Re-probed across 40 configurations — four table shapes at ten viewports — the gap is real
and systematic: **31 of 40 sit further past the oval than the box says**, worst 34px
against a box overhang of 0 everywhere. The 303px above was the all-in shape, which is the
extreme rather than the case.
Then it was looked at rather than measured, and 12–17px of a rounded puck's corner
crossing the rail reads as players *at* a rail — which is what the thing is a picture of.
The measurement gap is worth writing down and the appearance is not worth a round, so the
lead is closed rather than carried forward. Worth the re-measure to find that out: the
number that made it look urgent was the worst cell in the table, not a typical one.
## Eight rows that said only their own names
The sidebar is eight `<details>`, and every one measured exactly 60px shut with nothing on
it but its label — 480px of a phone's screen, and 480px of a 660px desktop column, spent
saying "Standings", "Hands", "Waiting list". Whether the box balances, whether anyone is
waiting for a seat, how many hands have been played: the page had **all three worked out
already**, and charged a tap for each of them, on rows two thirds of which were empty.
That is the disclosure pattern's named failure — a header you have to open to find out
whether it was worth opening — and the answer the design systems give is the same one:
put the status on the header. So each shut panel now carries the one fact you would have
opened it for, and the panel keeps the sentence. "Off by $300" is the alarm; *which seat
is holding chips nobody wrote down* is a paragraph, and that is the half that belongs
behind the tap.
Two rules hold it together, and `panelGlance.test.js` checks both.
**A glance appears only when it has something a dealer would act on.** Eight rows that
always say something are eight rows that say nothing again, and a row reading "Nobody
waiting" all night is the clutter this went in to remove. So no waiting list, no notes and
no hands yet are all silence, and the bank check stays quiet over a table nobody has
bought into — which is what the panel does with its own check, for the same reason.
**A glance never says something the panel under it would contradict.** It is read off the
same state, never off the panel's DOM — a shut `<details>` has no rendered body to read —
and one test walks three buy-in figures asserting the glance's colour agrees with
`bankReconciliation`'s verdict and its number carries the same difference.
The layout half is `glancesFitTheirRows` in the audit, and it took three goes.
The first version asked only whether a row had grown taller than a row with nothing on it,
which sounds like the whole question and is not: a glance floated back into flow and
allowed to wrap **sailed straight through it**, because a float does not expand the box it
sits in — two lines of note hung out of a row that still measured exactly 44px. Asking
where the glance actually ended up, in both axes, is the question that has an answer.
The second version was wrong in the other direction, and **the sweep caught it on its
first clean run**: 94 failing configurations, 91 of the offenders this check's own. It
takes the shortest row in the sidebar as its idea of a bare one, and the Keyboard panel is
`display: none` under `pointer: coarse` — a touch device has no keys to press — so on
every viewport visited with a thumb the bar was a row that wasn't drawn at all, 0px, and
all seven real rows read as grown. Seven rows times thirteen viewports is exactly the 91.
Rows that aren't rendered aren't rows.
Which is the point of running the sweep against a check as well as against the page. A
narrowing fix has to be shown not to have blinded the thing it quietened, so the four
broken versions are re-run **with the Keyboard panel hidden** — the condition that was
breaking it — and each is still caught, while the unbroken page stays clean.
## Why the hand evaluator is tested the way it is
`handRank.js` is the only module here that can pay the wrong person without anybody
+218
View File
@@ -0,0 +1,218 @@
// What a shut sidebar panel says about itself.
//
// The sidebar is eight <details>, every one exactly 60px tall with nothing in it but
// its own name — 480px of a phone's screen and 480px of a 660px desktop column, saying
// "Standings", "Hands", "Waiting list" and nothing else. Whether the box balances,
// whether anyone is waiting for a seat, how many hands have been played: the page had
// all three worked out and charged a tap for each of them, on rows two thirds empty.
//
// So each one now carries the fact you would have opened it for. Two rules hold this
// together, and both are checked here.
//
// A glance appears only when it has something a dealer would act on. Eight rows that
// always say something are eight rows that say nothing again, and a row reading
// "Nobody waiting" all night is the clutter this went in to remove.
//
// And a glance never says something the panel under it would contradict. It is the
// panel's own sentence with the explanation taken off, so it has to be read off the
// same state — never off the panel's DOM, which for a shut <details> isn't rendered
// at all.
const test = require("node:test");
const { describe } = require("node:test");
const assert = require("node:assert");
const { Render, HoldemState, makeTable } = require("./load");
const glances = state => Render.panelGlances(state);
const textOf = (state, id) => (glances(state)[id] || {}).text;
// A table part-way through a night: money on it, so the bank check has an opinion.
const night = () => {
const state = makeTable({ seatCount: 9, seated: [0, 1, 2, 3, 4], stack: 200,
smallBlind: 5, bigBlind: 10 });
state.seats.slice(0, 5).forEach(p => { p.buyIns = 200; });
return state;
};
describe("the rows that always have something to say", () => {
test("the table's own shape and stakes", () => {
assert.equal(textOf(night(), "glanceTableSetup"), "9 seats · $5/$10");
});
test("and the stakes drop out rather than reading as free", () => {
// A table set up with no blinds yet is a real state — it is what the panel
// opens on — and "9 seats · $0/$0" would be a claim about the game rather
// than an admission that nobody has said yet.
const state = night();
state.smallBlind = 0;
state.bigBlind = 0;
assert.equal(textOf(state, "glanceTableSetup"), "9 seats");
});
test("the chip values, as a range rather than a count of rows", () => {
// "Are the values right for tonight's set" is the question this answers.
// Five colours is the answer to a different one.
assert.equal(textOf(night(), "glanceChips"), "5 colours · $1–$500");
});
test("with a chip nobody has priced yet left out of the range", () => {
// A blank row in the legend is a chip the dealer hasn't got to, not a
// zero-dollar chip — and letting it in would read the range as starting at $0.
const state = night();
state.chipLegend.push({ id: "pink", color: "#ff88aa", value: 0 });
assert.equal(textOf(state, "glanceChips"), "5 colours · $1–$500");
});
test("and one chip is a value, not a range of one", () => {
const state = night();
state.chipLegend = [{ id: "red", color: "#c0392b", value: 5 }];
assert.equal(textOf(state, "glanceChips"), "1 colour · $5");
});
});
describe("the bank check, which is the one worth a colour", () => {
test("says so when the cash covers the chips", () => {
const state = night();
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Balanced");
assert.equal(tone, "ok");
});
test("and names the gap when it doesn't", () => {
// Chips on the table that nobody paid for. The glance is the alarm and the
// amount; which seat is holding them is a paragraph, and that is the half
// that stays behind the tap.
const state = night();
state.seats[0].buyIns = 100;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $100");
assert.equal(tone, "warn");
});
test("the same way round when the cash is the side that's over", () => {
// One word covers both signs on purpose: the number and the alarm are what a
// dealer acts on, and "short" against "over" is a direction they would have to
// hold in their head to read. The panel says which way in a sentence.
const state = night();
state.seats[0].buyIns = 400;
const { text, tone } = glances(state).glanceStandings;
assert.equal(text, "Off by $200");
assert.equal(tone, "warn");
});
test("and stays quiet over a table nobody has bought into", () => {
// The panel hides its own check here for the same reason. A glance reading
// "Balanced" over an empty table is answering for a count never taken.
const state = makeTable({ seatCount: 9, seated: [], smallBlind: 5, bigBlind: 10 });
state.seats.forEach(p => { p.stack = null; p.buyIns = 0; });
assert.equal(textOf(state, "glanceStandings"), "");
assert.equal(glances(state).glanceStandings.tone, "");
});
test("and agrees with the sentence the panel would print", () => {
// The two are read off one call apiece and must never disagree — a glance
// saying "Balanced" over a panel saying "$40 more in chips than cash taken"
// is worse than no glance at all.
[0, 100, 400].forEach(buyIn => {
const state = night();
state.seats[0].buyIns = buyIn;
const bank = HoldemState.bankReconciliation(state);
const { text, tone } = glances(state).glanceStandings;
assert.equal(tone === "ok", bank.balanced, `buy-in ${buyIn}`);
if (!bank.balanced) {
assert.ok(text.includes(String(Math.abs(bank.difference))),
`${text} doesn't carry the panel's own ${bank.difference}`);
}
});
});
});
describe("the rows that stay quiet until they have news", () => {
test("nobody waiting, no note, no hands played yet", () => {
const state = night();
assert.equal(textOf(state, "glanceWaiting"), "");
assert.equal(textOf(state, "glanceHands"), "");
assert.equal(textOf(state, "glanceNotes"), "");
});
test("and speak up the moment they do", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }, { id: "w2", name: "Owen" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "2 waiting");
assert.equal(textOf(state, "glanceHands"), "1 hand");
});
test("counting one of something as one of it", () => {
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] },
{ handNumber: 2, pot: 80, payouts: [], ledger: [] }];
assert.equal(textOf(state, "glanceWaiting"), "1 waiting");
assert.equal(textOf(state, "glanceHands"), "2 hands");
});
test("and a note shows which note it is, not that there is one", () => {
// "Has notes" costs a tap to learn anything, which is the whole failure this
// is fixing. The text is what identifies it; the row's own width ellipsises.
const state = night();
state.notes = "Sam owes Dee $20 from last night";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20 from last night");
});
test("reading past the blank lines a note gets typed under", () => {
// Notes get written as a list — a heading, a blank line, then the thing. The
// first line of the string is regularly not the first line of the note.
const state = night();
state.notes = "\n \n Sam owes Dee $20\nand Ted owes Al $5";
assert.equal(textOf(state, "glanceNotes"), "Sam owes Dee $20");
});
test("and a note of nothing but whitespace is no note", () => {
const state = night();
state.notes = "\n\n \t \n";
assert.equal(textOf(state, "glanceNotes"), "");
});
});
describe("what the row is allowed to be", () => {
const ROWS = ["glanceTableSetup", "glanceStandings", "glanceWaiting",
"glanceHands", "glanceChips", "glanceNotes"];
test("every glance the sidebar renders is one this knows about", () => {
// The list drives the DOM writes, so a glance computed and never listed is a
// row that silently keeps its old text for the rest of the night.
const state = night();
assert.deepEqual(Object.keys(glances(state)).sort(), [...ROWS].sort());
assert.deepEqual([...Render.PANEL_GLANCES].sort(), [...ROWS].sort());
});
test("and none of them is a paragraph", () => {
// The row is 44px of a 320px column with a label already on it. Anything
// long enough to need the panel's room belongs in the panel.
const state = night();
state.waitingList = [{ id: "w1", name: "Nadia" }];
state.handHistory = [{ handNumber: 1, pot: 120, payouts: [], ledger: [] }];
ROWS.filter(id => id !== "glanceNotes").forEach(id => {
const text = textOf(state, id);
assert.ok(text.length <= 24, `${id} is ${text.length} chars: "${text}"`);
assert.ok(!text.includes("\n"), `${id} has a line break in it`);
});
});
test("including a note, which is trimmed to one line and left to the ellipsis", () => {
// The one glance whose length isn't ours to choose. It must still be a single
// line — a second one would make the row taller, and the row is what holds the
// sidebar to eight equal slabs.
const state = night();
state.notes = "x".repeat(400) + "\nsecond line";
const text = textOf(state, "glanceNotes");
assert.ok(!text.includes("\n"));
assert.equal(text, "x".repeat(400));
});
test("and a state that isn't there yet asks for nothing", () => {
// renderAll runs before the first load has finished on a cold open.
assert.deepEqual(Render.panelGlances(null), {});
assert.deepEqual(Render.panelGlances(undefined), {});
});
});
+84
View File
@@ -1018,6 +1018,89 @@ const LayoutAudit = {
return { name: "nothing on the felt is painted for nothing", offenders };
},
// The status a shut sidebar panel wears, and the three ways it can go wrong.
//
// Each of the eight <details> in the sidebar now carries the one fact you would
// have opened it for — "Balanced", "2 waiting", "5 colours · $1–$500". They are the
// sidebar's whole navigation, and what makes them scannable is that they are eight
// identical slabs: a glance that pushes one row taller than the rest costs more in
// rhythm than the fact it added is worth. Two of the strings aren't ours to keep
// short — a table note is whatever the dealer typed, and the amounts grow with the
// stakes — so this is checked on the page rather than argued about in the markup.
//
// Four questions, and they fail in different directions. A row that grew is the
// layout one. A glance sitting on its own label is the narrow-screen one, and it is
// the one that will come back: it takes a long label and a long glance in the same
// panel, so it depends on the state and the viewport together. And a glance
// ellipsised down to "Sam…" is worth less than no glance at all — it has spent a
// row's width to say that a note exists, which the panel's own presence already
// said.
GLANCE_MIN_READABLE_PX: 60,
glancesFitTheirRows() {
// Rows that are actually drawn. The Keyboard panel is display: none under
// `pointer: coarse` — a touch device has no keys to press — and a panel that
// isn't rendered measures 0px, which the shortest-row rule below happily took
// as its idea of a bare row. Every real row then read as grown: caught by the
// sweep on its first clean run, 91 offenders, seven rows across each of the
// thirteen viewports visited with a thumb.
const rows = [...document.querySelectorAll(".sidebarPanel > summary")]
.filter(row => row.getBoundingClientRect().height > 0);
if (!rows.length) return { name: "panel glances fit their rows", offenders: [], skipped: "no sidebar here" };
// The shortest row in the sidebar is what a row with nothing on it measures,
// whichever one that happens to be — taken rather than declared, so a change to
// the header's padding doesn't quietly make this check pass by moving the bar.
const heights = rows.map(row => row.getBoundingClientRect().height);
const bare = Math.min(...heights);
const offenders = [];
rows.forEach((row, index) => {
const glance = row.querySelector(".panelGlance");
const label = row.querySelector("h2");
const rowBox = row.getBoundingClientRect();
const name = (label && label.textContent.trim()) || `panel ${index + 1}`;
if (heights[index] > bare + 1) {
offenders.push(`${name} :: row is ${Math.round(heights[index])}px against ` +
`${Math.round(bare)}px for a row with nothing on it`);
}
if (!glance || glance.hidden || !glance.textContent.trim()) return;
const box = glance.getBoundingClientRect();
if (label) {
const labelBox = label.getBoundingClientRect();
if (box.left < labelBox.right) {
offenders.push(`${name} :: "${glance.textContent}" sits on the label, ` +
`${Math.round(labelBox.right - box.left)}px into it`);
}
}
// Both axes, and the vertical one is the half that matters. The row's own
// height cannot be trusted to report this: the first version of this check
// asked only whether a row had grown, and a glance floated back into flow
// and allowed to wrap sailed straight through it — a float doesn't expand
// the box it sits in, so two lines of note hung 9px clear of a row that
// still measured exactly 44px. Asking where the glance actually
// ended up is the question that has an answer.
const out = [
box.right > rowBox.right + 1 ? "off the right" : null,
box.left < rowBox.left - 1 ? "off the left" : null,
box.top < rowBox.top - 1 ? `${Math.round(rowBox.top - box.top)}px out of the top` : null,
box.bottom > rowBox.bottom + 1 ? `${Math.round(box.bottom - rowBox.bottom)}px out of the bottom` : null,
].filter(Boolean);
if (out.length) {
offenders.push(`${name} :: "${glance.textContent}" hangs ${out.join(" and ")} of its row`);
}
if (box.width < this.GLANCE_MIN_READABLE_PX &&
glance.scrollWidth > glance.clientWidth + 1) {
offenders.push(`${name} :: "${glance.textContent}" is cut down to ` +
`${Math.round(box.width)}px, which says less than nothing`);
}
});
return { name: "panel glances fit their rows", offenders };
},
// A seat's name, painted under the decoration on its own puck.
//
// The chip that marks the button sits in the puck's top-right corner on a layer
@@ -1345,6 +1428,7 @@ const LayoutAudit = {
this.oneDealerButton(),
this.hiddenButPainted(),
this.emptyDecoration(),
this.glancesFitTheirRows(),
this.seatLabelsClear(),
this.seatsKeepTheirText(),
this.chipsKeepTheirAmounts(),