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:
@@ -68,7 +68,7 @@
|
|||||||
loaded: open on a table nobody has set up yet, shut on one already
|
loaded: open on a table nobody has set up yet, shut on one already
|
||||||
half-way through a night. *@
|
half-way through a night. *@
|
||||||
<details class="sidebarPanel" id="tableSetupPanel">
|
<details class="sidebarPanel" id="tableSetupPanel">
|
||||||
<summary><h2>Table Setup</h2></summary>
|
<summary><h2>Table Setup</h2> <span class="panelGlance" id="glanceTableSetup" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<label class="fieldRow">
|
<label class="fieldRow">
|
||||||
<span>Seats at table</span>
|
<span>Seats at table</span>
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
fill the seats, look back at a hand — and, last and apart from the rest,
|
fill the seats, look back at a hand — and, last and apart from the rest,
|
||||||
the two buttons that end it. *@
|
the two buttons that end it. *@
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary><h2>Standings</h2></summary>
|
<summary><h2>Standings</h2> <span class="panelGlance" id="glanceStandings" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<p id="storageWarning" class="storageWarning" hidden></p>
|
<p id="storageWarning" class="storageWarning" hidden></p>
|
||||||
<div class="standingsTableWrap">
|
<div class="standingsTableWrap">
|
||||||
@@ -212,7 +212,7 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary><h2>Waiting list</h2></summary>
|
<summary><h2>Waiting list</h2> <span class="panelGlance" id="glanceWaiting" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<form id="waitingListForm" class="waitingAdd">
|
<form id="waitingListForm" class="waitingAdd">
|
||||||
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
<input type="text" id="waitingNameInput" maxlength="24" placeholder="Who's waiting?" autocomplete="off" />
|
||||||
@@ -224,7 +224,7 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary><h2>Hands</h2></summary>
|
<summary><h2>Hands</h2> <span class="panelGlance" id="glanceHands" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<div class="handHistoryFilterRow">
|
<div class="handHistoryFilterRow">
|
||||||
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
<input id="handHistoryFilter" type="search" class="handHistoryFilter"
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary><h2>Chip Legend</h2></summary>
|
<summary><h2>Chip Legend</h2> <span class="panelGlance" id="glanceChips" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<div id="chipLegendList"></div>
|
<div id="chipLegendList"></div>
|
||||||
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
<button id="addChipColorBtn" class="holdemBtn holdemBtnSmall">+ Add chip color</button>
|
||||||
@@ -266,7 +266,7 @@
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="sidebarPanel">
|
<details class="sidebarPanel">
|
||||||
<summary><h2>Table Notes</h2></summary>
|
<summary><h2>Table Notes</h2> <span class="panelGlance" id="glanceNotes" hidden></span></summary>
|
||||||
<div class="panelBody">
|
<div class="panelBody">
|
||||||
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
<textarea id="tableNotes" rows="4" aria-label="Table notes" placeholder="Anything else worth tracking — side bets, IOUs, running totals..."></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2291,6 +2291,57 @@ button,
|
|||||||
letter-spacing: inherit;
|
letter-spacing: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The one fact a shut panel is worth opening for, said on the row itself.
|
||||||
|
See Render.panelGlances for what each one says and why.
|
||||||
|
|
||||||
|
Taken out of flow rather than laid out beside the label, because a <summary> only
|
||||||
|
draws its disclosure triangle while it is `display: list-item` — the same constraint
|
||||||
|
that put padding rather than a flex box on the row above.
|
||||||
|
|
||||||
|
Out of flow is also the guarantee that matters here: nothing about the glance may
|
||||||
|
make a sidebar row taller, and an absolutely positioned box cannot. Floating it did
|
||||||
|
keep the row at 60px, but it was aligned to the label's line box rather than to the
|
||||||
|
row, and a 12px string in a box sized for 15px text rode 2.5px low on all six panels.
|
||||||
|
Centring against the row is both the right alignment and the smaller promise.
|
||||||
|
|
||||||
|
It lands 1.5px below the label, measured, and that is the label's offset rather than
|
||||||
|
this one's: the row's 44px is a min-height, so the three pixels it adds to a 41px
|
||||||
|
header all go on the bottom and leave the label sitting high in its own row. The
|
||||||
|
glance is on the row's centre, which is the stable thing to be on. */
|
||||||
|
.sidebarPanel > summary {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelGlance {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
/* The labels are short and fixed — "Waiting list" is the longest at under a third
|
||||||
|
of the column — so this is room to spare, not a division of the row. */
|
||||||
|
max-width: 55%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
/* The amounts change while a hand is being dealt, and a proportional font shifts
|
||||||
|
the whole string sideways every time a digit does. */
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The same two colours the bank check's own sentence uses, because this is that
|
||||||
|
sentence with the explanation taken off — a glance that agreed with the panel in
|
||||||
|
words and disagreed with it in colour would be worse than no glance. */
|
||||||
|
.panelGlanceOk {
|
||||||
|
color: #7fc99a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelGlanceWarn {
|
||||||
|
color: #f0b46a;
|
||||||
|
}
|
||||||
|
|
||||||
.panelBody {
|
.panelBody {
|
||||||
padding-bottom: var(--sp-4);
|
padding-bottom: var(--sp-4);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -515,6 +515,12 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
document.getElementById("tableNotes").addEventListener("input", e => {
|
document.getElementById("tableNotes").addEventListener("input", e => {
|
||||||
state.notes = e.target.value;
|
state.notes = e.target.value;
|
||||||
hooks.save(state);
|
hooks.save(state);
|
||||||
|
// The one field on the page that must not go through renderAll: it would write
|
||||||
|
// the textarea's own value back into it on every keystroke and put the caret at
|
||||||
|
// the end. The panel's shut-state summary still has to keep up, though, and it
|
||||||
|
// is the only thing on the page that reads the notes — so it gets called on its
|
||||||
|
// own. It touches the six <span>s in the sidebar headers and nothing else.
|
||||||
|
Render.renderPanelGlances();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
document.getElementById("addChipColorBtn").addEventListener("click", () => {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const Render = {
|
|||||||
this.renderNotes();
|
this.renderNotes();
|
||||||
this.updatePotBadge();
|
this.updatePotBadge();
|
||||||
this.renderSetupFields();
|
this.renderSetupFields();
|
||||||
|
this.renderPanelGlances();
|
||||||
this.renderHeaderActions();
|
this.renderHeaderActions();
|
||||||
this.renderTableHint();
|
this.renderTableHint();
|
||||||
this.renderStandings();
|
this.renderStandings();
|
||||||
@@ -65,6 +66,105 @@ const Render = {
|
|||||||
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
if (taken > 0) hint.textContent = `Taken so far tonight: ${Money.format(taken)}.`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- What a shut panel says about itself ----
|
||||||
|
//
|
||||||
|
// The sidebar is eight <details>, and every one of them was exactly 60px tall with
|
||||||
|
// nothing in it but its own name: 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 has all three worked out already, and every one of them cost a tap to
|
||||||
|
// find out, on a row two thirds of which was empty.
|
||||||
|
//
|
||||||
|
// That is the failure mode the disclosure pattern is warned about by name: a header
|
||||||
|
// you have to open to learn whether it was worth opening. Salesforce and Carbon both
|
||||||
|
// answer it the same way — put the status in the header — and NN/g's objection to
|
||||||
|
// accordions is exactly the cost of the taps this saves.
|
||||||
|
//
|
||||||
|
// So a shut panel now carries the one fact you would have opened it for. The panel
|
||||||
|
// itself still holds the sentence: "Off by $40" is the alarm, and which seat is
|
||||||
|
// holding chips nobody wrote down is a paragraph, which is the half that belongs
|
||||||
|
// behind the tap.
|
||||||
|
//
|
||||||
|
// Silence is a state too. A glance appears when it has something a dealer would act
|
||||||
|
// on and stays away otherwise — nobody waiting, no notes, no hands yet — because a
|
||||||
|
// row reading "Nobody waiting" all night is the clutter this is here to remove, and
|
||||||
|
// eight rows that always say something are eight rows that say nothing again.
|
||||||
|
PANEL_GLANCES: ["glanceTableSetup", "glanceStandings", "glanceWaiting",
|
||||||
|
"glanceHands", "glanceChips", "glanceNotes"],
|
||||||
|
|
||||||
|
// Pure, so the wording can be tested without a page to hang it on. Every value is
|
||||||
|
// read off the state the panel below it renders from, never off the panel's own
|
||||||
|
// DOM — a shut <details> has no rendered body to read.
|
||||||
|
panelGlances(state) {
|
||||||
|
const glance = (text, tone = "") => ({ text: text || "", tone });
|
||||||
|
if (!state) return {};
|
||||||
|
|
||||||
|
const blinds = state.smallBlind > 0 && state.bigBlind > 0
|
||||||
|
? ` · ${Money.format(state.smallBlind)}/${Money.format(state.bigBlind)}`
|
||||||
|
: "";
|
||||||
|
const waiting = (state.waitingList || []).length;
|
||||||
|
const hands = (state.handHistory || []).length;
|
||||||
|
|
||||||
|
// Denominations rather than a count of rows, because "are the values right for
|
||||||
|
// tonight's set" is the question, and five colours is the answer to a different
|
||||||
|
// one. Chips with no value on them aren't in the range: a blank row is a chip
|
||||||
|
// the dealer hasn't got to yet, not a zero-dollar chip.
|
||||||
|
const values = (state.chipLegend || [])
|
||||||
|
.map(chip => Number(chip.value) || 0).filter(v => v > 0).sort((a, b) => a - b);
|
||||||
|
const span = values.length
|
||||||
|
? (values[0] === values[values.length - 1]
|
||||||
|
? Money.format(values[0])
|
||||||
|
: `${Money.format(values[0])}–${Money.format(values[values.length - 1])}`)
|
||||||
|
: "";
|
||||||
|
const chips = values.length
|
||||||
|
? `${values.length} ${values.length === 1 ? "colour" : "colours"} · ${span}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
// The first line that has anything on it. Notes get typed as a list — "Sam owes
|
||||||
|
// Dee 20" under a blank line under a heading — and the first line of the string
|
||||||
|
// is often not the first line of the note.
|
||||||
|
const notes = (state.notes || "").split("\n")
|
||||||
|
.map(line => line.trim()).find(Boolean) || "";
|
||||||
|
|
||||||
|
const bank = HoldemState.bankReconciliation(state);
|
||||||
|
// Before anybody has bought in there is nothing to reconcile, and the panel
|
||||||
|
// hides its own check for the same reason — so the glance saying "Balanced"
|
||||||
|
// over an empty table would be answering for a count that was never taken.
|
||||||
|
const nothingBanked = bank.cashIn === 0 && bank.chipsInPlay === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
glanceTableSetup: glance(`${state.seatCount} seats${blinds}`),
|
||||||
|
glanceStandings: nothingBanked ? glance("")
|
||||||
|
: bank.balanced ? glance("Balanced", "ok")
|
||||||
|
: glance(`Off by ${Money.format(Math.abs(bank.difference))}`, "warn"),
|
||||||
|
glanceWaiting: glance(waiting ? `${waiting} waiting` : ""),
|
||||||
|
glanceHands: glance(hands ? `${hands} ${hands === 1 ? "hand" : "hands"}` : ""),
|
||||||
|
glanceChips: glance(chips),
|
||||||
|
glanceNotes: glance(notes),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
renderPanelGlances() {
|
||||||
|
const glances = this.panelGlances(this.state);
|
||||||
|
// renderAll runs after every tap a dealer makes and none of this changes between
|
||||||
|
// most of them. Writing textContent is cheap; making the browser re-style eight
|
||||||
|
// rows for the same eight strings is the part worth not doing.
|
||||||
|
const signature = this.PANEL_GLANCES
|
||||||
|
.map(id => `${id}:${(glances[id] || {}).text}|${(glances[id] || {}).tone}`).join(";");
|
||||||
|
if (signature === this.lastGlanceSignature) return;
|
||||||
|
this.lastGlanceSignature = signature;
|
||||||
|
|
||||||
|
this.PANEL_GLANCES.forEach(id => {
|
||||||
|
const host = document.getElementById(id);
|
||||||
|
if (!host) return;
|
||||||
|
const { text, tone } = glances[id] || { text: "", tone: "" };
|
||||||
|
host.hidden = !text;
|
||||||
|
host.textContent = text;
|
||||||
|
host.classList.toggle("panelGlanceOk", tone === "ok");
|
||||||
|
host.classList.toggle("panelGlanceWarn", tone === "warn");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
|
// Misdeal is only a thing you can call on a hand that is out. Before the deal there
|
||||||
// is nothing to take back, and afterwards the header would be offering to unwind a
|
// is nothing to take back, and afterwards the header would be offering to unwind a
|
||||||
// hand that has already been paid — so the button comes and goes with the hand
|
// hand that has already been paid — so the button comes and goes with the hand
|
||||||
@@ -1946,6 +2046,12 @@ const Render = {
|
|||||||
// after naming a player both stayed greyed out, still captioned "nobody is at
|
// after naming a player both stayed greyed out, still captioned "nobody is at
|
||||||
// the table yet", because this path deliberately doesn't redraw everything.
|
// the table yet", because this path deliberately doesn't redraw everything.
|
||||||
this.renderHeaderActions();
|
this.renderHeaderActions();
|
||||||
|
// Third thing on this list for the third time the same reason. What a seat has
|
||||||
|
// bought in for and what it is holding are the two halves of the bank check, so
|
||||||
|
// this path is the one that moves "Balanced" to "Off by $300" — and the shut
|
||||||
|
// Standings panel is where a dealer would see it, since the seat editor is over
|
||||||
|
// the sidebar while they are typing.
|
||||||
|
this.renderPanelGlances();
|
||||||
},
|
},
|
||||||
|
|
||||||
wireSeatFields(form, player, index) {
|
wireSeatFields(form, player, index) {
|
||||||
@@ -2122,6 +2228,20 @@ const Render = {
|
|||||||
this.renderSeats();
|
this.renderSeats();
|
||||||
this.renderSeatEditor();
|
this.renderSeatEditor();
|
||||||
this.updatePotBadge();
|
this.updatePotBadge();
|
||||||
|
// This is a named list of everything a chip's value moves, and the
|
||||||
|
// legend's own summary is on it: shut, the panel reads "5 colours ·
|
||||||
|
// $1–$500", and re-pricing the top chip is exactly the edit that
|
||||||
|
// changes it. Called here for the same reason as the rest of the list:
|
||||||
|
// renderAll would write this field's own value back into it mid-
|
||||||
|
// keystroke.
|
||||||
|
//
|
||||||
|
// Only that one. The bank check is not downstream of this, which is
|
||||||
|
// worth saying because it looks as though it should be — a seat's chip
|
||||||
|
// counts are priced by the legend for the puck and the seat editor's
|
||||||
|
// total, but bankReconciliation asks chipsHeldBy, which is stackOf, and
|
||||||
|
// stackOf reads the seat's typed stack or the table's default and never
|
||||||
|
// opens the legend at all.
|
||||||
|
this.renderPanelGlances();
|
||||||
});
|
});
|
||||||
// Through the hook rather than done here, because what this costs depends on
|
// Through the hook rather than done here, because what this costs depends on
|
||||||
// what the seats are holding — see removeChipColor in main.js.
|
// what the seats are holding — see removeChipColor in main.js.
|
||||||
|
|||||||
@@ -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
|
off each other are in genuine conflict**, and resolving it needs the two settled together
|
||||||
rather than one bolted onto the other.
|
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
|
## 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
|
`handRank.js` is the only module here that can pay the wrong person without anybody
|
||||||
|
|||||||
@@ -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), {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1018,6 +1018,89 @@ const LayoutAudit = {
|
|||||||
return { name: "nothing on the felt is painted for nothing", offenders };
|
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.
|
// 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
|
// 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.oneDealerButton(),
|
||||||
this.hiddenButPainted(),
|
this.hiddenButPainted(),
|
||||||
this.emptyDecoration(),
|
this.emptyDecoration(),
|
||||||
|
this.glancesFitTheirRows(),
|
||||||
this.seatLabelsClear(),
|
this.seatLabelsClear(),
|
||||||
this.seatsKeepTheirText(),
|
this.seatsKeepTheirText(),
|
||||||
this.chipsKeepTheirAmounts(),
|
this.chipsKeepTheirAmounts(),
|
||||||
|
|||||||
Reference in New Issue
Block a user