Ask a seat what it is doing, and put the answer where it can be seen
Two findings in the seat editor, and the second is why the first was
hard to notice.
A seat is sitting out, in the hand, or folded, and "folded" means
nothing while the seat is out. The form asked that as two checkboxes,
which offers a fourth corner that has no meaning, and the code that
stopped a dealer reaching it disabled the Fold box whenever In hand was
clear. Measured: that left the label at opacity 1 and colour
rgb(170,170,170) — identical to a live one — with a title of "Folded"
on it. Pressing Fold on a seat that was sitting out gave no movement and
no reason, which is the exact failure round 16 went through the tool to
remove; it hid from the sweep rule that finds those because the title
sat on the <label> rather than on the control the rule inspects.
Three named states can't contradict each other and none of them is ever
unavailable, so there is nothing left to dim. Sitting out · In the hand ·
Folded, as a radiogroup — the same shape the card picker's suits use —
with the chosen Folded segment in the red the seat menu's own Fold
already wears.
And the form itself. The phone block's comment has always said why the
editor becomes a bottom sheet — "the seat editor lives far enough down
the page that tapping a seat looks like it did nothing" — but the sheet
was scoped to max-width 700px while #holdemLayout goes to one column at
900. The two tiers in between had exactly the fault the sheet was
written to fix. Measured, tapping a seat:
844x390 the editor drew in the flow at y=369 in a 390px window —
21px of 401 on screen, 5% of it, at scrollY 0 with nothing
scrolling it into view. The seat lit up green and the form
it opened was under the bottom edge.
768x1024 31% visible, the rest below the fold, again no scroll.
An iPhone held sideways and an iPad held upright are not obscure. The
query is the layout's own breakpoint now, and the seat-sheet rules moved
out of the phone block into their own so it could be asked separately.
Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the
sheet, and above 900 the editor is a static block at the top of a
sidebar beside the table — 100% visible there too.
1307 tests pass; the sweep is clean at 428 audits across 13 viewports.
Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
@@ -413,15 +413,25 @@
|
|||||||
<span>Name</span>
|
<span>Name</span>
|
||||||
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
|
<input type="text" class="seatNameInput" placeholder="Seat" maxlength="24" />
|
||||||
</label>
|
</label>
|
||||||
<div class="seatCardToggles">
|
@* One question with three answers, not two boxes with four corners.
|
||||||
<label class="inHandToggle" title="This seat is playing the current hand">
|
|
||||||
<input type="checkbox" class="inHandCheckbox" />
|
A seat is sitting out, in the hand, or folded — and "folded" only means
|
||||||
<span>In hand</span>
|
anything while the seat is in. So the two checkboxes here could be ticked
|
||||||
</label>
|
into a state that means nothing, and the code that stopped them doing it
|
||||||
<label class="foldToggle" title="Folded">
|
`disabled` the Fold box whenever In hand was clear. Measured: that left the
|
||||||
<input type="checkbox" class="foldCheckbox" />
|
label at the same opacity and the same colour with a `title` on it, so a
|
||||||
<span>Fold</span>
|
dealer pressing Fold on a seat that was sitting out got no movement and no
|
||||||
</label>
|
reason — the exact thing round 16 went through the tool to remove, hiding
|
||||||
|
from the rule that finds it because the title sat on the label rather than
|
||||||
|
on the control.
|
||||||
|
|
||||||
|
Three named states cannot contradict each other and none of them is ever
|
||||||
|
unavailable, so there is nothing left to dim. The radiogroup is the same
|
||||||
|
shape the card picker's suits use. *@
|
||||||
|
<div class="seatState" role="radiogroup" aria-label="This seat">
|
||||||
|
<button type="button" class="seatStateOption" data-seat-state="out" role="radio" aria-checked="false">Sitting out</button>
|
||||||
|
<button type="button" class="seatStateOption" data-seat-state="in" role="radio" aria-checked="false">In the hand</button>
|
||||||
|
<button type="button" class="seatStateOption" data-seat-state="folded" role="radio" aria-checked="false">Folded</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="fieldRow stackRow">
|
<label class="fieldRow stackRow">
|
||||||
<span>Stack</span>
|
<span>Stack</span>
|
||||||
|
|||||||
@@ -1865,19 +1865,53 @@ button,
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatCardToggles {
|
/* Sitting out · In the hand · Folded. Three equal columns rather than three widths,
|
||||||
display: flex;
|
because the answer moves between them as the hand goes on and a segment that resizes
|
||||||
gap: var(--sp-2);
|
under the thumb reads as the control flinching. */
|
||||||
font-size: var(--fs-base);
|
.seatState {
|
||||||
color: #aaa;
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: var(--sp-0);
|
||||||
|
padding: var(--sp-0);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
background: var(--surface-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.inHandToggle,
|
.seatStateOption {
|
||||||
.foldToggle {
|
min-height: 36px;
|
||||||
display: flex;
|
padding: var(--sp-1) var(--sp-1);
|
||||||
align-items: center;
|
border: 1px solid transparent;
|
||||||
gap: var(--sp-1);
|
border-radius: var(--r-sm);
|
||||||
|
background: none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.12s ease, color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seatStateOption[aria-checked="true"] {
|
||||||
|
background: var(--surface-3);
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The one state that is a thing having gone out of the hand rather than a thing being
|
||||||
|
in it, in the red the seat menu's own Fold already wears. Only when it is the answer:
|
||||||
|
a red segment sitting unpressed beside two grey ones reads as a warning about the
|
||||||
|
seat rather than as a choice about it. */
|
||||||
|
.seatStateOption[data-seat-state="folded"][aria-checked="true"] {
|
||||||
|
background: linear-gradient(180deg, #3d1f1f, #2c1515);
|
||||||
|
border-color: #5b2626;
|
||||||
|
color: #ffd6d6;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.seatStateOption:hover[aria-checked="false"] {
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatNameInput {
|
.seatNameInput {
|
||||||
@@ -5320,8 +5354,109 @@ body.hasActionBar #toastHost {
|
|||||||
font-size: var(--fs-sm);
|
font-size: var(--fs-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---- Seat editor as a bottom sheet ---- */
|
|
||||||
|
|
||||||
|
/* The sidebar's own controls never got the touch pass the seat form did — these
|
||||||
|
are settings a dealer changes at the table, on the same phone. Written against
|
||||||
|
the panel rather than each field, so a setting added later inherits it. */
|
||||||
|
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
||||||
|
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
||||||
|
.panelBody select {
|
||||||
|
font-size: 16px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panelBody .holdemBtnSmall,
|
||||||
|
.handHistoryActions .holdemBtn,
|
||||||
|
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
||||||
|
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
||||||
|
player's cards, which is not a thing to make a dealer aim at. */
|
||||||
|
.cardPickerHeader .holdemBtn,
|
||||||
|
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
||||||
|
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
||||||
|
short of a fingertip. Growing the box costs bar height, which comes out of the
|
||||||
|
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
||||||
|
is room for it. A hit-area pad was tried first and does not work here: the pill
|
||||||
|
clips its overflow to keep a long label on one line, which clips the pad too. */
|
||||||
|
.actionUndo {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Destructive and tiny is the worst combination: these delete a chip colour or a
|
||||||
|
custom field, and were 28px wide. */
|
||||||
|
.removeChipColorBtn,
|
||||||
|
.removeFieldBtn {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* flex-shrink would take a stated width straight back off again in a row this
|
||||||
|
narrow, which is why it stayed 32px wide with the height applied. */
|
||||||
|
.chipColorInput {
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A bare checkbox can't grow into a target, but the row it sits in can — the
|
||||||
|
label is what a tap actually lands on. */
|
||||||
|
.panelBody .fieldRow {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An underlined word is a 14px-tall target; give it room without making it look
|
||||||
|
like a button. */
|
||||||
|
.linkBtn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 var(--sp-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The dealer chip's 44px tap pad used to be redeclared here, centred on the disc.
|
||||||
|
On a 72x46 puck that square started at the puck's own middle, and the seat that
|
||||||
|
had the button could not be tapped where a thumb lands. It is gone; see
|
||||||
|
.dealerChip near the top of this file for why nothing needs to replace it. */
|
||||||
|
|
||||||
|
.toastAction {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirmActions .holdemBtn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A card the width of a phone is a card a thumb can hit without aiming. Nothing to
|
||||||
|
do with the sheet below — it moved here when the seat rules moved out from
|
||||||
|
around it. */
|
||||||
|
.pickerCard {
|
||||||
|
min-height: 46px;
|
||||||
|
font-size: var(--fs-lg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Seat editor as a bottom sheet ----
|
||||||
|
|
||||||
|
Lifted out of the block above so its own question can be asked, which is not the one
|
||||||
|
that block asks. The phone block's comment has always said why the sheet exists —
|
||||||
|
"the seat editor lives far enough down the page that tapping a seat looks like it did
|
||||||
|
nothing" — and that is true of every width where #holdemLayout is one column, which
|
||||||
|
is 900px, not 700. The two tiers in between had the fault the sheet was written to
|
||||||
|
fix. Measured, tapping a seat:
|
||||||
|
|
||||||
|
844x390 the editor drew in the flow at y=369 in a 390px window — 21px of 401
|
||||||
|
on screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
|
||||||
|
The seat lit up green and the form it opened was under the bottom edge.
|
||||||
|
768x1024 31% of it visible, the rest below the fold, and again no scroll.
|
||||||
|
|
||||||
|
So the query is the layout's own breakpoint. Past 900px the sidebar is a column
|
||||||
|
beside the table and the editor is at the top of it, already in sight. */
|
||||||
|
@media (max-width: 900px) {
|
||||||
/* What the sheet is in front of.
|
/* What the sheet is in front of.
|
||||||
Lighter than the 0.72 the other six overlays use, on purpose: those replace the
|
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
|
table, and this one is a form *about* something on it. The chair being edited
|
||||||
@@ -5482,29 +5617,11 @@ body.hasActionBar #toastHost {
|
|||||||
min-height: 62px;
|
min-height: 62px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Thirteen rows of cards need to stay reachable without the sheet running off
|
.seatEditorForm .seatStateOption {
|
||||||
the bottom of a phone. */
|
|
||||||
.pickerCard {
|
|
||||||
min-height: 46px;
|
|
||||||
font-size: var(--fs-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.seatEditorForm .seatCardToggles {
|
|
||||||
gap: var(--sp-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.seatEditorForm .inHandToggle,
|
|
||||||
.seatEditorForm .foldToggle {
|
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
font-size: var(--fs-md);
|
font-size: var(--fs-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.seatEditorForm .inHandToggle input,
|
|
||||||
.seatEditorForm .foldToggle input {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.seatEditorForm .amountOverrideRow,
|
.seatEditorForm .amountOverrideRow,
|
||||||
.seatEditorForm .seatTotal {
|
.seatEditorForm .seatTotal {
|
||||||
font-size: var(--fs-md);
|
font-size: var(--fs-md);
|
||||||
@@ -5516,82 +5633,6 @@ body.hasActionBar #toastHost {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
margin-top: var(--sp-2);
|
margin-top: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The sidebar's own controls never got the touch pass the seat form did — these
|
|
||||||
are settings a dealer changes at the table, on the same phone. Written against
|
|
||||||
the panel rather than each field, so a setting added later inherits it. */
|
|
||||||
.panelBody input:not([type="checkbox"]):not([type="color"]),
|
|
||||||
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
|
|
||||||
.panelBody select {
|
|
||||||
font-size: 16px;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: var(--sp-2) var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.panelBody .holdemBtnSmall,
|
|
||||||
.handHistoryActions .holdemBtn,
|
|
||||||
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
|
|
||||||
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
|
|
||||||
player's cards, which is not a thing to make a dealer aim at. */
|
|
||||||
.cardPickerHeader .holdemBtn,
|
|
||||||
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
|
|
||||||
the wrong thing with five people waiting — the worst place to be fourteen pixels
|
|
||||||
short of a fingertip. Growing the box costs bar height, which comes out of the
|
|
||||||
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
|
|
||||||
is room for it. A hit-area pad was tried first and does not work here: the pill
|
|
||||||
clips its overflow to keep a long label on one line, which clips the pad too. */
|
|
||||||
.actionUndo {
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Destructive and tiny is the worst combination: these delete a chip colour or a
|
|
||||||
custom field, and were 28px wide. */
|
|
||||||
.removeChipColorBtn,
|
|
||||||
.removeFieldBtn {
|
|
||||||
min-width: 44px;
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* flex-shrink would take a stated width straight back off again in a row this
|
|
||||||
narrow, which is why it stayed 32px wide with the height applied. */
|
|
||||||
.chipColorInput {
|
|
||||||
flex: none;
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A bare checkbox can't grow into a target, but the row it sits in can — the
|
|
||||||
label is what a tap actually lands on. */
|
|
||||||
.panelBody .fieldRow {
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* An underlined word is a 14px-tall target; give it room without making it look
|
|
||||||
like a button. */
|
|
||||||
.linkBtn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 44px;
|
|
||||||
padding: 0 var(--sp-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* The dealer chip's 44px tap pad used to be redeclared here, centred on the disc.
|
|
||||||
On a 72x46 puck that square started at the puck's own middle, and the seat that
|
|
||||||
had the button could not be tapped where a thumb lands. It is gone; see
|
|
||||||
.dealerChip near the top of this file for why nothing needs to replace it. */
|
|
||||||
|
|
||||||
.toastAction {
|
|
||||||
min-height: 44px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmActions {
|
|
||||||
flex-direction: column-reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirmActions .holdemBtn {
|
|
||||||
width: 100%;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The seat sheet, for anyone who asked not to be moved.
|
/* The seat sheet, for anyone who asked not to be moved.
|
||||||
@@ -6336,6 +6377,14 @@ h2[data-modal-focus]:focus-visible {
|
|||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
padding: var(--sp-2) var(--sp-2);
|
padding: var(--sp-2) var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sized by the pointer rather than by the width, because a wide screen can still be
|
||||||
|
a touch screen. The sheet block sizes these too, and has to: it is the width that
|
||||||
|
decides whether the seat form is a sheet with a phone's room in it. This is the
|
||||||
|
floor for everywhere else. */
|
||||||
|
.seatStateOption {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.toastClose::after {
|
.toastClose::after {
|
||||||
|
|||||||
@@ -2439,6 +2439,65 @@ const Render = {
|
|||||||
this.renderPanelGlances();
|
this.renderPanelGlances();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// The three things a seat can be, in the order a hand goes through them. `folded`
|
||||||
|
// is only meaningful while `inHand` is set, which is why these are one choice and
|
||||||
|
// not two: the pair (out, folded) has no meaning, and the old two-checkbox control
|
||||||
|
// could be asked for it.
|
||||||
|
SEAT_STATES: [
|
||||||
|
{ key: "out", label: "Sitting out", inHand: false, folded: false },
|
||||||
|
{ key: "in", label: "In the hand", inHand: true, folded: false },
|
||||||
|
{ key: "folded", label: "Folded", inHand: true, folded: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
seatStateOf(player) {
|
||||||
|
if (!player.inHand) return "out";
|
||||||
|
return player.folded ? "folded" : "in";
|
||||||
|
},
|
||||||
|
|
||||||
|
// Draw the current answer onto the three buttons. Called on every paint and again
|
||||||
|
// whenever something other than a press changes the seat — typing a name into an
|
||||||
|
// empty chair seats somebody, and the control has to say so.
|
||||||
|
paintSeatState(form, player) {
|
||||||
|
const current = this.seatStateOf(player);
|
||||||
|
form.querySelectorAll(".seatStateOption").forEach(btn => {
|
||||||
|
const chosen = btn.dataset.seatState === current;
|
||||||
|
btn.setAttribute("aria-checked", chosen ? "true" : "false");
|
||||||
|
// One Tab stop for the three, the way the APG asks: Tab reaches the group,
|
||||||
|
// the arrows move inside it.
|
||||||
|
btn.tabIndex = chosen ? 0 : -1;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
wireSeatState(form, player, index) {
|
||||||
|
const options = [...form.querySelectorAll(".seatStateOption")];
|
||||||
|
const choose = (key, { focus = false } = {}) => {
|
||||||
|
const state = this.SEAT_STATES.find(entry => entry.key === key);
|
||||||
|
if (!state) return;
|
||||||
|
player.inHand = state.inHand;
|
||||||
|
player.folded = state.folded;
|
||||||
|
this.paintSeatState(form, player);
|
||||||
|
if (focus) {
|
||||||
|
const chosen = options.find(btn => btn.dataset.seatState === key);
|
||||||
|
if (chosen) chosen.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
this.commitSeatChange(form, player, index);
|
||||||
|
};
|
||||||
|
|
||||||
|
options.forEach(btn => {
|
||||||
|
btn.addEventListener("click", () => choose(btn.dataset.seatState));
|
||||||
|
btn.addEventListener("keydown", event => {
|
||||||
|
const step = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[event.key];
|
||||||
|
if (step === undefined) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const at = this.SEAT_STATES.findIndex(entry => entry.key === this.seatStateOf(player));
|
||||||
|
const to = (at + step + this.SEAT_STATES.length) % this.SEAT_STATES.length;
|
||||||
|
choose(this.SEAT_STATES[to].key, { focus: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.paintSeatState(form, player);
|
||||||
|
},
|
||||||
|
|
||||||
wireSeatFields(form, player, index) {
|
wireSeatFields(form, player, index) {
|
||||||
const nameInput = form.querySelector(".seatNameInput");
|
const nameInput = form.querySelector(".seatNameInput");
|
||||||
nameInput.value = player.name;
|
nameInput.value = player.name;
|
||||||
@@ -2457,32 +2516,13 @@ const Render = {
|
|||||||
if (wasEmpty && player.name.trim()) {
|
if (wasEmpty && player.name.trim()) {
|
||||||
player.inHand = true;
|
player.inHand = true;
|
||||||
player.folded = false;
|
player.folded = false;
|
||||||
const inHandBox = form.querySelector(".inHandCheckbox");
|
this.paintSeatState(form, player);
|
||||||
if (inHandBox) inHandBox.checked = true;
|
|
||||||
const foldBox = form.querySelector(".foldCheckbox");
|
|
||||||
if (foldBox) { foldBox.checked = false; foldBox.disabled = false; }
|
|
||||||
}
|
}
|
||||||
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
document.getElementById("seatEditorTitle").textContent = this.puckLabel(player, index);
|
||||||
this.commitSeatChange(form, player, index);
|
this.commitSeatChange(form, player, index);
|
||||||
});
|
});
|
||||||
|
|
||||||
const inHandCheckbox = form.querySelector(".inHandCheckbox");
|
this.wireSeatState(form, player, index);
|
||||||
inHandCheckbox.checked = player.inHand;
|
|
||||||
inHandCheckbox.addEventListener("change", () => {
|
|
||||||
player.inHand = inHandCheckbox.checked;
|
|
||||||
if (!player.inHand) player.folded = false;
|
|
||||||
foldCheckboxRef.checked = player.folded;
|
|
||||||
foldCheckboxRef.disabled = !player.inHand;
|
|
||||||
this.commitSeatChange(form, player, index);
|
|
||||||
});
|
|
||||||
|
|
||||||
const foldCheckboxRef = form.querySelector(".foldCheckbox");
|
|
||||||
foldCheckboxRef.checked = player.folded;
|
|
||||||
foldCheckboxRef.disabled = !player.inHand;
|
|
||||||
foldCheckboxRef.addEventListener("change", () => {
|
|
||||||
player.folded = foldCheckboxRef.checked;
|
|
||||||
this.commitSeatChange(form, player, index);
|
|
||||||
});
|
|
||||||
|
|
||||||
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
const slots = [form.querySelector(".card1Slot"), form.querySelector(".card2Slot")];
|
||||||
slots.forEach((slot, cardIdx) => {
|
slots.forEach((slot, cardIdx) => {
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ describe("what the sheet puts in front of a dealer at rest", () => {
|
|||||||
// <details> blocks are stripped whole, so what is left is the flat part.
|
// <details> blocks are stripped whole, so what is left is the flat part.
|
||||||
const alwaysVisible = () => seatForm().replace(/<details[\s\S]*?<\/details>/g, "");
|
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", () => {
|
test("the flat part is the name, what the seat is doing and the stack, and nothing else", () => {
|
||||||
const flat = alwaysVisible();
|
const flat = alwaysVisible();
|
||||||
["seatNameInput", "inHandCheckbox", "foldCheckbox", "stackInput", "seatBuyIns"]
|
["seatNameInput", "seatState", "seatStateOption", "stackInput", "seatBuyIns"]
|
||||||
.forEach(cls => assert.ok(flat.includes(cls),
|
.forEach(cls => assert.ok(flat.includes(cls),
|
||||||
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
`${cls} is what a dealer opens a seat for — it cannot be behind a disclosure`));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
// What a seat is doing, asked once.
|
||||||
|
//
|
||||||
|
// The seat editor had two checkboxes, "In hand" and "Fold". A seat is really in one of
|
||||||
|
// three states — sitting out, in the hand, folded — and "folded" means nothing while
|
||||||
|
// the seat is out, so two boxes offered a fourth corner that has no meaning. The code
|
||||||
|
// that stopped a dealer reaching it `disabled` the Fold box whenever In hand was clear.
|
||||||
|
//
|
||||||
|
// Measured: that left the label at opacity 1 and colour rgb(170,170,170) — identical to
|
||||||
|
// a live one — with a `title` of "Folded" on it. So pressing Fold on a seat that was
|
||||||
|
// sitting out gave no movement and no reason, which is the exact failure round 16 went
|
||||||
|
// through the whole tool to remove. It hid from the sweep rule that finds those because
|
||||||
|
// the title sat on the <label> rather than on the control the rule inspects.
|
||||||
|
//
|
||||||
|
// Three named states cannot contradict each other and none of them is ever unavailable,
|
||||||
|
// so there is nothing left to dim.
|
||||||
|
//
|
||||||
|
// The second half of this round is where that form appears at all. The phone block's
|
||||||
|
// own comment has always said why the editor becomes a bottom sheet — "the seat editor
|
||||||
|
// lives far enough down the page that tapping a seat looks like it did nothing" — but
|
||||||
|
// the sheet was scoped to 700px while #holdemLayout goes to one column at 900. The two
|
||||||
|
// tiers in between had precisely the fault the sheet was written to fix. Measured,
|
||||||
|
// tapping a seat:
|
||||||
|
//
|
||||||
|
// 844x390 the editor drew in the flow at y=369 in a 390px window: 21px of 401 on
|
||||||
|
// screen, 5% of it, at scrollY 0 with nothing scrolling it into view.
|
||||||
|
// 768x1024 31% visible, the rest below the fold, and again no scroll.
|
||||||
|
//
|
||||||
|
// Afterwards every viewport from 360x640 to 900x500 shows 97-99% of the sheet, and
|
||||||
|
// above 900 the editor is a static block at the top of a sidebar that is beside the
|
||||||
|
// table rather than below it — 100% visible there too.
|
||||||
|
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 ROOT = path.join(__dirname, "..", "..", "JoshHeaps.Net");
|
||||||
|
const PAGE = fs.readFileSync(path.join(ROOT, "Pages", "Casino", "TexasHoldem.cshtml"), "utf8");
|
||||||
|
const CSS = fs.readFileSync(path.join(ROOT, "wwwroot", "css", "casino", "holdem.css"), "utf8");
|
||||||
|
|
||||||
|
const seatForm = () => {
|
||||||
|
const at = PAGE.indexOf('<template id="seatEditorTemplate">');
|
||||||
|
assert.ok(at >= 0, "there is no seat editor template");
|
||||||
|
return PAGE.slice(at, PAGE.indexOf("</template>", at));
|
||||||
|
};
|
||||||
|
|
||||||
|
const ruleFor = selector => {
|
||||||
|
const at = CSS.indexOf(`\n${selector} {`);
|
||||||
|
assert.ok(at >= 0, `there is no \`${selector}\` rule`);
|
||||||
|
return CSS.slice(at, CSS.indexOf("}", at));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Everything inside every block with this query, braces balanced and joined. Every one
|
||||||
|
// of them: this stylesheet opens `@media (hover: none)` twice, and a helper that stops
|
||||||
|
// at the first has twice caught a test asserting about the wrong half of the file.
|
||||||
|
function blockAt(query) {
|
||||||
|
const found = [];
|
||||||
|
for (let at = CSS.indexOf(query); at >= 0; at = CSS.indexOf(query, at + 1)) {
|
||||||
|
let depth = 0;
|
||||||
|
for (let i = at + query.length - 1; i < CSS.length; i++) {
|
||||||
|
if (CSS[i] === "{") depth++;
|
||||||
|
else if (CSS[i] === "}" && --depth === 0) { found.push(CSS.slice(at, i + 1)); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(found.length, `there is no \`${query}\` block`);
|
||||||
|
return found.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// The three buttons, and enough of a form to hand them back.
|
||||||
|
function fakeForm() {
|
||||||
|
const options = Render.SEAT_STATES.map(state => ({
|
||||||
|
dataset: { seatState: state.key },
|
||||||
|
attrs: {},
|
||||||
|
tabIndex: 0,
|
||||||
|
handlers: {},
|
||||||
|
setAttribute(name, value) { this.attrs[name] = value; },
|
||||||
|
getAttribute(name) { return this.attrs[name]; },
|
||||||
|
addEventListener(type, fn) { this.handlers[type] = fn; },
|
||||||
|
focus() { options.focused = this.dataset.seatState; },
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
options,
|
||||||
|
querySelectorAll: selector => {
|
||||||
|
assert.equal(selector, ".seatStateOption");
|
||||||
|
return options;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const checked = form => form.options.filter(o => o.getAttribute("aria-checked") === "true")
|
||||||
|
.map(o => o.dataset.seatState);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("three states, not two booleans", () => {
|
||||||
|
test("the markup asks one question with three answers", () => {
|
||||||
|
const form = seatForm();
|
||||||
|
const at = form.indexOf('class="seatState"');
|
||||||
|
assert.ok(at >= 0, "the seat form has no state control");
|
||||||
|
const group = form.slice(form.lastIndexOf("<div", at), form.indexOf("</div>", at));
|
||||||
|
assert.match(group, /role="radiogroup"/);
|
||||||
|
assert.match(group, /aria-label="This seat"/);
|
||||||
|
["out", "in", "folded"].forEach(key => {
|
||||||
|
assert.match(group, new RegExp(`data-seat-state="${key}"`), `there is no "${key}" answer`);
|
||||||
|
});
|
||||||
|
assert.equal((group.match(/role="radio"/g) || []).length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and the two checkboxes are gone from the page and the stylesheet", () => {
|
||||||
|
["inHandCheckbox", "foldCheckbox", "inHandToggle", "foldToggle", "seatCardToggles"]
|
||||||
|
.forEach(name => {
|
||||||
|
assert.ok(!PAGE.includes(name), `${name} is still in the markup`);
|
||||||
|
assert.ok(!CSS.includes(name), `${name} is still in the stylesheet`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The pair (out, folded) is the one the old control could be asked for.
|
||||||
|
test("every state names both halves, and none of them is the meaningless pair", () => {
|
||||||
|
assert.deepEqual(Render.SEAT_STATES.map(s => s.key), ["out", "in", "folded"]);
|
||||||
|
Render.SEAT_STATES.forEach(state => {
|
||||||
|
assert.equal(typeof state.inHand, "boolean");
|
||||||
|
assert.equal(typeof state.folded, "boolean");
|
||||||
|
assert.ok(!(state.folded && !state.inHand),
|
||||||
|
`"${state.key}" is folded and not in the hand, which means nothing`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and a seat reads back as the state it was put in", () => {
|
||||||
|
Render.SEAT_STATES.forEach(state => {
|
||||||
|
assert.equal(Render.seatStateOf({ inHand: state.inHand, folded: state.folded }), state.key);
|
||||||
|
});
|
||||||
|
// Including a seat saved before any of this, where folded was left set on a
|
||||||
|
// chair that had sat out.
|
||||||
|
assert.equal(Render.seatStateOf({ inHand: false, folded: true }), "out");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what the control draws", () => {
|
||||||
|
test("exactly one answer is marked, and it is the seat's", () => {
|
||||||
|
const form = fakeForm();
|
||||||
|
Render.paintSeatState(form, { inHand: true, folded: true });
|
||||||
|
assert.deepEqual(checked(form), ["folded"]);
|
||||||
|
Render.paintSeatState(form, { inHand: false, folded: false });
|
||||||
|
assert.deepEqual(checked(form), ["out"]);
|
||||||
|
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||||
|
assert.deepEqual(checked(form), ["in"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// One stop for the group, arrows inside it — the APG's radio group, and the same
|
||||||
|
// shape the card picker's suits use.
|
||||||
|
test("and exactly one of the three is a Tab stop", () => {
|
||||||
|
const form = fakeForm();
|
||||||
|
Render.paintSeatState(form, { inHand: true, folded: false });
|
||||||
|
assert.deepEqual(form.options.map(o => o.tabIndex), [-1, 0, -1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("what pressing one does", () => {
|
||||||
|
const wired = player => {
|
||||||
|
const form = fakeForm();
|
||||||
|
const commits = [];
|
||||||
|
const realCommit = Render.commitSeatChange;
|
||||||
|
Render.commitSeatChange = () => commits.push(true);
|
||||||
|
try {
|
||||||
|
Render.wireSeatState(form, player, 0);
|
||||||
|
} finally {
|
||||||
|
Render.commitSeatChange = realCommit;
|
||||||
|
}
|
||||||
|
return { form, commits, press: key => {
|
||||||
|
const btn = form.options.find(o => o.dataset.seatState === key);
|
||||||
|
const again = Render.commitSeatChange;
|
||||||
|
Render.commitSeatChange = () => commits.push(true);
|
||||||
|
try { btn.handlers.click(); } finally { Render.commitSeatChange = again; }
|
||||||
|
} };
|
||||||
|
};
|
||||||
|
|
||||||
|
test("it writes both halves of the seat, not one", () => {
|
||||||
|
const player = { inHand: true, folded: false };
|
||||||
|
const { press } = wired(player);
|
||||||
|
press("folded");
|
||||||
|
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: true });
|
||||||
|
press("out");
|
||||||
|
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: false, folded: false });
|
||||||
|
press("in");
|
||||||
|
assert.deepEqual({ inHand: player.inHand, folded: player.folded }, { inHand: true, folded: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and says so, so the table redraws", () => {
|
||||||
|
const player = { inHand: true, folded: false };
|
||||||
|
const { commits, press } = wired(player);
|
||||||
|
const before = commits.length;
|
||||||
|
press("out");
|
||||||
|
assert.ok(commits.length > before, "nothing was committed, so the felt still shows the old state");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing a name into an empty chair seats somebody. The control has to say so —
|
||||||
|
// it is the only thing on the form that shows it.
|
||||||
|
test("naming an empty chair repaints the control rather than leaving it stale", () => {
|
||||||
|
const src = fs.readFileSync(
|
||||||
|
path.join(ROOT, "wwwroot", "js", "casino", "holdem", "render.js"), "utf8");
|
||||||
|
const at = src.indexOf("const wasEmpty = !player.name.trim()");
|
||||||
|
assert.ok(at >= 0);
|
||||||
|
assert.match(src.slice(at, at + 400), /this\.paintSeatState\(form, player\);/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("how it is drawn", () => {
|
||||||
|
// The answer moves between the three as the hand goes on, and a segment that
|
||||||
|
// resizes under the thumb reads as the control flinching.
|
||||||
|
test("three equal columns, whatever the words in them", () => {
|
||||||
|
assert.match(ruleFor(".seatState"), /grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the chosen one is the one that looks pressed", () => {
|
||||||
|
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /background:\s*var\(--surface-3\)/);
|
||||||
|
assert.match(ruleFor('.seatStateOption[aria-checked="true"]'), /color:\s*var\(--text-bright\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The same red the seat menu's own Fold wears, and only when it is the answer: a red
|
||||||
|
// segment sitting unpressed beside two grey ones reads as a warning about the seat.
|
||||||
|
test("folded is red, and only once it is chosen", () => {
|
||||||
|
const rule = ruleFor('.seatStateOption[data-seat-state="folded"][aria-checked="true"]');
|
||||||
|
assert.match(rule, /#3d1f1f/);
|
||||||
|
assert.ok(!/\n\.seatStateOption\[data-seat-state="folded"\] \{/.test(CSS),
|
||||||
|
"an unchosen Folded segment is painted red");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and a thumb gets its 44px wherever there is no pointer", () => {
|
||||||
|
assert.match(blockAt("@media (hover: none) {"), /\.seatStateOption \{\s*min-height: 44px;/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("where the form appears at all", () => {
|
||||||
|
// The sheet exists because a stacked sidebar puts the editor far below the felt.
|
||||||
|
// That is true at every width where the layout is one column.
|
||||||
|
test("the sheet is scoped to the breakpoint that stacks the layout", () => {
|
||||||
|
const oneColumn = blockAt("@media (max-width: 900px) {");
|
||||||
|
assert.match(oneColumn, /#holdemLayout \{\s*grid-template-columns: 1fr;/,
|
||||||
|
"900px is no longer where the sidebar stops being a column beside the table");
|
||||||
|
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||||
|
const query = sheet.slice(sheet.indexOf("@media"), sheet.indexOf("{", sheet.indexOf("@media")) + 1);
|
||||||
|
assert.equal(query.trim(), "@media (max-width: 900px) {");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("and it is a sheet, fixed to the bottom, not a block in the flow", () => {
|
||||||
|
const sheet = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"));
|
||||||
|
const at = sheet.indexOf(" #seatEditorPanel {");
|
||||||
|
assert.ok(at >= 0, "the sheet block no longer positions the panel");
|
||||||
|
const rule = sheet.slice(at, sheet.indexOf("}", at));
|
||||||
|
assert.match(rule, /position:\s*fixed/);
|
||||||
|
assert.match(rule, /bottom:\s*0/);
|
||||||
|
assert.match(rule, /max-height:\s*74vh/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what was measured is written beside the query", () => {
|
||||||
|
const note = CSS.slice(CSS.indexOf("---- Seat editor as a bottom sheet ----"),
|
||||||
|
CSS.indexOf("@media (max-width: 900px) {", CSS.indexOf("---- Seat editor as a bottom sheet ----")));
|
||||||
|
assert.match(note, /844x390/);
|
||||||
|
assert.match(note, /768x1024/);
|
||||||
|
assert.match(note, /5% of it/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user