Say what the undo would take back, and let the empty chairs be empty

Two things on the felt and the bar that were spending weight on the wrong
half of what they had to say.

The undo pill names the last action, and on a phone it had stopped doing
it. It is a fixed width — its text changes on every tap, and a control
that resizes under a thumb reads as the interface twitching — and that
width leaves the label 73px. Measured across one hand, four of the five
labels a hand produces wanted more:

  ↶ dealing the hand        104     ↶ Christopher's fold      109
  ↶ Mike from work's fold   128     ↶ Alexandra's fold        100
  ↶ Dee's fold               69  (the only one that fitted)

so the safety net spent most of a night reading "↶ dealing …". Cut as one
string it keeps the wrong half: "↶ Mike from wor…" names everybody except
the action. So an action's name is handed to commit() in two halves now —
who it was, and what they did — and the actor comes off whole when the
act cannot fit beside it. The whole sentence stays on the button's title
and aria-label, which is what the guidance asks of a control that has had
to shorten its own name.

Two things fell out of it. The label cannot overflow — the act inside it
is the item with `overflow: hidden`, so it swallows the excess and the row
measures as though everything fitted — which is why the fit asks the act
and not the label; asked the label, "↶ dealing the hand" kept its actor
and quietly cut "the hand" off inside it. And the pill was still printing
a keycap on phones: every other control on the bar has dropped its cap
under `hover: none` since that rule was written, but this one is a
different class and had been left out of it, so the narrowest label on the
bar was the only one spending 22px on a shortcut nothing could press.
That 22px is exactly what "raise to $1,200" needed to keep its digits.

Nothing is cut now at any viewport in the sweep: a phone reads "↶ the
hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
landscape rail keep the actor wherever it fits.

And the empty chairs. An open seat was drawn as a whole puck of near-black
on the green — the same box a seated player gets, at the same weight,
saying nothing. Five players nine-handed is four of them, and on the felt
they read as holes punched in the table: the eye had to sort the loud
empty boxes from the loud full ones before it could find who was in the
hand. The wash was there for one reason, which is that the chair number's
contrast cannot depend on whether its chair sits over the rim, the dark
green or the light — about Lc 5 across one ring. So the wash moved off the
chair and under the number, which is the only thing an open chair has to
say. The dashes still say "open", the box a thumb lands on has not moved a
pixel, and the felt gets four boxes' worth of quiet back.

