Open a seat on what is on it, and put the sheet in front of the table
The seat sheet is the second-most-used surface here — every buy-in, every rename, every correction goes through it — and it was the least finished one. Seven things were stacked flat at one weight and all open at once. Measured on a 390px phone it came to 594px and scrolled, and the three controls a dealer touches on nearly every visit were 132px of that. The chip counter alone was 174px, the tallest single thing in the sheet, for a job most tables do once a night or never; the two hole-card slots were 62px of dashed grey on every table that never asked to be shown a card. Cards, the contribution and the custom fields are behind a fold now, and which of the three stand open is a question about the seat rather than a setting: a seat holding cards opens its cards, a seat with chips counted opens its count, a seat with a note on it opens its notes. A shut section says the answer on its own row — the cards, the total, how many notes — so nothing already entered is further away than it was. An empty chair is 419px and does not scroll; a seat with everything filled in is 625 and opens all three. The fields have the width the sheet always had. `.fieldRow input` caps at 140px, which is right in a 320px sidebar of settings and wrong in a sheet holding a whole phone: "Name" sat against the left edge with 203px of nothing between it and a field a third of the row wide. Same 44px control, 300px instead of 140. The chip counter's values are text. They were a `title` attribute, which is a tooltip, which is nothing on the device this is used on — five identical number boxes told apart by five coloured dots. It also left the wrapping <label> with no text in it, so they had no accessible name either. And the sheet has a scrim. It was the only one of the tool's seven overlays without one, while covering the felt from 250px down and the action bar with it — modal in everything but appearance. What stayed lit above it was the header, with Misdeal, Next Hand and Resolve Pot live under a thumb reaching for a name field. It dismisses on a tap and on Escape now, like the other six; a card picker opened from inside still takes Escape first. In landscape the scrim stops where the sheet stops, so Fold, Call and Raise keep the rail. It is put away with visibility rather than opacity, which is the same lesson the sheet above it already learned about transforms — and the layout sweep caught it the same way. At `opacity: 0` a fixed, full-window box is invisible and still entirely there: bottomFurniture() read it as furniture standing 640px tall at the bottom of a 640px phone, in every state and on every screen, and asked scroll-padding to reserve the whole window for it. Ninety-eight scenarios failed on it; hidden is hidden, and the sweep is back to the five it started with. The toast steps over the sheet the way it already steps over the action bar. Both are fixed to the bottom of a phone, and a "Keeping the screen on" toast was landing squarely on the seat form with its Undo and its × live above the fields. The height is measured and published, because it changes the moment a section is unfolded. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -31,6 +31,20 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@* The seat sheet was the only one of the tool's seven overlays with nothing behind
|
||||
it. On a phone it covers the felt from 250px down and the action bar with it, so
|
||||
it is modal in everything but appearance — and what stayed lit above it was the
|
||||
header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was
|
||||
reaching for a name field. NN/g and Material both say the same thing about this:
|
||||
a modal sheet with an invisible scrim tells the user they can still touch what
|
||||
they can still see, and they can't.
|
||||
|
||||
Not an `*Overlay` id, deliberately — Modal watches those and would take the
|
||||
sidebar panel this becomes on a desktop for a dialog. It is driven entirely by
|
||||
body.seatSheetOpen, and it is `display: none` above 700px where the panel is
|
||||
just a panel. *@
|
||||
<div id="seatSheetScrim"></div>
|
||||
|
||||
<div id="holdemLayout">
|
||||
<section id="tableSection">
|
||||
<div id="boardStrip" hidden></div>
|
||||
@@ -337,6 +351,20 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@* Four things stacked flat used to be five: name, in/out, two hole-card slots, stack,
|
||||
a five-box chip counter and a list of custom fields, every one of them at the same
|
||||
weight, all of it open at once. Measured on a 390px phone the sheet came to 594px,
|
||||
and the three controls a dealer touches on nearly every visit — the name, the two
|
||||
status boxes, the stack — were 132px of it. The chip counter alone was 174px, the
|
||||
tallest thing in the sheet, for a job most tables do once a night if at all; the two
|
||||
card slots were 62px of dashed grey on every table that never asked to be shown a
|
||||
card.
|
||||
|
||||
So the flat list is now three rows and three disclosures, and which of the three
|
||||
stand open is a question about *this seat*, answered in renderSeatDisclosures: a
|
||||
seat holding cards opens its cards, a seat with chips counted opens its count, a
|
||||
seat with notes on it opens its notes. Anything with nothing in it stays a one-line
|
||||
summary that says so. Nothing was removed and nothing moved to another screen. *@
|
||||
<template id="seatEditorTemplate">
|
||||
<div class="seatEditorForm">
|
||||
<label class="fieldRow">
|
||||
@@ -353,16 +381,29 @@
|
||||
<span>Fold</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="seatCards">
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</div>
|
||||
<label class="fieldRow stackRow">
|
||||
<span>Stack</span>
|
||||
<input type="number" class="stackInput" min="0" step="1" placeholder="table default" />
|
||||
</label>
|
||||
<div class="seatBuyIns" hidden></div>
|
||||
<div class="seatContribution">
|
||||
|
||||
<details class="subPanel seatSubPanel cardsPanel">
|
||||
<summary><span class="seatSubPanelName">Hole cards</span> <span class="seatGlance cardsGlance"></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="seatCards">
|
||||
<button type="button" class="cardSlot card1Slot" aria-label="Choose first card"></button>
|
||||
<button type="button" class="cardSlot card2Slot" aria-label="Choose second card"></button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
@* Named for the question it answers rather than for the boxes it contains. The
|
||||
counter, the typed override and the total are one calculation — what this
|
||||
seat has put in the pot — and the answer now sits on the summary, so a shut
|
||||
panel still says it. *@
|
||||
<details class="subPanel seatSubPanel contributionPanel">
|
||||
<summary><span class="seatSubPanelName">What they've put in</span> <span class="seatGlance contributionGlance"></span></summary>
|
||||
<div class="subPanelBody seatContribution">
|
||||
<div class="chipCounts"></div>
|
||||
<label class="fieldRow amountOverrideRow">
|
||||
<span>$ amount</span>
|
||||
@@ -370,9 +411,16 @@
|
||||
</label>
|
||||
<div class="seatTotal">Total: <span class="seatTotalAmount">$0</span></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="subPanel seatSubPanel notesPanel">
|
||||
<summary><span class="seatSubPanelName">Notes on this seat</span> <span class="seatGlance notesGlance"></span></summary>
|
||||
<div class="subPanelBody">
|
||||
<div class="customFields"></div>
|
||||
<button class="holdemBtn holdemBtnSmall addFieldBtn">+ field</button>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="chipLegendRowTemplate">
|
||||
@@ -383,9 +431,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is
|
||||
nothing at all on the device this is used on. Five coloured dots with five number
|
||||
boxes and no way to tell which one was the $25 without opening the Chip Legend and
|
||||
matching colours by eye. It is text now. *@
|
||||
<template id="chipCountTemplate">
|
||||
<label class="chipCountRow">
|
||||
<span class="chipSwatch"></span>
|
||||
<span class="chipCountValue"></span>
|
||||
<input type="number" class="chipCountInput" min="0" step="1" value="0" />
|
||||
</label>
|
||||
</template>
|
||||
|
||||
@@ -304,6 +304,20 @@ button,
|
||||
.potWizardChoice:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* The seat sheet's disclosure arrows keep their two positions and lose the swing
|
||||
between them. Not in the list above — nothing here is under a pointer, and the
|
||||
end state is meaningful: an arrow that pointed the same way open and shut would
|
||||
have stopped saying anything.
|
||||
|
||||
The scrim loses its fade with it, and the delay goes with the fade: with nothing
|
||||
to cover, a quarter second of visibility held open is a scrim still painted over
|
||||
a sheet that has already gone. `none` flips it at once in both directions, which
|
||||
is what the sheet's own reduced-motion rule concluded for the same reason. */
|
||||
.seatSubPanel > summary::before,
|
||||
#seatSheetScrim {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Border-box, everywhere ----------
|
||||
@@ -1506,10 +1520,11 @@ button,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Grip and Done button are the sheet's furniture — no reason for either on a
|
||||
desktop sidebar, where the panel is just a panel. */
|
||||
/* Grip, Done button and scrim are the sheet's furniture — no reason for any of them
|
||||
on a desktop sidebar, where the panel is just a panel. */
|
||||
.seatSheetGrip,
|
||||
.seatSheetClose {
|
||||
.seatSheetClose,
|
||||
#seatSheetScrim {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1528,6 +1543,66 @@ button,
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
/* A label and a field, and the field takes what is left.
|
||||
`.fieldRow input` caps at 140px, which is the right width in a 320px sidebar of
|
||||
settings and the wrong one in a sheet that has the whole phone: "Name" sat against
|
||||
the left edge with 203px of nothing between it and a field a third of the row wide.
|
||||
The row's height does not change — this is the same 44px control, given the width
|
||||
the sheet already had. */
|
||||
.seatEditorForm .fieldRow input:not([type="checkbox"]) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
/* The three folded sections. `.subPanel` already draws the rule above and the muted
|
||||
summary; what these add is the room to hit one with a thumb and the fact on the
|
||||
right that says whether opening it is worth the tap. */
|
||||
.seatSubPanel > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-1) 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* A flex <summary> loses its disclosure triangle in Safari and Chrome alike, so the
|
||||
marker is drawn here instead — and it is the one piece of this sheet that says
|
||||
"there is more underneath" before anything is tapped. */
|
||||
.seatSubPanel > summary::before {
|
||||
content: "▸";
|
||||
flex: none;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-dim);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.seatSubPanel[open] > summary::before {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.seatSubPanel > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.seatSubPanelName {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* The answer, on the shut row. Sized and coloured like the sidebar's panel glances,
|
||||
because it is the same idea one level down: the one fact worth opening for. */
|
||||
.seatGlance {
|
||||
flex: none;
|
||||
max-width: 55%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.seatCardToggles {
|
||||
display: flex;
|
||||
gap: var(--sp-2);
|
||||
@@ -2234,15 +2309,21 @@ button,
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* The rule above it is the disclosure's own now, so this no longer draws one of its
|
||||
own — two lines a few pixels apart was the look of a section that didn't know where
|
||||
it started. */
|
||||
.seatContribution {
|
||||
border-top: 1px dashed var(--line);
|
||||
padding-top: var(--sp-2);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
/* A grid rather than a wrapping flex row. Five chip colours in a flex row came out
|
||||
four-and-one, with the fifth stranded on a line of its own and the four above it
|
||||
ending wherever their widths happened to land — a ragged block for a set of things
|
||||
that are all the same shape. Columns are as wide as they need to be for a value and
|
||||
a count, and every cell is the same. */
|
||||
.chipCounts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
|
||||
gap: var(--sp-1);
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
@@ -2251,17 +2332,32 @@ button,
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chipSwatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* What the colour is worth, in the row. Fixed-width and tabular so five of these
|
||||
stack into a column the eye can run down, rather than five labels each starting
|
||||
somewhere else. */
|
||||
.chipCountValue {
|
||||
flex: none;
|
||||
min-width: 2.75rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-sm);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chipCountInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 34px;
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--line);
|
||||
@@ -4368,6 +4464,50 @@ body.hasActionBar #toastHost {
|
||||
|
||||
/* ---- Seat editor as a bottom sheet ---- */
|
||||
|
||||
/* What the sheet is in front of.
|
||||
Lighter than the 0.72 the other six overlays use, on purpose: those replace the
|
||||
table, and this one is a form *about* something on it. The chair being edited
|
||||
should still read through the dim, and at 0.55 it does — the felt stays a felt
|
||||
and the header behind it stops looking like somewhere to put a thumb. */
|
||||
/* Put away with visibility, not with opacity — the same lesson the sheet above it
|
||||
already learned about transforms, and it was caught the same way. A fixed,
|
||||
full-window box at `opacity: 0` is invisible and still entirely there: the layout
|
||||
sweep read it as furniture standing 640px tall at the bottom of a 640px phone, on
|
||||
every screen and in every state, and asked scroll-padding to reserve the whole
|
||||
window for it. Hidden is hidden — off the accessibility tree, out of hit testing,
|
||||
and out of that measurement.
|
||||
|
||||
The delay is what keeps the fade-out on screen for its own quarter second:
|
||||
visibility flips at the end of the 0.25s going out, and immediately coming in. */
|
||||
#seatSheetScrim {
|
||||
display: block;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 59;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.25s ease, visibility 0s linear 0.25s;
|
||||
}
|
||||
|
||||
body.seatSheetOpen #seatSheetScrim {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: opacity 0.25s ease, visibility 0s;
|
||||
}
|
||||
|
||||
/* And the toast steps over the sheet, the way it already steps over the action
|
||||
bar. Both are fixed to the bottom edge, and the sheet is the taller of the two
|
||||
while it is open — a toast left at the bar's height lands in the middle of the
|
||||
seat form, with a live Undo and a live × sitting on top of the fields. Written
|
||||
after the `body.hasActionBar` rule it overrides: equal specificity, later wins.
|
||||
|
||||
The height is measured and published by Render.watchSeatSheetHeight, so it
|
||||
follows the sheet when a section is unfolded rather than assuming a size. */
|
||||
body.seatSheetOpen #toastHost {
|
||||
bottom: calc(var(--seatSheetHeight, 0px) + 0.75rem + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
#seatEditorPanel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
@@ -4452,6 +4592,20 @@ body.hasActionBar #toastHost {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* A section's name is a control, and a thumb has to be able to land on it without
|
||||
aiming. Same 44px floor as every field below it, and the same 15px the toggles
|
||||
and the total are set in — a summary at 12px reads as a caption for the row
|
||||
above rather than as the thing you press. */
|
||||
.seatEditorForm .seatSubPanel > summary {
|
||||
min-height: 44px;
|
||||
font-size: var(--fs-md);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.seatEditorForm .seatGlance {
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.seatEditorForm .chipCounts {
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
@@ -5039,6 +5193,14 @@ body.hasActionBar #toastHost {
|
||||
right: var(--actionRailW);
|
||||
border-top-right-radius: var(--r-lg);
|
||||
}
|
||||
|
||||
/* The scrim stops where the sheet stops. Fold, Call and Raise live in the rail
|
||||
down the right-hand side when a phone is on its side, and a scrim that reached
|
||||
across it would be dimming — and swallowing the taps on — the one part of the
|
||||
screen the sheet was deliberately kept clear of. */
|
||||
#seatSheetScrim {
|
||||
right: var(--actionRailW);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Focus is brought to rest where it can be seen ----------
|
||||
|
||||
@@ -796,6 +796,27 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// The two ways out of the sheet that every other overlay in the tool already had.
|
||||
//
|
||||
// Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join
|
||||
// them, because above 700px it is not a sheet at all — it is the sidebar panel the
|
||||
// seat's fields live in, and a dialog is the wrong thing for Modal to make of it.
|
||||
// So the two behaviours it was missing are wired here against the same state the
|
||||
// scrim is drawn from.
|
||||
document.getElementById("seatSheetScrim")
|
||||
.addEventListener("click", () => Render.closeSeatEditor());
|
||||
|
||||
// Not in capture, and behind Modal's own check: a card picker opened *from* the
|
||||
// sheet is on top of it, and Escape there means "stop picking a card", not "throw
|
||||
// the seat away". Modal stops that event before this listener ever sees it; the
|
||||
// guard is for the case where it hasn't, and costs nothing.
|
||||
document.addEventListener("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
if (Modal.open.length > 0) return;
|
||||
if (!document.body.classList.contains("seatSheetOpen")) return;
|
||||
Render.closeSeatEditor();
|
||||
});
|
||||
|
||||
document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm());
|
||||
document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close());
|
||||
document.getElementById("addChipsOverlay").addEventListener("click", e => {
|
||||
|
||||
@@ -20,9 +20,46 @@ const Render = {
|
||||
// last one still applies.
|
||||
this.lastHistorySignature = null;
|
||||
this.handTimeCache.clear();
|
||||
this.watchSeatSheetHeight();
|
||||
this.renderAll();
|
||||
},
|
||||
|
||||
// How tall the seat sheet is, published the way the action bar publishes its own.
|
||||
//
|
||||
// Two things are fixed to the bottom of a phone here, and the toast is the one that
|
||||
// has to give way. It already lifts clear of the action bar — `body.hasActionBar
|
||||
// #toastHost` — because a toast sitting on Fold is a toast the dealer taps by
|
||||
// accident. The sheet is the same situation and had never been told about: opening
|
||||
// a seat put a "Keeping the screen on" toast squarely over the sheet's own rows,
|
||||
// with its Undo and its × live above them.
|
||||
//
|
||||
// Measured rather than guessed, for the same reason the bar's is: the height moves
|
||||
// when a section is unfolded, so any number written down here would be wrong as
|
||||
// soon as somebody tapped "What they've put in".
|
||||
watchSeatSheetHeight() {
|
||||
const panel = document.getElementById("seatEditorPanel");
|
||||
if (!panel || this.seatSheetWatcher) return;
|
||||
|
||||
const publish = () => {
|
||||
// Only when it is actually a sheet over the bottom of the screen. Above
|
||||
// 700px this is a panel in the sidebar column, and its height is nothing to
|
||||
// do with how much of the bottom edge is spoken for.
|
||||
const isSheet = getComputedStyle(panel).position === "fixed";
|
||||
const open = document.body.classList.contains("seatSheetOpen");
|
||||
const height = isSheet && open ? panel.getBoundingClientRect().height : 0;
|
||||
document.documentElement.style.setProperty("--seatSheetHeight", `${Math.round(height)}px`);
|
||||
};
|
||||
|
||||
this.publishSeatSheetHeight = publish;
|
||||
if (typeof ResizeObserver === "function") {
|
||||
this.seatSheetWatcher = new ResizeObserver(publish);
|
||||
this.seatSheetWatcher.observe(panel);
|
||||
}
|
||||
window.addEventListener("resize", publish);
|
||||
window.addEventListener("orientationchange", publish);
|
||||
publish();
|
||||
},
|
||||
|
||||
renderAll() {
|
||||
if (this.selectedSeatIndex !== null && this.selectedSeatIndex >= this.state.seatCount) {
|
||||
this.selectedSeatIndex = null;
|
||||
@@ -2105,6 +2142,10 @@ const Render = {
|
||||
if (this.selectedSeatIndex === null) {
|
||||
title.textContent = "Select a seat";
|
||||
body.innerHTML = '<p class="fieldHint">Click a seat at the table to edit their name, cards, chips and anything else.</p>';
|
||||
// Closing is the one size change a ResizeObserver never sees: the sheet
|
||||
// leaves by transform and visibility, and its box stays exactly as tall as
|
||||
// it was. Without this the toast would go on holding its place.
|
||||
if (this.publishSeatSheetHeight) this.publishSeatSheetHeight();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2120,8 +2161,52 @@ const Render = {
|
||||
this.renderChipCounts(form, player, index);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.renderSeatTotal(form, player);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
|
||||
body.appendChild(form);
|
||||
if (this.publishSeatSheetHeight) this.publishSeatSheetHeight();
|
||||
},
|
||||
|
||||
// Which of the sheet's three folded sections stand open, and what each says while
|
||||
// it is shut.
|
||||
//
|
||||
// The rule is the same for all three and it is about the seat rather than about the
|
||||
// dealer: a section that has something in it opens, a section that is empty stays a
|
||||
// line. That is what makes this progressive disclosure rather than a place to hide
|
||||
// things — nothing a dealer has already entered is ever one tap further away than
|
||||
// it was, and the sheet only carries the weight of what is actually on the chair.
|
||||
//
|
||||
// Hole cards are the one exception, and it is not really one: a table that asked to
|
||||
// be shown the cards is a table where every seat is about to have some, so the
|
||||
// slots open before the first card rather than after it.
|
||||
//
|
||||
// Called on open and after every commit, so a section fills in as the dealer types
|
||||
// — the glance beside "What they've put in" is the total, and it is the number the
|
||||
// boxes underneath are adding up.
|
||||
renderSeatDisclosures(form, player) {
|
||||
const set = (selector, hasContent, glance) => {
|
||||
const panel = form.querySelector(selector);
|
||||
if (!panel) return;
|
||||
// Only ever forced open. Once the dealer has opened a section by hand,
|
||||
// typing in it must not close it under them on the next keystroke, and
|
||||
// `open = false` on a re-render would do exactly that.
|
||||
if (hasContent) panel.open = true;
|
||||
const label = panel.querySelector(".seatGlance");
|
||||
if (label) label.textContent = glance || "";
|
||||
};
|
||||
|
||||
const cards = (player.cards || []).filter(Boolean);
|
||||
set(".cardsPanel", cards.length > 0 || !!this.state.trackBoard,
|
||||
cards.length ? cards.map(card => this.formatCard(card.toUpperCase())).join(" ") : "none yet");
|
||||
|
||||
const counted = Object.values(player.chipCounts || {}).some(n => Number(n) > 0);
|
||||
const contribution = HoldemState.computeContribution(
|
||||
player, this.state.chipLegend, this.state.basePotAmount);
|
||||
set(".contributionPanel", counted || player.amountOverride != null,
|
||||
Money.format(contribution));
|
||||
|
||||
const notes = (player.customFields || []).filter(field => field.label || field.value).length;
|
||||
set(".notesPanel", notes > 0, notes ? `${notes} ${notes === 1 ? "note" : "notes"}` : "");
|
||||
},
|
||||
|
||||
renderSeatTotal(form, player) {
|
||||
@@ -2133,6 +2218,11 @@ const Render = {
|
||||
this.hooks.save(this.state);
|
||||
this.updatePotBadge();
|
||||
this.renderSeatTotal(form, player);
|
||||
// The sections' summaries are downstream of nearly every field in the sheet —
|
||||
// a card picked, a chip counted, an amount typed — so they are refreshed on the
|
||||
// same path the total is. Safe on every keystroke for the same reason: this
|
||||
// writes to the summary labels and never to a field the dealer is inside.
|
||||
this.renderSeatDisclosures(form, player);
|
||||
this.refreshPuck(index);
|
||||
// The editor deliberately refreshes narrowly rather than redrawing everything —
|
||||
// a full render on every keystroke would take the name field out from under the
|
||||
@@ -2253,7 +2343,12 @@ const Render = {
|
||||
this.state.chipLegend.forEach(chip => {
|
||||
const row = template.content.firstElementChild.cloneNode(true);
|
||||
row.querySelector(".chipSwatch").style.background = chip.color;
|
||||
row.title = `$${chip.value} chips`;
|
||||
// Written into the row rather than hung off it as a `title`. A tooltip is
|
||||
// nothing on a touch screen, and this was the only thing telling five
|
||||
// identical number boxes apart — so the boxes were, in practice, unlabelled,
|
||||
// to the dealer and to a screen reader alike: the wrapping <label> had a
|
||||
// coloured dot in it and no text, which is no name at all.
|
||||
row.querySelector(".chipCountValue").textContent = Money.format(chip.value);
|
||||
const input = row.querySelector(".chipCountInput");
|
||||
input.value = player.chipCounts[chip.id] || 0;
|
||||
input.addEventListener("input", () => {
|
||||
@@ -2279,15 +2374,18 @@ const Render = {
|
||||
labelInput.addEventListener("input", () => {
|
||||
field.label = labelInput.value;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
valueInput.addEventListener("input", () => {
|
||||
field.value = valueInput.value;
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
row.querySelector(".removeFieldBtn").addEventListener("click", () => {
|
||||
player.customFields.splice(fieldIdx, 1);
|
||||
this.renderCustomFields(form, player, index);
|
||||
this.hooks.save(this.state);
|
||||
this.renderSeatDisclosures(form, player);
|
||||
});
|
||||
container.appendChild(row);
|
||||
});
|
||||
|
||||
@@ -107,9 +107,13 @@ describe("the tokens themselves", () => {
|
||||
// the first two, render.js for the seat width and the chip's denomination colour,
|
||||
// which is picked per chip from the amount on it, and feedback.js for the
|
||||
// toast's height, which is one line or two depending on what is being said.
|
||||
// The seat sheet's height is the same idea as the action bar's, and for the same
|
||||
// reason: two things fixed to the bottom of a phone, and the toast has to clear
|
||||
// whichever is taller. It changes when a section of the sheet is unfolded, so it
|
||||
// is measured rather than written down — see Render.watchSeatSheetHeight.
|
||||
const fromScript = new Set([
|
||||
"--actionBarHeight", "--feltRoomPx", "--seatPuckW", "--betChipColour",
|
||||
"--toastHeight",
|
||||
"--toastHeight", "--seatSheetHeight",
|
||||
]);
|
||||
// Defined by the site's own stylesheet, not this one.
|
||||
const fromSite = new Set(["--font-mono"]);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
// What the seat sheet shows before it is asked.
|
||||
//
|
||||
// The sheet is the second-most-used surface in the tool — every buy-in, every rename,
|
||||
// every correction goes through it — and it was the least finished. Seven things were
|
||||
// stacked flat at one weight and all open at once: a name, two status boxes, two
|
||||
// hole-card slots, a stack, a five-box chip counter with an override and a total, and a
|
||||
// list of custom fields. Measured on a 390px phone it came to 594px, and the three
|
||||
// controls a dealer touches on nearly every visit were 132px of that — 22%. The chip
|
||||
// counter alone was 174px, the tallest single thing in the sheet, and its five number
|
||||
// boxes were told apart by five coloured dots and a `title` attribute, which is a
|
||||
// tooltip, which is nothing at all on a phone.
|
||||
//
|
||||
// Two of these describe layout decisions and are checked as text — the markup for what
|
||||
// sits in front of a dealer at rest, the stylesheet for the scrim and the order two
|
||||
// bottom-anchored things stack in. The third describes behaviour and is run: which
|
||||
// sections stand open is a question about the seat, and the answer is computed.
|
||||
const test = require("node:test");
|
||||
const { describe } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { Render, makeTable } = require("./load");
|
||||
|
||||
const PAGE = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||
const CSS = fs.readFileSync(
|
||||
path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||
|
||||
const template = id => {
|
||||
const at = PAGE.indexOf(`<template id="${id}">`);
|
||||
assert.ok(at >= 0, `there is no <template id="${id}">`);
|
||||
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("what the sheet puts in front of a dealer at rest", () => {
|
||||
const seatForm = () => template("seatEditorTemplate");
|
||||
|
||||
// Everything the form holds that is *not* inside one of the folded sections. The
|
||||
// <details> blocks are stripped whole, so what is left is the flat part.
|
||||
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
|
||||
|
||||
test("the flat part is the name, the two status boxes and the stack, and nothing else", () => {
|
||||
const flat = alwaysVisible();
|
||||
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
|
||||
.forEach(cls => assert.ok(flat.includes(cls),
|
||||
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
||||
|
||||
// And the four that used to be up here with them. Each is a job most tables do
|
||||
// once a night or never, and between them they were two thirds of the sheet.
|
||||
["cardSlot", "chipCounts", "amountOverrideInput", "customFields", "addFieldBtn"]
|
||||
.forEach(cls => assert.ok(!flat.includes(cls),
|
||||
`${cls} is open at rest again — it belongs inside a folded section`));
|
||||
});
|
||||
|
||||
test("every folded section says what it holds and what is in it", () => {
|
||||
const sections = [...seatForm().matchAll(/<details class="([^"]*seatSubPanel[^"]*)">([\s\S]*?)<\/details>/g)];
|
||||
assert.equal(sections.length, 3, "the sheet folds cards, contribution and notes");
|
||||
|
||||
sections.forEach(([, className, markup]) => {
|
||||
const summary = markup.match(/<summary>([\s\S]*?)<\/summary>/);
|
||||
assert.ok(summary, `${className} has no summary to press`);
|
||||
// A name a dealer would recognise, and a place for the one fact that says
|
||||
// whether opening it is worth the tap.
|
||||
assert.match(summary[1], /<span class="seatSubPanelName">[^<]+<\/span>/,
|
||||
`${className}'s summary has no name in it`);
|
||||
assert.match(summary[1], /class="seatGlance/,
|
||||
`${className} says nothing while it is shut`);
|
||||
});
|
||||
});
|
||||
|
||||
test("the chip counter's values are text, not a tooltip", () => {
|
||||
// Five identical number boxes, each worth a different amount, told apart by a
|
||||
// coloured dot and a `title`. A title is a hover, and there is no hover here.
|
||||
// It also meant the wrapping <label> had no text in it, so the boxes had no
|
||||
// accessible name either — five unnamed spinbuttons in a row.
|
||||
assert.match(template("chipCountTemplate"), /class="chipCountValue"/,
|
||||
"the chip's value has to be in the row, where it can be read and announced");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the sheet is in front of the table, and says so", () => {
|
||||
test("there is a scrim, and it is furniture the desktop panel does not get", () => {
|
||||
assert.match(PAGE, /id="seatSheetScrim"/, "the sheet has nothing behind it");
|
||||
// Above 700px this element is not a scrim for anything — the seat form is a
|
||||
// panel in the sidebar column, with the whole page still live around it.
|
||||
const off = CSS.match(/\.seatSheetGrip,[\s\S]{0,120}?#seatSheetScrim\s*\{\s*display:\s*none/);
|
||||
assert.ok(off, "the scrim has to be off by default, with the sheet's other furniture");
|
||||
});
|
||||
|
||||
test("and it is not called an Overlay, because Modal would adopt it", () => {
|
||||
// Modal.mount() watches every id ending in "Overlay" and makes a dialog of it.
|
||||
// This one is a sidebar panel two thirds of the time.
|
||||
assert.ok(!/id="seatSheet[A-Za-z]*Overlay"/.test(PAGE));
|
||||
});
|
||||
|
||||
test("and it is put away with visibility, not with opacity", () => {
|
||||
// The same lesson the sheet above it already learned about transforms, and the
|
||||
// layout sweep caught it the same way: a fixed, full-window box at `opacity: 0`
|
||||
// is invisible and still entirely there. bottomFurniture() read it as furniture
|
||||
// standing 640px tall at the bottom of a 640px phone, in every state and on
|
||||
// every screen, and asked scroll-padding to reserve the whole window for it.
|
||||
const phone = CSS.slice(CSS.indexOf("@media (max-width: 700px)"));
|
||||
const rule = phone.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the phone block should make a scrim of it");
|
||||
assert.match(rule[0], /visibility:\s*hidden/,
|
||||
"opacity alone leaves a full-window fixed element in the layout and the a11y tree");
|
||||
});
|
||||
|
||||
test("the scrim stops where the sheet stops when a phone is on its side", () => {
|
||||
// Fold, Call and Raise are a rail down the right-hand edge in landscape, and the
|
||||
// sheet is deliberately kept clear of it. A scrim that reached across would dim
|
||||
// the one part of the screen the sheet was making room for — and swallow its
|
||||
// taps, which is worse.
|
||||
const landscape = CSS.slice(CSS.indexOf("@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px)"));
|
||||
const rule = landscape.match(/#seatSheetScrim\s*\{[^}]*\}/);
|
||||
assert.ok(rule, "the landscape block never tells the scrim about the rail");
|
||||
assert.match(rule[0], /right:\s*var\(--actionRailW\)/);
|
||||
});
|
||||
|
||||
test("the toast steps over the sheet, and the rule that says so comes last", () => {
|
||||
// Two things are fixed to the bottom of a phone and the toast is the one that
|
||||
// gives way. `body.hasActionBar #toastHost` and `body.seatSheetOpen #toastHost`
|
||||
// have identical specificity, so this is decided by source order and by nothing
|
||||
// else — a copy written above the other would look right and do nothing.
|
||||
const bar = CSS.indexOf("body.hasActionBar #toastHost");
|
||||
const sheet = CSS.indexOf("body.seatSheetOpen #toastHost");
|
||||
assert.ok(bar > 0 && sheet > 0, "both rules should exist");
|
||||
assert.ok(sheet > bar,
|
||||
"at equal specificity the later rule wins, and the sheet is the taller of the two");
|
||||
// Measured, not assumed: the height changes the moment a section is unfolded.
|
||||
assert.match(CSS.slice(sheet, sheet + 240), /var\(--seatSheetHeight/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// The form the disclosures are rendered into, reduced to the two things
|
||||
// renderSeatDisclosures asks of it: find a section, find its glance.
|
||||
const fakeForm = () => {
|
||||
const sections = {};
|
||||
for (const name of ["cardsPanel", "contributionPanel", "notesPanel"]) {
|
||||
sections[`.${name}`] = {
|
||||
open: false,
|
||||
glance: null,
|
||||
querySelector(selector) {
|
||||
assert.equal(selector, ".seatGlance");
|
||||
return { set textContent(text) { sections[`.${name}`].glance = text; } };
|
||||
},
|
||||
};
|
||||
}
|
||||
return { querySelector: selector => sections[selector] || null, sections };
|
||||
};
|
||||
|
||||
const disclose = (state, player) => {
|
||||
const form = fakeForm();
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, player);
|
||||
return form.sections;
|
||||
};
|
||||
|
||||
describe("which sections stand open is a question about the seat", () => {
|
||||
test("a seat with nothing on it opens nothing", () => {
|
||||
const state = makeTable({ seatCount: 4, stack: 200 });
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.deepEqual(
|
||||
Object.values(sections).map(s => s.open), [false, false, false],
|
||||
"an empty seat's sheet is three lines and the three fields above them");
|
||||
});
|
||||
|
||||
test("a seat holding cards opens its cards, and names them while shut", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].cards = ["as", "kd"];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".cardsPanel"].open, true);
|
||||
// Whatever formatCard makes of them — the point is that both are in it.
|
||||
assert.match(sections[".cardsPanel"].glance, /A/);
|
||||
assert.match(sections[".cardsPanel"].glance, /K/);
|
||||
});
|
||||
|
||||
test("a table that asked to be shown the cards opens them before the first one", () => {
|
||||
// Every seat at such a table is about to have two. Opening the slots after the
|
||||
// cards arrive would be opening them one tap too late, every hand.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.trackBoard = true;
|
||||
assert.equal(disclose(state, state.seats[0])[".cardsPanel"].open, true);
|
||||
});
|
||||
|
||||
test("a seat with chips counted opens the count, with the total on the row", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const fiver = state.chipLegend.find(chip => chip.value === 5);
|
||||
state.seats[0].chipCounts = { [fiver.id]: 4 };
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".contributionPanel"].open, true);
|
||||
assert.equal(sections[".contributionPanel"].glance, "$20");
|
||||
});
|
||||
|
||||
test("a typed amount counts as something on the seat too", () => {
|
||||
// Overriding the total is the other way of answering the same question, and a
|
||||
// dealer who typed a number has to be able to see it without hunting.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 60;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true);
|
||||
});
|
||||
|
||||
test("and a zero someone actually typed is not the same as never having typed one", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].amountOverride = 0;
|
||||
assert.equal(disclose(state, state.seats[0])[".contributionPanel"].open, true,
|
||||
"0 is a value; only null means the field was left alone");
|
||||
});
|
||||
|
||||
test("a seat with a note on it opens the notes and counts them", () => {
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
state.seats[0].customFields = [{ label: "Owes", value: "$40" }, { label: "", value: "" }];
|
||||
const sections = disclose(state, state.seats[0]);
|
||||
assert.equal(sections[".notesPanel"].open, true);
|
||||
assert.equal(sections[".notesPanel"].glance, "1 note",
|
||||
"an empty row the dealer has not filled in yet is not a note");
|
||||
});
|
||||
|
||||
test("a section is only ever pushed open, never pulled shut", () => {
|
||||
// This runs again after every keystroke in the sheet. A dealer who opened the
|
||||
// chip counter to start counting would have it shut under them on the first
|
||||
// digit if this ever wrote `open = false`.
|
||||
const state = makeTable({ seatCount: 4 });
|
||||
const form = fakeForm();
|
||||
form.sections[".notesPanel"].open = true;
|
||||
Render.state = state;
|
||||
Render.renderSeatDisclosures(form, state.seats[0]);
|
||||
assert.equal(form.sections[".notesPanel"].open, true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user