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
219 lines
10 KiB
JavaScript
219 lines
10 KiB
JavaScript
// 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), {});
|
||
});
|
||
});
|