1039 unit tests; 418 layout audits across 13 viewports, clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
This commit is contained in:
2026-09-10 14:13:25 -06:00
co-authored by Claude Opus 5
parent d4c39f209a
commit 8136025544
5 changed files with 387 additions and 40 deletions
+69 -6
View File
@@ -1001,12 +1001,40 @@ button,
about two points of Lc and cost four dark rectangles sitting on the felt like holes. about two points of Lc and cost four dark rectangles sitting on the felt like holes.
Taking the lighter one is free. */ Taking the lighter one is free. */
.seat.seatEmpty { .seat.seatEmpty {
background: rgba(18, 18, 18, 0.62); background: rgba(0, 0, 0, 0.28);
border-style: dashed; border-style: dashed;
border-color: rgba(255, 255, 255, 0.22); border-color: rgba(255, 255, 255, 0.2);
box-shadow: none; box-shadow: none;
} }
/* The ground moved off the chair and under the one thing on it.
An open chair was drawn as a whole puck of near-black on the green — the same box a
seated player gets, at the same weight, saying nothing. Five players nine-handed is
four of them, and on the felt they read as holes punched in the table rather than as
chairs nobody is in: the eye has to sort the loud empty boxes from the loud full ones
before it can find who is in the hand.
The wash is off the chair now and under its number, which is the only thing an open
chair has to say. That keeps what the wash was actually for — the number's contrast
cannot depend on whether its chair happens to sit over the rim, the dark green or the
light, which is a difference of about Lc 5 across one ring — while giving the felt
back four boxes' worth of quiet. The dashes and the outline still say "open", which
they always did, and the box a thumb lands on has not moved a pixel.
Centred rather than in the corner, because on an empty chair the number is the label
and not a reference mark on something else. */
.seatEmpty .seatChairNumber {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
min-width: 18px;
padding: 0 var(--sp-1);
border-radius: var(--r-pill);
background: rgba(18, 18, 18, 0.72);
text-align: center;
}
.seatEmptyLabel { .seatEmptyLabel {
color: var(--text-dim); color: var(--text-dim);
font-size: var(--fs-2xs); font-size: var(--fs-2xs);
@@ -3625,12 +3653,39 @@ body.hasActionBar #holdemApp {
} }
/* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */ /* The undo pill's key, inline rather than pinned to a corner — see actionBar.js. */
/* The half of the pill that gives: the label shortens, the keycap never does. */ /* The half of the pill that gives: the label shortens, the keycap never does.
And which part of the label shortens is decided too. It is three items rather than one
string — the arrow, the actor, the act — so that a pill too narrow for all of it drops
the actor whole instead of cutting the sentence off wherever the ellipsis lands. On a
phone this label has 73px and "↶ Mike from work's fold" wants 128; cut, that reads
"↶ Mike from wor…", which names everything except the thing you would be taking back.
See ActionBar.fitUndoLabel. */
.actionUndoLabel { .actionUndoLabel {
display: flex;
align-items: baseline;
gap: var(--sp-1);
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.actionUndoGlyph,
.actionUndoLead {
flex: none;
}
.actionUndoLead[hidden] {
display: none;
}
/* The half that stays. It can still ellipsize — "raise to $1,200" is 69px of the 59 the
label has on the narrowest phone once the arrow is drawn — and it is the only act that
ever does. */
.actionUndoAct {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.actionUndoKey { .actionUndoKey {
@@ -3675,9 +3730,17 @@ body.hasActionBar #holdemApp {
} }
/* No keyboard, no key badges — on a phone they'd just be clutter on the buttons /* No keyboard, no key badges — on a phone they'd just be clutter on the buttons
the dealer is already tapping. */ the dealer is already tapping.
The undo pill's cap is a different class (see .actionUndoKey: this one pins itself to
a corner and that one sits inline) and had been left out of this rule since the day it
was written, so the one control on the bar too narrow to spare the room was the only
one still printing a shortcut nothing could press. It is 18px and a 4px gap of a 73px
label — enough, on its own, for "raise to $1,200" to keep its last three digits. The
Keyboard panel still lists the shortcut for anyone who has a keyboard to use it. */
@media (hover: none) { @media (hover: none) {
.actionKey { .actionKey,
.actionUndoKey {
display: none; display: none;
} }
} }
@@ -247,6 +247,7 @@ const ActionBar = {
// After the tank timer, because whether the stopwatch and the clock button are // After the tank timer, because whether the stopwatch and the clock button are
// on the acting line is what decides how much room the name has left. // on the acting line is what decides how much room the name has left.
this.fitActingLine(); this.fitActingLine();
this.fitUndoLabel();
this.showRailEnd(host); this.showRailEnd(host);
this.announce(); this.announce();
}, },
@@ -499,30 +500,43 @@ const ActionBar = {
// The label goes in its own element rather than straight onto the button. // The label goes in its own element rather than straight onto the button.
// The pill is a fixed width — it names the last action, so its text changes // The pill is a fixed width — it names the last action, so its text changes
// on every tap, and a control that resizes under the dealer's thumb reads // on every tap, and a control that resizes under the dealer's thumb reads
// as the interface twitching. Fixed width means long labels have to // as the interface twitching. Fixed width means long labels have to give
// ellipsize, and only a real element can: as a bare text node the label // something up, and only a real element can: as a bare text node the label
// truncated *through* the keycap below it, which then sat squashed against // truncated *through* the keycap below it, which then sat squashed against
// the cut-off text looking like a rendering fault. // the cut-off text looking like a rendering fault.
// //
// Known, measured, and not fixed here: on a phone this label has 73px and // Three elements, because what it gives up matters. Measured on a phone, the
// four of the five labels a hand produces want more than that — // label has 73px and four of the five labels a hand produces want more:
// //
// ↶ dealing the hand 104 ↶ Christopher's fold 109 // ↶ dealing the hand 104 ↶ Christopher's fold 109
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100 // ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
// ↶ Dee's fold 69 (the only one that fits) // ↶ Dee's fold 69 (the only one that fitted)
// //
// so the pill reads "↶ dealing …" most of the night. Widening it is not // so the pill spent most of a night reading "↶ dealing …" — a safety net
// available — the room it would take is the room the name beside it needs, // that would not say what it was a net for. Widening it is not available:
// and that is what fitActingLine is spending. The answer is the same shape // the room it would take is the room the acting name beside it needs, and
// as the acting line's: the actor and the act as two spans, the actor // that is what fitActingLine is already spending. So the actor comes off
// shrinking first, so "Mike from work's fold" degrades to "Mike f…'s fold" // instead, whole, and the act stays — see fitUndoLabel.
// rather than to "Mike from wor…" and the *act* — the half that says what
// would come back — survives. That needs the two halves to come from the
// call sites rather than be guessed out of a string with an apostrophe in
// it, which is a change to what commit() is handed, and a round of its own.
const label = document.createElement("span"); const label = document.createElement("span");
label.className = "actionUndoLabel"; label.className = "actionUndoLabel";
label.textContent = `↶ ${undoLabel}`;
const glyph = document.createElement("span");
glyph.className = "actionUndoGlyph";
glyph.textContent = "↶";
label.appendChild(glyph);
const parts = this.undoStep();
if (parts.lead) {
const lead = document.createElement("span");
lead.className = "actionUndoLead";
lead.textContent = parts.lead;
label.appendChild(lead);
}
const act = document.createElement("span");
act.className = "actionUndoAct";
act.textContent = parts.act;
label.appendChild(act);
undo.appendChild(label); undo.appendChild(label);
// U undoes, and nothing said so — this control was the only place a dealer // U undoes, and nothing said so — this control was the only place a dealer
// could have learned it, and it was the one action bar control without its // could have learned it, and it was the one action bar control without its
@@ -777,6 +791,41 @@ const ActionBar = {
// not lost — it is on that player's own seat puck, a couple of inches up the same // not lost — it is on that player's own seat puck, a couple of inches up the same
// screen, which is where a dealer counting somebody down is looking anyway. The name // screen, which is where a dealer counting somebody down is looking anyway. The name
// is the thing the bar alone is saying. // is the thing the bar alone is saying.
// The last action in the two halves the pill draws it in. The history is main.js's,
// because undo pops it — remembering the halves here would name the step that was
// just taken back rather than the one now on top. A caller that has not been given
// the halves gets the label whole, with nothing to drop, which is what it had before.
undoStep() {
const label = this.hooks.lastActionLabel();
const parts = this.hooks.lastActionParts && this.hooks.lastActionParts();
return parts || { lead: "", act: label };
},
// Which half of "Christopher's fold" the pill can hold.
//
// The actor comes off whole rather than being cut down: "Christo…'s fold" is a worse
// sentence than "fold", and the question a hand later is what came back rather than
// whose it was — the dealer has the seat in front of them. The whole of it stays on
// the button's title and aria-label, which is what the guidance asks of a control
// that has had to shorten its own name.
fitUndoLabel() {
const label = document.querySelector("#actionBar .actionUndoLabel");
const lead = label && label.querySelector(".actionUndoLead");
const act = label && label.querySelector(".actionUndoAct");
if (!lead || !act) return;
// The act is what gets asked, not the label. The label cannot overflow — the act
// inside it is the item with `overflow: hidden`, so it swallows the excess and
// the row it sits in measures as though everything fitted. Asking the label was
// the first way this was written and it read "↶ dealing the hand" with "the
// hand" quietly cut off inside it, on the one label short enough that the actor
// alone still fitted.
//
// Measured with the actor back on, so the answer is a property of the pill
// rather than of whatever the last render left behind.
lead.hidden = false;
if (act.scrollWidth > act.clientWidth + 1) lead.hidden = true;
},
fitActingLine() { fitActingLine() {
const name = document.querySelector("#actionBar .actionWhoName"); const name = document.querySelector("#actionBar .actionWhoName");
const stack = document.querySelector("#actionBar .actionWhoStack"); const stack = document.querySelector("#actionBar .actionWhoStack");
@@ -907,12 +956,35 @@ const ActionBar = {
// would actually take back. // would actually take back.
if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss(); if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss();
this.hooks.beginAction(label); const step = this.undoParts(label);
this.hooks.beginAction(step.long, step);
mutate(); mutate();
this.hooks.save(this.state); this.hooks.save(this.state);
this.hooks.rerender(); this.hooks.rerender();
}, },
// An action's name in the two halves the undo pill needs it in: who it was, and
// what they did.
//
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching —
// and on a phone that width leaves the label 73px. Measured across one hand, four of
// the five labels a hand produces want more than that: "↶ dealing the hand" 104,
// "↶ Christopher's fold" 109, "↶ Mike from work's fold" 128, "↶ Alexandra's fold"
// 100. Only "↶ Dee's fold" fitted, at 69. So the pill spent most of a night reading
// "↶ dealing …" — a safety net that would not say what it was a net for.
//
// Given the halves it can drop one. The actor is the half that goes, because the
// dealer has the seat in front of them and the question a hand later is *what* came
// back, not who it belonged to; and because "Christo…'s fold" is a worse sentence
// than "fold". A label handed over as one string keeps working and simply has
// nothing to drop.
undoParts(label) {
const lead = typeof label === "string" ? "" : (label.lead || "");
const act = typeof label === "string" ? label : label.act;
return { lead, act, long: lead ? `${lead} ${act}` : act };
},
renderActions() { renderActions() {
const index = this.state.actingSeatIndex; const index = this.state.actingSeatIndex;
const player = this.state.seats[index]; const player = this.state.seats[index];
@@ -941,7 +1013,8 @@ const ActionBar = {
expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`, expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`,
"actionEnforceClock", "actionEnforceClock",
() => this.commit( () => this.commit(
expired.verb === "fold" ? `${expired.who}'s hand killed on the clock` : `${expired.who}'s check on the clock`, { lead: `${expired.who}'s`,
act: expired.verb === "fold" ? "hand killed on the clock" : "check on the clock" },
() => { () => {
this.stopClock(); this.stopClock();
if (expired.verb === "fold") Betting.fold(this.state, index); if (expired.verb === "fold") Betting.fold(this.state, index);
@@ -953,16 +1026,16 @@ const ActionBar = {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = "actionButtons"; row.className = "actionButtons";
row.appendChild(this.button("Fold", "actionFold", () => this.commit(`${who}'s fold`, () => Betting.fold(this.state, index)))); row.appendChild(this.button("Fold", "actionFold", () => this.commit({ lead: `${who}'s`, act: "fold" }, () => Betting.fold(this.state, index))));
if (owed <= 0) { if (owed <= 0) {
row.appendChild(this.button("Check", "actionCheck", () => this.commit(`${who}'s check`, () => Betting.check(this.state, index)))); row.appendChild(this.button("Check", "actionCheck", () => this.commit({ lead: `${who}'s`, act: "check" }, () => Betting.check(this.state, index))));
} else if (owed >= stack) { } else if (owed >= stack) {
// Calling would put them all-in, so say that rather than quoting a number // Calling would put them all-in, so say that rather than quoting a number
// they can't cover. // they can't cover.
row.appendChild(this.button("Call all-in", "actionCall", () => this.commit(`${who}'s all-in`, () => Betting.allIn(this.state, index)), Money.format(stack))); row.appendChild(this.button("Call all-in", "actionCall", () => this.commit({ lead: `${who}'s`, act: "all-in" }, () => Betting.allIn(this.state, index)), Money.format(stack)));
} else { } else {
row.appendChild(this.button("Call", "actionCall", () => this.commit(`${who}'s call`, () => Betting.call(this.state, index)), Money.format(owed))); row.appendChild(this.button("Call", "actionCall", () => this.commit({ lead: `${who}'s`, act: "call" }, () => Betting.call(this.state, index)), Money.format(owed)));
} }
if (stack > owed) { if (stack > owed) {
@@ -1060,9 +1133,13 @@ const ActionBar = {
const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo)); const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo));
this.raiseOpen = false; this.raiseOpen = false;
const who = HoldemState.displayName(player, index); const who = HoldemState.displayName(player, index);
const label = raising // The amount stays in the half that survives: "did I raise, and to what"
? `${who}'s raise to ${Money.format(target)}` // is one question. It is the only act long enough to still need an ellipsis
: `${who}'s bet of ${Money.format(target)}`; // on the narrowest phone, and only past four figures — "raise to $60" is
// 55px of the 59 the label has once the arrow is drawn, "raise to $1,200" is
// 69. The whole of it is on the button's own title and aria-label either way.
const label = { lead: `${who}'s`,
act: raising ? `raise to ${Money.format(target)}` : `bet of ${Money.format(target)}` };
this.commit(label, () => Betting.raiseTo(this.state, index, target)); this.commit(label, () => Betting.raiseTo(this.state, index, target));
})); }));
@@ -1169,12 +1246,12 @@ const ActionBar = {
buttons.push(this.button( buttons.push(this.button(
on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`, on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`,
"actionStraddle", "actionStraddle",
() => this.commit(on === 0 ? "the straddle" : "the re-straddle", () => this.commit({ lead: "the", act: on === 0 ? "straddle" : "re-straddle" },
() => { this.state.straddleCount = on + 1; }))); () => { this.state.straddleCount = on + 1; })));
} }
if (on > 0) { if (on > 0) {
buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff", buttons.push(this.button("No straddle", "actionStraddle actionStraddleOff",
() => this.commit("calling the straddle off", () => this.commit({ lead: "calling", act: "the straddle off" },
() => { this.state.straddleCount = 0; }))); () => { this.state.straddleCount = 0; })));
} }
return buttons; return buttons;
@@ -1262,7 +1339,7 @@ const ActionBar = {
aside.appendChild(this.button( aside.appendChild(this.button(
benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`, benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`,
"dealPromptDebtBtn", "dealPromptDebtBtn",
() => this.commit("dealing everybody in", () => { () => this.commit({ lead: "dealing", act: "everybody in" }, () => {
benched.forEach(index => { benched.forEach(index => {
this.state.seats[index].inHand = true; this.state.seats[index].inHand = true;
this.state.seats[index].folded = false; this.state.seats[index].folded = false;
@@ -1351,7 +1428,7 @@ const ActionBar = {
posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`, posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`,
"dealPromptDebtBtn", "dealPromptDebtBtn",
() => this.commit( () => this.commit(
posting ? `${who} waiting for the blind` : `${who} posting to come in`, { lead: who, act: posting ? "waiting for the blind" : "posting to come in" },
() => { this.state.postingIn = posting ? [] : ids; }) () => { this.state.postingIn = posting ? [] : ids; })
)); ));
wrap.appendChild(line); wrap.appendChild(line);
@@ -1370,7 +1447,7 @@ const ActionBar = {
// browser wants the "keep my data" request to come from — see // browser wants the "keep my data" request to come from — see
// requestDurable() in state.js. Harmless and instant after the first time. // requestDurable() in state.js. Harmless and instant after the first time.
if (this.hooks.keepStorage) this.hooks.keepStorage(); if (this.hooks.keepStorage) this.hooks.keepStorage();
this.commit("dealing the hand", () => Betting.beginHand(this.state)); this.commit({ lead: "dealing", act: "the hand" }, () => Betting.beginHand(this.state));
})); }));
wrap.appendChild(row); wrap.appendChild(row);
@@ -1431,7 +1508,8 @@ const ActionBar = {
this.toShowdown(); this.toShowdown();
return; return;
} }
this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state)); const street = Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase();
this.commit({ lead: "dealing", act: `the ${street}` }, () => Betting.closeStreet(this.state));
// The cards that just came off the deck. Asked for here rather than left // The cards that just came off the deck. Asked for here rather than left
// for the dealer to remember, because the moment they are holding them is // for the dealer to remember, because the moment they are holding them is
// the only moment they know what they are without turning the board round. // the only moment they know what they are without turning the board round.
@@ -407,13 +407,18 @@ document.addEventListener("DOMContentLoaded", () => {
Render.bringIntoView(panel, { block: "nearest" }); Render.bringIntoView(panel, { block: "nearest" });
}, },
beginAction: label => { // `parts` is the same name split into the actor and the act — see
actionHistory.push({ label, snapshot: snapshotState() }); // ActionBar.undoParts. It rides along with the label rather than replacing it,
// because the toast below wants the sentence and the pill wants the halves.
beginAction: (label, parts) => {
actionHistory.push({ label, parts, snapshot: snapshotState() });
if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift(); if (actionHistory.length > MAX_UNDO_STEPS) actionHistory.shift();
}, },
lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null), lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null),
lastActionParts: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].parts : null),
undoLast: () => { undoLast: () => {
const step = actionHistory.pop(); const step = actionHistory.pop();
if (!step) return; if (!step) return;
+56 -2
View File
@@ -163,6 +163,44 @@ describe("numbers that change while the dealer watches", () => {
}); });
}); });
describe("what an open chair is allowed to weigh", () => {
// An empty seat used to be drawn as a whole puck of near-black on the green: the same
// box a seated player gets, at the same weight, saying nothing. Five players
// nine-handed is four of them, and they read as holes punched in the felt rather than
// as chairs nobody is in — the eye had to sort the loud empty boxes from the loud full
// ones before it could find who was in the hand.
//
// The wash the box carried was there for one reason: the chair number's contrast
// cannot depend on whether its chair happens to sit over the brown rim, the dark green
// or the light, which is about Lc 5 across one ring. So the wash moved off the chair
// and under the number, which is the only thing an open chair has to say.
test("the chair is quieter than the player who could sit in it", () => {
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/);
const seat = css.match(/\n\.seat \{([^}]*)\}/);
assert.ok(empty && seat);
assert.match(seat[1], /background:\s*linear-gradient/,
"a taken seat is a puck with a ground of its own");
const alpha = empty[1].match(/background:\s*rgba\([^)]*?([0-9.]+)\s*\)/);
assert.ok(alpha, "an open chair should still be washed, only faintly");
assert.ok(Number(alpha[1]) <= 0.35,
`an open chair at ${alpha[1]} reads as a hole in the felt, not a chair`);
});
test("and it still says it is open", () => {
const empty = css.match(/\.seat\.seatEmpty \{([^}]*)\}/)[1];
assert.match(empty, /border-style:\s*dashed/,
"the dashes are what carry the meaning; they always were");
});
test("the number keeps a ground of its own, because it is the part that is read", () => {
const rule = css.match(/\.seatEmpty \.seatChairNumber \{([^}]*)\}/);
assert.ok(rule, "no ground under the one thing an open chair says");
assert.match(rule[1], /background:\s*rgba/);
assert.match(rule[1], /border-radius:\s*var\(--r-pill\)/);
});
});
describe("controls that must not resize under a thumb", () => { describe("controls that must not resize under a thumb", () => {
test("the undo pill has a fixed width, not a content width", () => { test("the undo pill has a fixed width, not a content width", () => {
// It names the last action, so its label changes on every tap. Measured across // It names the last action, so its label changes on every tap. Measured across
@@ -182,9 +220,25 @@ describe("controls that must not resize under a thumb", () => {
"the pill should still state a fixed width somewhere"); "the pill should still state a fixed width somewhere");
}); });
test("and its label is the part that ellipsizes", () => { test("and its label is the part that gives, never the keycap", () => {
const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/); const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/);
assert.ok(rule, ".actionUndoLabel rule not found"); assert.ok(rule, ".actionUndoLabel rule not found");
assert.match(rule[0], /text-overflow:\s*ellipsis/); assert.match(rule[0], /overflow:\s*hidden/);
assert.match(css.match(/\.actionUndoKey\s*\{[^}]*\}/)[0], /flex:\s*none/);
});
test("and within the label, the act outlives the actor", () => {
// Three items, not one string: the arrow, who it was, and what they did. Cut as
// one string, "↶ Mike from work's fold" reads "↶ Mike from wor…" on a phone —
// everything except the thing you would be taking back. The actor is dropped
// whole instead, so the act is what survives, and only the act may ellipsize.
const lead = css.match(/\.actionUndoGlyph,\s*\n\.actionUndoLead\s*\{([^}]*)\}/);
assert.ok(lead, "the arrow and the actor should be fixed items");
assert.match(lead[1], /flex:\s*none/);
assert.match(css, /\.actionUndoLead\[hidden\]\s*\{[^}]*display:\s*none/);
const act = css.match(/\.actionUndoAct\s*\{([^}]*)\}/);
assert.ok(act, ".actionUndoAct rule not found");
assert.match(act[1], /text-overflow:\s*ellipsis/);
assert.match(act[1], /min-width:\s*0/);
}); });
}); });
+147
View File
@@ -399,6 +399,153 @@ describe("and the name is the half that is still there at the end of it", () =>
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describe("what the undo pill says it would take back", () => {
// The pill is a fixed width — it names the last action, so its text changes on every
// tap, and a control that resizes under a thumb reads as the interface twitching —
// and on a phone that width leaves the label 73px. Measured across one hand, four of
// the five labels a hand produces wanted more:
//
// ↶ dealing the hand 104 ↶ Christopher's fold 109
// ↶ Mike from work's fold 128 ↶ Alexandra's fold 100
// ↶ Dee's fold 69 (the only one that fitted)
//
// So the safety net spent most of a night reading "↶ dealing …", which does not say
// what it is a net for. Cut as one string it says the wrong half: "↶ Mike from wor…"
// names everybody except the action.
//
// Two halves, and the actor is the one that goes — whole, not shortened. Measured
// again afterwards, nothing is cut at any viewport in the sweep: a phone reads
// "↶ the hand", "↶ Dee's fold", "↶ raise to $20", "↶ call"; a desktop and the
// landscape rail keep the actor wherever it fits.
test("a label handed over in halves knows which is which", () => {
const step = ActionBar.undoParts({ lead: "Christopher's", act: "fold" });
assert.equal(step.lead, "Christopher's");
assert.equal(step.act, "fold");
assert.equal(step.long, "Christopher's fold", "the sentence the toast and the tooltip use");
});
test("one with no actor is all act", () => {
// "the straddle" belongs to the table rather than to a player.
const step = ActionBar.undoParts({ lead: "", act: "the straddle" });
assert.equal(step.lead, "");
assert.equal(step.long, "the straddle", "no stray space in front of it");
});
test("and a plain string still works, with nothing to drop", () => {
// Every caller passes halves today. This is what keeps a caller that doesn't
// from drawing "[object Object]" on the one control a dealer reaches for when
// they have made a mistake.
const step = ActionBar.undoParts("something nobody split");
assert.equal(step.lead, "");
assert.equal(step.act, "something nobody split");
assert.equal(step.long, "something nobody split");
});
test("the sentence is what goes into the history, and the halves ride with it", () => {
const seen = [];
const before = ActionBar.hooks;
ActionBar.hooks = {
beginAction: (label, parts) => seen.push({ label, parts }),
save: () => {}, rerender: () => {},
};
try {
ActionBar.commit({ lead: "Dee's", act: "call" }, () => {});
} finally { ActionBar.hooks = before; }
assert.equal(seen.length, 1);
assert.equal(seen[0].label, "Dee's call", "the toast says the whole thing");
assert.deepEqual(
{ lead: seen[0].parts.lead, act: seen[0].parts.act },
{ lead: "Dee's", act: "call" });
});
test("and the halves are read back from the history, not remembered here", () => {
// Undo pops the history. A copy kept on this object would name the step that was
// just taken back rather than the one now on top of the pile.
const before = ActionBar.hooks;
ActionBar.hooks = {
lastActionLabel: () => "Dee's call",
lastActionParts: () => ({ lead: "Dee's", act: "call" }),
};
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "Dee's", act: "call" });
} finally { ActionBar.hooks = before; }
});
test("a caller that offers no halves gets the label whole", () => {
const before = ActionBar.hooks;
ActionBar.hooks = { lastActionLabel: () => "dealing the hand" };
try {
assert.deepEqual(ActionBar.undoStep(), { lead: "", act: "dealing the hand" });
} finally { ActionBar.hooks = before; }
});
const withPill = (act, run) => {
const lead = { hidden: false, className: "actionUndoLead" };
const label = {
className: "actionUndoLabel",
querySelector: sel => (sel === ".actionUndoLead" ? lead
: sel === ".actionUndoAct" ? act : null),
};
global.document = {
querySelector: sel => (sel.endsWith(".actionUndoLabel") ? label : null),
};
try { run(lead); } finally { delete global.document; }
};
test("an act that fits keeps the actor beside it", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("an act being cut drops the actor whole", () => {
withPill({ scrollWidth: 63, clientWidth: 40 }, lead => {
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, true,
"\"Christo…'s fold\" is a worse sentence than \"fold\"");
});
});
test("and it is the act that is asked, never the label", () => {
// The label cannot overflow: the act inside it is the item with `overflow:
// hidden`, so it swallows the excess and the row measures as though everything
// fitted. Asking the label was the first way this was written, and it drew
// "↶ dealing the hand" with "the hand" quietly cut off inside it.
const fit = CSS_JS.slice(CSS_JS.indexOf(" fitUndoLabel() {"));
const body = fit.slice(0, fit.indexOf("\n },"));
assert.match(body, /act\.scrollWidth > act\.clientWidth/);
assert.doesNotMatch(body, /label\.scrollWidth/);
});
test("an actor already dropped is put back before anything is measured", () => {
withPill({ scrollWidth: 19, clientWidth: 40 }, lead => {
lead.hidden = true;
ActionBar.fitUndoLabel();
assert.equal(lead.hidden, false);
});
});
test("and a bar with nothing to take back is left alone", () => {
global.document = { querySelector: () => null };
try { assert.doesNotThrow(() => ActionBar.fitUndoLabel()); }
finally { delete global.document; }
});
test("the keycap goes where there is no keyboard to press it", () => {
// Every other control on the bar had dropped its cap on a phone since the rule
// was written; this one is a different class and had been left out of it, so the
// narrowest label on the bar was the only one still spending 22px on a shortcut
// nothing could press.
const rule = CSS.match(/@media \(hover: none\) \{\s*\.actionKey,\s*\.actionUndoKey \{([^}]*)\}/);
assert.ok(rule, ".actionUndoKey should be hidden alongside .actionKey on touch");
assert.match(rule[1], /display:\s*none/);
});
});
// ---------------------------------------------------------------------------
describe("a stopwatch that is only there when it is saying something", () => { describe("a stopwatch that is only there when it is saying something", () => {
// 50px of a 349px line, from the first second of every decision, reading "0:07" in // 50px of a 349px line, from the first second of every decision, reading "0:07" in
// grey. It is a prompt rather than a readout — nothing happens when it grows — and a // grey. It is a prompt rather than a readout — nothing happens when it grows — and a