From 81360255448c57a418b68c2991e7403af411286d Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Thu, 10 Sep 2026 14:13:25 -0600 Subject: [PATCH] Say what the undo would take back, and let the empty chairs be empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 75 ++++++++- .../wwwroot/js/casino/holdem/actionBar.js | 138 ++++++++++++---- .../wwwroot/js/casino/holdem/main.js | 9 +- tests/holdem/designTokens.test.js | 58 ++++++- tests/holdem/theRail.test.js | 147 ++++++++++++++++++ 5 files changed, 387 insertions(+), 40 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index d2e4018..80b24ba 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1001,12 +1001,40 @@ button, about two points of Lc and cost four dark rectangles sitting on the felt like holes. Taking the lighter one is free. */ .seat.seatEmpty { - background: rgba(18, 18, 18, 0.62); + background: rgba(0, 0, 0, 0.28); border-style: dashed; - border-color: rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.2); 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 { color: var(--text-dim); 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 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 { + 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; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } .actionUndoKey { @@ -3675,9 +3730,17 @@ body.hasActionBar #holdemApp { } /* 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) { - .actionKey { + .actionKey, + .actionUndoKey { display: none; } } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 66027ab..66d5c9e 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -247,6 +247,7 @@ const ActionBar = { // 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. this.fitActingLine(); + this.fitUndoLabel(); this.showRailEnd(host); this.announce(); }, @@ -499,30 +500,43 @@ const ActionBar = { // 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 // 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 - // ellipsize, and only a real element can: as a bare text node the label + // as the interface twitching. Fixed width means long labels have to give + // 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 // the cut-off text looking like a rendering fault. // - // Known, measured, and not fixed here: on a phone this label has 73px and - // four of the five labels a hand produces want more than that — + // Three elements, because what it gives up matters. Measured on a phone, the + // label has 73px and four of the five labels a hand produces want 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 fits) + // ↶ Dee's fold 69 (the only one that fitted) // - // so the pill reads "↶ dealing …" most of the night. Widening it is not - // available — the room it would take is the room the name beside it needs, - // and that is what fitActingLine is spending. The answer is the same shape - // as the acting line's: the actor and the act as two spans, the actor - // shrinking first, so "Mike from work's fold" degrades to "Mike f…'s fold" - // 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. + // so the pill spent most of a night reading "↶ dealing …" — a safety net + // that would not say what it was a net for. Widening it is not available: + // the room it would take is the room the acting name beside it needs, and + // that is what fitActingLine is already spending. So the actor comes off + // instead, whole, and the act stays — see fitUndoLabel. const label = document.createElement("span"); 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); // 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 @@ -777,6 +791,41 @@ const ActionBar = { // 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 // 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() { const name = document.querySelector("#actionBar .actionWhoName"); const stack = document.querySelector("#actionBar .actionWhoStack"); @@ -907,12 +956,35 @@ const ActionBar = { // would actually take back. if (typeof Toast !== "undefined" && Toast.dismiss) Toast.dismiss(); - this.hooks.beginAction(label); + const step = this.undoParts(label); + this.hooks.beginAction(step.long, step); mutate(); this.hooks.save(this.state); 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() { const index = this.state.actingSeatIndex; const player = this.state.seats[index]; @@ -941,7 +1013,8 @@ const ActionBar = { expired.verb === "fold" ? `Kill ${expired.who}'s hand` : `Check for ${expired.who}`, "actionEnforceClock", () => 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(); if (expired.verb === "fold") Betting.fold(this.state, index); @@ -953,16 +1026,16 @@ const ActionBar = { const row = document.createElement("div"); 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) { - 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) { // Calling would put them all-in, so say that rather than quoting a number // 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 { - 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) { @@ -1060,9 +1133,13 @@ const ActionBar = { const target = Math.max(0, Math.min(Number(this.raiseDraft) || 0, maxTo)); this.raiseOpen = false; const who = HoldemState.displayName(player, index); - const label = raising - ? `${who}'s raise to ${Money.format(target)}` - : `${who}'s bet of ${Money.format(target)}`; + // The amount stays in the half that survives: "did I raise, and to what" + // is one question. It is the only act long enough to still need an ellipsis + // 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)); })); @@ -1169,12 +1246,12 @@ const ActionBar = { buttons.push(this.button( on === 0 ? `Straddle ${nextAmount}` : `Re-straddle ${nextAmount}`, "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; }))); } if (on > 0) { 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; }))); } return buttons; @@ -1262,7 +1339,7 @@ const ActionBar = { aside.appendChild(this.button( benched.length === 1 ? "Deal them in" : `Deal in all ${benched.length}`, "dealPromptDebtBtn", - () => this.commit("dealing everybody in", () => { + () => this.commit({ lead: "dealing", act: "everybody in" }, () => { benched.forEach(index => { this.state.seats[index].inHand = true; this.state.seats[index].folded = false; @@ -1351,7 +1428,7 @@ const ActionBar = { posting ? "Don't post" : `Post ${Money.format(preview.bigBlind)}${each}`, "dealPromptDebtBtn", () => 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; }) )); wrap.appendChild(line); @@ -1370,7 +1447,7 @@ const ActionBar = { // browser wants the "keep my data" request to come from — see // requestDurable() in state.js. Harmless and instant after the first time. 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); @@ -1431,7 +1508,8 @@ const ActionBar = { this.toShowdown(); 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 // 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. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 82224e3..6344551 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -407,13 +407,18 @@ document.addEventListener("DOMContentLoaded", () => { Render.bringIntoView(panel, { block: "nearest" }); }, - beginAction: label => { - actionHistory.push({ label, snapshot: snapshotState() }); + // `parts` is the same name split into the actor and the act — see + // 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(); }, lastActionLabel: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].label : null), + lastActionParts: () => (actionHistory.length ? actionHistory[actionHistory.length - 1].parts : null), + undoLast: () => { const step = actionHistory.pop(); if (!step) return; diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js index 5d07f20..8007303 100644 --- a/tests/holdem/designTokens.test.js +++ b/tests/holdem/designTokens.test.js @@ -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", () => { 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 @@ -182,9 +220,25 @@ describe("controls that must not resize under a thumb", () => { "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*\{[^}]*\}/); 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/); }); }); diff --git a/tests/holdem/theRail.test.js b/tests/holdem/theRail.test.js index 33c513c..c4af5a1 100644 --- a/tests/holdem/theRail.test.js +++ b/tests/holdem/theRail.test.js @@ -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", () => { // 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