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