diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index 4d47fce..ea2314b 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -7,18 +7,23 @@
+ @* Six equally-weighted buttons used to sit here in two rows, and on a 390px + phone the felt did not start until 225px down — a quarter of the screen spent + before the dealer saw the table. Only two of the six are per-hand controls. + The rest have gone where they belong rather than into a "more" menu, which + would have relocated the crowding rather than removed it: Reset Table to the + Session panel beside Clear history and the save/load pair, Quick Split down + with Common rulings where the other "open something else" link already lives, + and the way back to the site up top where a back link goes. *@
+ ← Casino

Texas Hold'em Dealer

-

Seat your players, track the action, split the pot.

Hand #1
- Quick Split - - ← Casino
@@ -37,6 +42,7 @@

+ Quick Split

@@ -52,7 +58,11 @@
-
+ @* Not `open` in the markup any more. Whether this panel starts open is a + question about the table, so main.js answers it after the state has + loaded: open on a table nobody has set up yet, shut on one already + half-way through a night. *@ +
Table Setup
diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 2318c8d..feec269 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -1,8 +1,109 @@ +/* ---------- Design tokens ---------- + Before this block the stylesheet used 42 different spacing values, 31 font sizes, + 14 border-radii and 139 colours, and defined three custom properties. Every number + had been picked by hand at the moment it was needed, which is why the tool read as + crowded even on the screens where nothing was actually overlapping: a gap of 0.42rem + next to one of 0.45rem next to one of 0.5rem gives the eye no rhythm to lock onto, + so it reads the whole surface as noise. + + The fix is not "less" — it is "fewer choices". Constrained scales are the point: + pick from these, never from thin air, and the layout falls into a rhythm without + anybody art-directing it. + + Nothing here changes what the tool does. It is the vocabulary the rest of the file + is written in. */ +:root { + /* Spacing. An 8px rhythm, because 8 divides cleanly into every device pixel ratio + and is what Material and HIG already tuned their components against. --sp-0 is + the sub-step for badges and chips where 4px is already too much. */ + --sp-0: 0.125rem; /* 2px */ + --sp-1: 0.25rem; /* 4px */ + --sp-2: 0.5rem; /* 8px */ + --sp-3: 0.75rem; /* 12px */ + --sp-4: 1rem; /* 16px */ + --sp-5: 1.5rem; /* 24px */ + --sp-6: 2rem; /* 32px */ + + /* Type. Eight steps, and the two smallest exist only for the seat pucks, where the + label has to fit inside a 58px circle. Hierarchy is built with weight and colour + below, not by inventing another size. */ + --fs-2xs: 0.625rem; /* 10px — puck micro-labels */ + --fs-xs: 0.6875rem; /* 11px */ + --fs-sm: 0.75rem; /* 12px */ + --fs-base: 0.8125rem; /* 13px — body copy */ + --fs-md: 0.9375rem; /* 15px */ + --fs-lg: 1.125rem; /* 18px */ + --fs-xl: 1.375rem; /* 22px */ + --fs-2xl: 1.75rem; /* 28px — page title */ + + /* Weights. Two, plus the body default. */ + --fw-medium: 600; + --fw-bold: 700; + + /* Corners. */ + --r-sm: 6px; + --r-md: 10px; + --r-lg: 16px; + --r-pill: 999px; + + /* Surfaces. Dark grey rather than black: shadows are invisible on true black, so + depth has to come from a lighter surface, and #000 against light text is the + contrast pairing that smears on OLED. Four steps is enough for a felt, a panel, + a row and a control. */ + --surface-0: #141414; /* page behind everything */ + --surface-1: #1a1a1a; /* panels */ + --surface-2: #232323; /* rows, fields, resting controls */ + --surface-3: #2c2c2c; /* raised: hover, the seat puck */ + --line: #3a3a3a; + --line-strong: #4a4a4a; + + /* Text. */ + --text-bright: #f5f5f5; + --text: #eee; + --text-muted: #9a9a9a; + --text-dim: #8a8a8a; + + /* The one accent. Desaturated against the dark ground — a fully saturated gold + vibrates on a near-black surface and stops reading as a highlight. */ + --gold: #d8b46a; + --gold-bright: #f0d9a8; + --gold-deep: #a9782f; + --gold-tint: #3a2f18; + --gold-shade: #241d0f; +} + +/* Every number that changes while the dealer is looking at it — the pot, the stacks, + the bets on the felt, the clock — is set in tabular figures. In a proportional face + a "1" is narrower than an "8", so a pot ticking $18 -> $21 shuffles the digits + sideways and the eye reads movement where there is only arithmetic. */ +#potBadgeAmount, +.seatPuckStack, +.betChipAmount, +.actionPot, +.actionClockBtn, +.standingsRow, +.bankReconcileRow, +.potLayerAmount, +.handHistoryRow, +.handHistoryPot, +input[type="number"] { + font-variant-numeric: tabular-nums; +} + +/* Hold the scrollbar's width whether or not there is one to show. A panel opening far + enough to make the page scrollable used to narrow the viewport by the scrollbar's + width, which re-centres the felt and nudges every line of text left. No effect on + phones, where scrollbars float over the content — this is for the desktop the tool + also gets run on. */ +html { + scrollbar-gutter: stable; +} + body { - background: radial-gradient(ellipse at 50% -10%, #1c1c1c 0%, #101010 55%, #0a0a0a 100%); + background: radial-gradient(ellipse at 50% -10%, var(--surface-1) 0%, var(--surface-0) 55%, #0a0a0a 100%); min-height: 100vh; margin: 0; - color: #e6e6e6; + color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } @@ -151,7 +252,7 @@ button, #holdemApp { max-width: 1400px; margin: 0 auto; - padding: 2rem; + padding: var(--sp-6); } /* ---------- Header ---------- */ @@ -161,19 +262,19 @@ button, flex-wrap: wrap; justify-content: space-between; align-items: center; - gap: 1rem; - margin-bottom: 1.5rem; + gap: var(--sp-4); + margin-bottom: var(--sp-5); } #holdemHeader h1 { margin: 0; - font-size: 1.8rem; + font-size: var(--fs-2xl); font-weight: 300; letter-spacing: 0.04em; - color: #f5f5f5; + color: var(--text-bright); position: relative; display: inline-block; - padding-bottom: 0.4rem; + padding-bottom: var(--sp-2); } #holdemHeader h1::after { @@ -184,30 +285,51 @@ button, width: 42px; height: 3px; border-radius: 2px; - background: linear-gradient(90deg, #d8b46a, #a9782f); + background: linear-gradient(90deg, var(--gold), var(--gold-deep)); } -.holdemSubtitle { - margin: 0.25rem 0 0; - color: #999; - font-size: 0.9rem; +/* The way back to the site. It used to be a button in the action row, the same size + and weight as Resolve Pot; navigation is not an action, and putting it in that row + cost a sixth of the row's width to say "← Casino". Up here it is where a back link + goes, and it reads as one. */ +.holdemBackLink { + /* Reads as a quiet link, hits like a control. As bare text it drew a 56x14 target + — the audit caught it at every viewport — and a back link a thumb misses is + worse than the button it replaced. The padding is the hit area; the negative + margins give most of it back to the layout, so 44px of reachable link costs the + header about eight. */ + display: inline-flex; + align-items: center; + min-height: 44px; + padding: 0 var(--sp-2); + margin: calc(var(--sp-3) * -1) calc(var(--sp-2) * -1) calc(var(--sp-2) * -1); + color: var(--text-dim); + font-size: var(--fs-sm); + text-decoration: none; + letter-spacing: 0.03em; +} + +@media (hover: hover) { + .holdemBackLink:hover { + color: var(--gold); + } } .handCounterBadge { display: inline-block; - margin-top: 0.5rem; - padding: 0.2rem 0.65rem; - border-radius: 999px; + margin-top: var(--sp-2); + padding: var(--sp-1) var(--sp-3); + border-radius: var(--r-pill); background: rgba(216, 180, 106, 0.12); border: 1px solid rgba(216, 180, 106, 0.4); - color: #d8b46a; - font-size: 0.75rem; + color: var(--gold); + font-size: var(--fs-sm); letter-spacing: 0.04em; } #holdemHeaderActions { display: flex; - gap: 0.6rem; + gap: var(--sp-2); flex-wrap: wrap; } @@ -226,12 +348,12 @@ button, (wider than the phone breakpoint, still a thumb) would have been left with 42px buttons. 44px is the floor everywhere; the narrow layout still asks for 46. */ min-height: 44px; - background: linear-gradient(180deg, #2e2e2e, #232323); - color: #eee; - border: 1px solid #3a3a3a; - border-radius: 7px; - padding: 0.6rem 1rem; - font-size: 0.85rem; + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); + color: var(--text); + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: var(--sp-2) var(--sp-4); + font-size: var(--fs-base); cursor: pointer; /* Named rather than `all`, so the focus ring isn't animated. With `all` the outline faded in over 150ms, which on keyboard navigation reads as the ring @@ -241,14 +363,14 @@ button, text-decoration: none; display: inline-flex; align-items: center; - gap: 0.3rem; + gap: var(--sp-1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05); } @media (hover: hover) { .holdemBtn:hover { - background: linear-gradient(180deg, #383838, #2a2a2a); - border-color: #4a4a4a; + background: linear-gradient(180deg, var(--line), #2a2a2a); + border-color: var(--line-strong); transform: translateY(-1px); } } @@ -282,8 +404,8 @@ button, } .holdemBtnSmall { - padding: 0.3rem 0.6rem; - font-size: 0.75rem; + padding: var(--sp-1) var(--sp-2); + font-size: var(--fs-sm); } .holdemBtn:disabled { @@ -294,8 +416,8 @@ button, .handHistoryActions { display: flex; - gap: 0.5rem; - margin-top: 0.5rem; + gap: var(--sp-2); + margin-top: var(--sp-2); } /* ---------- Layout ---------- */ @@ -303,7 +425,7 @@ button, #holdemLayout { display: grid; grid-template-columns: 1fr 320px; - gap: 1.5rem; + gap: var(--sp-5); align-items: start; } @@ -312,10 +434,10 @@ button, } .tableHint { - color: #949494; - font-size: 0.8rem; + color: var(--text-muted); + font-size: var(--fs-base); text-align: center; - margin-top: 1rem; + margin-top: var(--sp-4); } /* `hidden` means hidden. @@ -388,24 +510,24 @@ button, position: relative; background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55)); border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 999px; - padding: 0.7rem 1.7rem; + border-radius: var(--r-pill); + padding: var(--sp-3) var(--sp-5); text-align: center; display: flex; flex-direction: column; - gap: 0.15rem; + gap: var(--sp-0); box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6), 0 4px 14px rgba(0, 0, 0, 0.45); } #potBadgeLabel { - font-size: 0.68rem; + font-size: var(--fs-xs); letter-spacing: 0.14em; text-transform: uppercase; - color: #d8b46a; + color: var(--gold); } #potBadgeAmount { - font-size: 1.5rem; + font-size: var(--fs-xl); font-weight: 700; color: #fff; } @@ -432,11 +554,11 @@ button, transform: translate(-50%, -50%); display: flex; align-items: center; - gap: 0.28rem; - padding: 0.1rem 0.42rem 0.1rem 0.2rem; + gap: var(--sp-1); + padding: var(--sp-0) var(--sp-2) var(--sp-0) var(--sp-1); background: rgba(0, 0, 0, 0.55); border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 999px; + border-radius: var(--r-pill); box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5); white-space: nowrap; } @@ -454,9 +576,9 @@ button, } .betChipAmount { - font-size: 0.62rem; + font-size: var(--fs-2xs); font-weight: 700; - color: #f0f0f0; + color: var(--text-bright); font-variant-numeric: tabular-nums; } @@ -484,10 +606,10 @@ button, transform: translate(-50%, -50%); width: 96px; min-height: 54px; - background: linear-gradient(160deg, #2c2c2c, #1b1b1b); + background: linear-gradient(160deg, var(--surface-3), #1b1b1b); border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - padding: 0.3rem 0.4rem; + border-radius: var(--r-lg); + padding: var(--sp-1) var(--sp-2); box-shadow: 0 3px 10px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05); cursor: grab; z-index: 1; @@ -505,7 +627,7 @@ button, } .seat.dropTarget { - outline: 2px dashed #d8b46a; + outline: 2px dashed var(--gold); outline-offset: 2px; } @@ -544,7 +666,7 @@ button, is the part that actually names the state. */ .seat.seatInactive, .seat.seatFolded { - background: linear-gradient(160deg, #232323, #171717); + background: linear-gradient(160deg, var(--surface-2), #171717); border-color: rgba(255, 255, 255, 0.06); } @@ -589,8 +711,8 @@ button, } .seatEmptyLabel { - color: #8a8a8a; - font-size: 0.6rem; + color: var(--text-dim); + font-size: var(--fs-2xs); letter-spacing: 0.08em; text-transform: uppercase; } @@ -600,7 +722,7 @@ button, box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5); } -/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: #8a8a8a +/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: var(--text-dim) came out at 3.39:1 once the fade was composited, for the label a dealer reads to answer "which seat is seat 6?". Lifted until it passes with a little to spare — measured 4.5:1 at #a5a5a5 exactly, so it sits above that. */ @@ -608,18 +730,43 @@ button, position: absolute; top: 2px; left: 6px; - font-size: 0.58rem; + font-size: var(--fs-2xs); color: #aeaeae; } +/* Two lines, not an ellipsis. + A 96px puck fits about nine characters on a line, so "Christopher" came out as + "Chri…" and "Mike from work" as "Mike fro…" — and "from work" is the half a dealer + typed to tell two Mikes apart, so the ellipsis was eating exactly the part that + carried the information. Wrapping keeps both, and a name is the one thing on the + felt read at a glance rather than looked up. */ .seatPuckName { - font-size: 0.72rem; + /* Not shrunk to buy a line. Dropping a step did fit another character and a half, + and still didn't get "Christopher" onto one line on the button seat, where the + dealer chip takes 22px of the row — so it bought nothing and cost legibility on + the one label a dealer reads at a glance across a table. The name stays the size + it was; the wrap deals with the length. */ + font-size: var(--fs-sm); font-weight: 600; - color: #f0f0f0; - white-space: nowrap; + color: var(--text-bright); + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; overflow: hidden; - text-overflow: ellipsis; - margin-top: 0.6rem; + /* A single word longer than the puck has nothing to break on. `anywhere` was the + first try and it gave "Christo / pher", which reads as a rendering fault; with + `break-word` the browser only breaks a word that cannot fit on a line of its + own, and `hyphens` puts a hyphen in when it does. */ + overflow-wrap: break-word; + hyphens: auto; + line-height: 1.2; + /* Deliberately no reserved second line. Reserving one made every puck the height of + the tallest — measured, 69-73px became a uniform 88 — and 15px added to all nine + seats is fifteen more pixels of felt spent on chrome, which is the thing this + round is trying to give back. A ragged ring is cheaper than a crowded one, and + the ring was already ragged by 4px before any of this. */ + margin-top: var(--sp-2); } /* The button's chip sits in this puck's top-right corner, on a layer above the text. @@ -631,10 +778,10 @@ button, } .seatPuckCards { - font-size: 0.68rem; + font-size: var(--fs-xs); font-weight: 700; font-family: var(--font-mono, monospace); - color: #ddd; + color: var(--text); min-height: 0.95em; letter-spacing: 0.05em; } @@ -645,13 +792,13 @@ button, .seatPuckTotal { display: inline-block; - margin-top: 0.15rem; - font-size: 0.64rem; + margin-top: var(--sp-0); + font-size: var(--fs-2xs); font-weight: 700; color: #a9f5b8; background: rgba(9, 255, 0, 0.1); - border-radius: 999px; - padding: 0 0.35rem; + border-radius: var(--r-pill); + padding: 0 var(--sp-1); min-height: 1.1em; line-height: 1.4; } @@ -675,10 +822,10 @@ button, height: 22px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #fff, #d8d8d8 55%, #b8b8b8 100%); - color: #222; - border: 2px solid #999; + color: var(--surface-2); + border: 2px solid var(--text-muted); font-weight: 700; - font-size: 0.68rem; + font-size: var(--fs-xs); cursor: pointer; z-index: 2; transition: opacity 0.15s ease; @@ -747,7 +894,7 @@ button, .dealerChip.active { background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%); - border-color: #a9782f; + border-color: var(--gold-deep); color: #3a2e00; opacity: 1; } @@ -772,10 +919,10 @@ button, display: none; position: absolute; top: -9px; - font-size: 0.58rem; + font-size: var(--fs-2xs); font-weight: 700; - padding: 0.05rem 0.34rem; - border-radius: 999px; + padding: var(--sp-0) var(--sp-1); + border-radius: var(--r-pill); z-index: 2; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4); } @@ -838,7 +985,7 @@ button, /* A blind the dealer named by hand rather than one the button handed out. Without the ring the table just looks like it's put the blinds in the wrong place. */ .blindBadge.blindManual { - outline: 1.5px solid #f2f2f2; + outline: 1.5px solid var(--text-bright); outline-offset: 1px; } @@ -861,13 +1008,21 @@ button, padding: 3px 4px; } +/* One line again once the ring is crowded. A second line is 14px the tight layout + hasn't got — it already gives up the hole cards to fit — and a puck that grew taller + here would push its neighbours into each other, which is the one thing the tight + layout exists to stop. */ .seatsTight .seatPuckName { - margin-top: 0.35rem; - font-size: 0.62rem; + margin-top: var(--sp-1); + font-size: var(--fs-2xs); + -webkit-line-clamp: 1; + line-clamp: 1; + min-height: 0; + text-overflow: ellipsis; } .seatsTight .seatPuckStack { - font-size: 0.55rem; + font-size: var(--fs-2xs); } /* The hole cards give up their line here. A puck this close to its neighbours is 64px @@ -882,13 +1037,13 @@ button, /* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */ .seatEditorPanel { - padding: 0.9rem; + padding: var(--sp-4); } .seatEditorHeader { display: flex; align-items: center; - gap: 0.6rem; + gap: var(--sp-2); } .seatEditorHeader h2 { @@ -907,24 +1062,24 @@ button, } .seatEditorPanel h2 { - margin: 0 0 0.6rem; - padding-bottom: 0.5rem; - font-size: 0.95rem; + margin: 0 0 var(--sp-2); + padding-bottom: var(--sp-2); + font-size: var(--fs-md); font-weight: 500; - color: #f5f5f5; + color: var(--text-bright); border-bottom: 1px solid rgba(216, 180, 106, 0.35); } .seatEditorForm { display: flex; flex-direction: column; - gap: 0.6rem; + gap: var(--sp-2); } .seatCardToggles { display: flex; - gap: 0.6rem; - font-size: 0.8rem; + gap: var(--sp-2); + font-size: var(--fs-base); color: #aaa; } @@ -932,25 +1087,25 @@ button, .foldToggle { display: flex; align-items: center; - gap: 0.3rem; + gap: var(--sp-1); cursor: pointer; } .seatNameInput { flex: 1; min-width: 0; - background: #1a1a1a; - border: 1px solid #3a3a3a; - border-radius: 5px; - color: #eee; - padding: 0.3rem 0.45rem; - font-size: 0.8rem; + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + padding: var(--sp-1) var(--sp-2); + font-size: var(--fs-base); } .seatCards { display: flex; - gap: 0.35rem; - margin-bottom: 0.4rem; + gap: var(--sp-1); + margin-bottom: var(--sp-2); } /* The two hole-card slots: they look like cards face-up on the felt, and tapping @@ -961,10 +1116,10 @@ button, text-align: center; background: #fdfdfd; color: #111; - border: 1px solid #3a3a3a; - border-radius: 6px; - padding: 0.35rem 0; - font-size: 1.25rem; + border: 1px solid var(--line); + border-radius: var(--r-sm); + padding: var(--sp-1) 0; + font-size: var(--fs-lg); font-weight: 700; font-family: var(--font-mono, monospace); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); @@ -984,8 +1139,8 @@ button, } .cardSlot.cardSlotEmpty { - background: #191919; - color: #8a8a8a; + background: var(--surface-1); + color: var(--text-dim); border-style: dashed; border-color: #3f3f3f; box-shadow: none; @@ -995,9 +1150,9 @@ button, /* ---------- Add chips ---------- */ .seatBuyIns { - margin-top: -0.3rem; - color: #9a9a9a; - font-size: 0.75rem; + margin-top: -var(--sp-1); + color: var(--text-muted); + font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } @@ -1018,35 +1173,35 @@ button, #addChipsSheet { width: 100%; max-width: 420px; - background: linear-gradient(180deg, #242424, #1a1a1a); - border: 1px solid #3a3a3a; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); border-bottom: none; - border-radius: 16px 16px 0 0; - padding: 0.35rem 1rem calc(1.2rem + env(safe-area-inset-bottom, 0px)); + border-radius: var(--r-lg) var(--r-lg) 0 0; + padding: var(--sp-1) var(--sp-4) calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65); } .addChipsHeader { - margin-bottom: 0.8rem; + margin-bottom: var(--sp-3); } .addChipsHeader h2 { margin: 0; - font-size: 1rem; + font-size: var(--fs-md); font-weight: 500; - color: #f5f5f5; + color: var(--text-bright); } #addChipsSubtitle { - color: #9a9a9a; - font-size: 0.78rem; + color: var(--text-muted); + font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } #addChipsQuick { display: flex; - gap: 0.45rem; - margin-bottom: 0.7rem; + gap: var(--sp-2); + margin-bottom: var(--sp-3); } .addChipsQuickBtn { @@ -1054,55 +1209,55 @@ button, display: flex; flex-direction: column; align-items: center; - gap: 0.1rem; + gap: var(--sp-0); min-height: 52px; - border-radius: 11px; - border: 1px solid #3a3a3a; - background: linear-gradient(180deg, #2e2e2e, #232323); - color: #ddd; + border-radius: var(--r-md); + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); + color: var(--text); font-family: inherit; cursor: pointer; } .addChipsQuickBtn.selected { - border-color: #d8b46a; - background: linear-gradient(180deg, #3a2f18, #241d0f); - color: #f0d9a8; + border-color: var(--gold); + background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade)); + color: var(--gold-bright); } .addChipsQuickLabel { - font-size: 0.78rem; + font-size: var(--fs-sm); font-weight: 600; } .addChipsQuickAmount { - font-size: 0.7rem; + font-size: var(--fs-xs); color: #a8a8a8; font-variant-numeric: tabular-nums; } .addChipsQuickBtn.selected .addChipsQuickAmount { - color: #d8b46a; + color: var(--gold); } .addChipsEntry { display: flex; align-items: center; - gap: 0.4rem; - background: #141414; - border: 1px solid #3a3a3a; - border-radius: 11px; - padding: 0.2rem 0.8rem; - margin-bottom: 0.8rem; + gap: var(--sp-2); + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: var(--r-md); + padding: var(--sp-1) var(--sp-3); + margin-bottom: var(--sp-3); } .addChipsEntry:focus-within { - border-color: #d8b46a; + border-color: var(--gold); } .addChipsCurrency { - color: #8a8a8a; - font-size: 1.3rem; + color: var(--text-dim); + font-size: var(--fs-xl); } #addChipsInput { @@ -1111,18 +1266,24 @@ button, background: none; border: none; outline: none; - color: #f2f2f2; + color: var(--text-bright); font-family: inherit; - font-size: 1.5rem; + font-size: var(--fs-xl); font-weight: 500; font-variant-numeric: tabular-nums; - padding: 0.4rem 0; + padding: var(--sp-2) 0; + /* Stated, not arrived at. This field used to clear 44px by way of its font size + plus its padding, so moving either on to the scales dropped it to 42 — a touch + target is a promise about the size of the thing, and it should not be an + accident of two other numbers. */ + box-sizing: border-box; + min-height: 44px; } .addChipsActions { display: flex; flex-direction: column-reverse; - gap: 0.5rem; + gap: var(--sp-2); } .addChipsActions .holdemBtn { @@ -1134,7 +1295,7 @@ button, /* ---------- Rulings reference ---------- */ .tableHintActions { - margin: 0.35rem 0 0; + margin: var(--sp-1) 0 0; text-align: center; } @@ -1157,9 +1318,9 @@ button, box-sizing: border-box; min-height: 24px; padding: 0; - color: #d8b46a; + color: var(--gold); font-family: inherit; - font-size: 0.78rem; + font-size: var(--fs-sm); text-decoration: underline; text-underline-offset: 2px; cursor: pointer; @@ -1167,7 +1328,7 @@ button, @media (hover: hover) { .linkBtn:hover { - color: #d8b46a; + color: var(--gold); } } @@ -1190,33 +1351,33 @@ button, max-width: 460px; max-height: 86vh; overflow-y: auto; - background: linear-gradient(180deg, #242424, #1a1a1a); - border: 1px solid #3a3a3a; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); border-bottom: none; - border-radius: 16px 16px 0 0; - padding: 0.35rem 1.1rem calc(1.2rem + env(safe-area-inset-bottom, 0px)); + border-radius: var(--r-lg) var(--r-lg) 0 0; + padding: var(--sp-1) var(--sp-4) calc(var(--sp-5) + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65); } .rulingsHeader { display: flex; align-items: center; - gap: 0.6rem; - margin-bottom: 0.5rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } .rulingsHeader h2 { flex: 1; margin: 0; - font-size: 1.05rem; + font-size: var(--fs-lg); font-weight: 500; - color: #f5f5f5; + color: var(--text-bright); } .rulingsIntro { - margin: 0 0 1rem; - color: #9a9a9a; - font-size: 0.8rem; + margin: 0 0 var(--sp-4); + color: var(--text-muted); + font-size: var(--fs-base); line-height: 1.5; } @@ -1224,12 +1385,12 @@ button, wrong and you can still do something about it". */ .storageWarning { margin: 0; - padding: 0.5rem 0.6rem; + padding: var(--sp-2) var(--sp-2); border: 1px solid rgba(240, 180, 106, 0.45); - border-radius: 7px; + border-radius: var(--r-sm); background: rgba(240, 180, 106, 0.08); color: #f0b46a; - font-size: 0.76rem; + font-size: var(--fs-sm); line-height: 1.4; } @@ -1237,19 +1398,19 @@ button, the thing being named on one line, the answer under it, so a dealer with a rack in one hand reads down rather than across. */ .colourUpList { - margin: 0.2rem 0 0; + margin: var(--sp-1) 0 0; } .colourUpWho { - color: #ddd; - font-size: 0.78rem; + color: var(--text); + font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } .colourUpChips { - margin: 0 0 0.45rem; + margin: 0 0 var(--sp-2); color: #9fb8a6; - font-size: 0.74rem; + font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } @@ -1268,32 +1429,32 @@ button, .shortcutLegend { display: grid; grid-template-columns: auto 1fr; - gap: 0.45rem 0.6rem; + gap: var(--sp-2) var(--sp-2); margin: 0; align-items: baseline; } .shortcutLegend dt { display: flex; - gap: 0.2rem; + gap: var(--sp-1); } .shortcutLegend dd { margin: 0; color: #b8b8b8; - font-size: 0.78rem; + font-size: var(--fs-sm); line-height: 1.35; } .shortcutKey { - background: #232323; + background: var(--surface-2); border: 1px solid #3f3f3f; border-bottom-width: 2px; - border-radius: 4px; - padding: 0.05rem 0.32rem; + border-radius: var(--r-sm); + padding: var(--sp-0) var(--sp-1); font-family: inherit; - font-size: 0.68rem; - color: #d8b46a; + font-size: var(--fs-xs); + color: var(--gold); white-space: nowrap; } @@ -1302,20 +1463,20 @@ button, .rulingsIndex { display: flex; flex-wrap: wrap; - gap: 0.35rem; - margin: 0 0 1rem; + gap: var(--sp-1); + margin: 0 0 var(--sp-4); } .rulingsIndexBtn { box-sizing: border-box; min-height: 32px; - padding: 0.3rem 0.6rem; - border-radius: 999px; - border: 1px solid #3a3a3a; - background: #232323; - color: #d8b46a; + padding: var(--sp-1) var(--sp-2); + border-radius: var(--r-pill); + border: 1px solid var(--line); + background: var(--surface-2); + color: var(--gold); font-family: inherit; - font-size: 0.72rem; + font-size: var(--fs-sm); line-height: 1.25; text-align: left; cursor: pointer; @@ -1323,8 +1484,8 @@ button, @media (hover: hover) { .rulingsIndexBtn:hover { - background: #2e2e2e; - border-color: #4a4a4a; + background: var(--surface-3); + border-color: var(--line-strong); } } @@ -1332,7 +1493,7 @@ button, @media (pointer: coarse) { .rulingsIndexBtn { min-height: 44px; - font-size: 0.78rem; + font-size: var(--fs-sm); } } @@ -1343,15 +1504,15 @@ button, } .ruling { - margin-bottom: 1rem; - padding-left: 0.7rem; + margin-bottom: var(--sp-4); + padding-left: var(--sp-3); border-left: 2px solid rgba(216, 180, 106, 0.4); } .ruling h3 { - margin: 0 0 0.35rem; - color: #d8b46a; - font-size: 0.82rem; + margin: 0 0 var(--sp-1); + color: var(--gold); + font-size: var(--fs-base); font-weight: 600; letter-spacing: 0.03em; } @@ -1359,7 +1520,7 @@ button, .ruling p, .ruling li { color: #c4c4c4; - font-size: 0.82rem; + font-size: var(--fs-base); line-height: 1.5; } @@ -1369,19 +1530,19 @@ button, .ruling ul { margin: 0; - padding-left: 1.1rem; + padding-left: var(--sp-4); } .ruling li + li { - margin-top: 0.25rem; + margin-top: var(--sp-1); } .rulingsSource { margin: 0; - padding-top: 0.6rem; - border-top: 1px solid #2c2c2c; - color: #8a8a8a; - font-size: 0.72rem; + padding-top: var(--sp-2); + border-top: 1px solid var(--surface-3); + color: var(--text-dim); + font-size: var(--fs-sm); line-height: 1.45; } @@ -1410,11 +1571,11 @@ button, #seatMenuSheet { width: 100%; max-width: 420px; - background: linear-gradient(180deg, #242424, #1a1a1a); - border: 1px solid #3a3a3a; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); border-bottom: none; - border-radius: 16px 16px 0 0; - padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px)); + border-radius: var(--r-lg) var(--r-lg) 0 0; + padding: var(--sp-1) var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65); /* This sheet had no height limit and didn't scroll, so on a phone held sideways its first two actions — Fold and All-in, the two that matter — sat below the @@ -1426,46 +1587,46 @@ button, } .seatMenuHeader { - margin-bottom: 0.8rem; + margin-bottom: var(--sp-3); } .seatMenuHeader h2 { margin: 0; - font-size: 1.05rem; + font-size: var(--fs-lg); font-weight: 500; - color: #f5f5f5; + color: var(--text-bright); } #seatMenuSubtitle { - color: #9a9a9a; - font-size: 0.8rem; + color: var(--text-muted); + font-size: var(--fs-base); font-variant-numeric: tabular-nums; } #seatMenuActions { display: flex; flex-direction: column; - gap: 0.45rem; - margin-bottom: 0.6rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } .seatMenuBtn { width: 100%; min-height: 52px; - border-radius: 11px; - border: 1px solid #3a3a3a; - background: linear-gradient(180deg, #2e2e2e, #232323); - color: #eee; + border-radius: var(--r-md); + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); + color: var(--text); font-family: inherit; - font-size: 0.95rem; + font-size: var(--fs-md); cursor: pointer; transition: all 0.12s ease; } @media (hover: hover) { .seatMenuBtn:hover { - border-color: #4a4a4a; - background: linear-gradient(180deg, #383838, #2a2a2a); + border-color: var(--line-strong); + background: linear-gradient(180deg, var(--line), #2a2a2a); } } @@ -1476,9 +1637,9 @@ button, } .seatMenuBtn.menuAccent { - background: linear-gradient(180deg, #3a2f18, #241d0f); - border-color: #a9782f; - color: #f0d9a8; + background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade)); + border-color: var(--gold-deep); + color: var(--gold-bright); } #seatMenuCancelBtn { @@ -1508,28 +1669,28 @@ button, max-width: 460px; max-height: 88vh; overflow-y: auto; - background: linear-gradient(180deg, #242424, #1a1a1a); - border: 1px solid #3a3a3a; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); border-bottom: none; - border-radius: 16px 16px 0 0; - padding: 0.35rem 0.9rem calc(1rem + env(safe-area-inset-bottom, 0px)); + border-radius: var(--r-lg) var(--r-lg) 0 0; + padding: var(--sp-1) var(--sp-4) calc(var(--sp-4) + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65); } .cardPickerHeader { display: flex; align-items: center; - gap: 0.5rem; - margin-bottom: 0.6rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } .cardPickerHeader h2 { flex: 1; min-width: 0; margin: 0; - font-size: 0.95rem; + font-size: var(--fs-md); font-weight: 500; - color: #f5f5f5; + color: var(--text-bright); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -1541,28 +1702,28 @@ button, #cardPickerSlots { display: flex; - gap: 0.5rem; - margin-bottom: 0.7rem; + gap: var(--sp-2); + margin-bottom: var(--sp-3); } .pickerSlot { flex: 1; min-height: 46px; - border-radius: 8px; - border: 1px solid #3a3a3a; - background: #191919; - color: #8a8a8a; + border-radius: var(--r-md); + border: 1px solid var(--line); + background: var(--surface-1); + color: var(--text-dim); font-family: var(--font-mono, monospace); - font-size: 1.1rem; + font-size: var(--fs-lg); font-weight: 700; cursor: pointer; } /* The slot the next tap will fill. */ .pickerSlot.active { - border-color: #d8b46a; - background: #241d0f; - color: #f0d9a8; + border-color: var(--gold); + background: var(--gold-shade); + color: var(--gold-bright); } .pickerSlot.cardRed { @@ -1572,21 +1733,21 @@ button, #cardPickerGrid { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 0.35rem; + gap: var(--sp-1); } .pickerCard { display: flex; align-items: center; justify-content: center; - gap: 0.1rem; + gap: var(--sp-0); min-height: 44px; - border-radius: 7px; + border-radius: var(--r-sm); border: 1px solid #d5d5d5; background: #fdfdfd; color: #111; font-family: var(--font-mono, monospace); - font-size: 1rem; + font-size: var(--fs-md); font-weight: 700; cursor: pointer; transition: transform 0.1s ease; @@ -1603,7 +1764,7 @@ button, } .pickerCard.selected { - outline: 3px solid #d8b46a; + outline: 3px solid var(--gold); outline-offset: -1px; } @@ -1612,7 +1773,7 @@ button, .pickerCard:disabled { background: #1d1d1d; border-color: #303030; - color: #4a4a4a; + color: var(--line-strong); cursor: not-allowed; } @@ -1622,22 +1783,22 @@ button, } .seatContribution { - border-top: 1px dashed #3a3a3a; - padding-top: 0.4rem; - margin-bottom: 0.3rem; + border-top: 1px dashed var(--line); + padding-top: var(--sp-2); + margin-bottom: var(--sp-1); } .chipCounts { display: flex; flex-wrap: wrap; - gap: 0.35rem; - margin-bottom: 0.35rem; + gap: var(--sp-1); + margin-bottom: var(--sp-1); } .chipCountRow { display: flex; align-items: center; - gap: 0.2rem; + gap: var(--sp-1); } .chipSwatch { @@ -1650,32 +1811,32 @@ button, .chipCountInput { width: 34px; - background: #1a1a1a; - border: 1px solid #3a3a3a; - border-radius: 4px; - color: #eee; - font-size: 0.75rem; - padding: 0.1rem 0.2rem; + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + font-size: var(--fs-sm); + padding: var(--sp-0) var(--sp-1); } .amountOverrideRow { justify-content: space-between; - font-size: 0.75rem; + font-size: var(--fs-sm); color: #aaa; - margin-bottom: 0.3rem; + margin-bottom: var(--sp-1); } .amountOverrideInput { width: 70px; - background: #1a1a1a; - border: 1px solid #3a3a3a; - border-radius: 4px; - color: #eee; - padding: 0.2rem 0.3rem; + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + padding: var(--sp-1) var(--sp-1); } .seatTotal { - font-size: 0.8rem; + font-size: var(--fs-base); color: #d6ffe0; font-weight: 600; text-align: right; @@ -1684,25 +1845,25 @@ button, .customFields { display: flex; flex-direction: column; - gap: 0.25rem; - margin-bottom: 0.3rem; + gap: var(--sp-1); + margin-bottom: var(--sp-1); } .customFieldRow { display: flex; - gap: 0.25rem; + gap: var(--sp-1); } .customFieldLabel, .customFieldValue { min-width: 0; flex: 1; - background: #1a1a1a; - border: 1px solid #3a3a3a; - border-radius: 4px; - color: #eee; - font-size: 0.7rem; - padding: 0.2rem 0.3rem; + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + font-size: var(--fs-xs); + padding: var(--sp-1) var(--sp-1); } .removeFieldBtn, @@ -1715,18 +1876,21 @@ button, #holdemSidebar { display: flex; flex-direction: column; - gap: 0.8rem; + gap: var(--sp-3); /* Grid items default to min-width:auto, so anything with a wide min-content — a side-by-side field row, say — would push this column past the viewport and drag the table out with it. */ min-width: 0; } +/* No border. The panel already has a lighter background than the page behind it and a + shadow lifting it off — three ways of drawing the same edge, where the background + alone does the job. Putting a line round every box is what makes an interface read + like a spreadsheet: the eye spends itself on the lines rather than the contents. */ .sidebarPanel { - background: linear-gradient(160deg, #212121, #1a1a1a); - border: 1px solid #333; - border-radius: 10px; - padding: 0.2rem 0.9rem; + background: linear-gradient(160deg, #212121, var(--surface-1)); + border-radius: var(--r-md); + padding: var(--sp-2) var(--sp-4); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } @@ -1736,51 +1900,59 @@ button, device, phones included — the touch pass further down never reached them. Padding rather than a flex box with min-height, because a only draws its disclosure triangle while it is display: list-item. */ - padding: 0.85rem 0; + padding: var(--sp-3) 0; /* A is content-box, so min-height would have been *added* to the padding — measured at 71px before this line went in. Same trap the header buttons were in, one file over. */ box-sizing: border-box; min-height: 44px; - font-size: 0.9rem; - color: #eee; + font-size: var(--fs-md); + color: var(--text); letter-spacing: 0.03em; } .panelBody { - padding-bottom: 0.9rem; + padding-bottom: var(--sp-4); display: flex; flex-direction: column; - gap: 0.7rem; + gap: var(--sp-3); } +/* Deliberately not animated. The obvious move here is the grid 0fr -> 1fr trick, and + it does not work on a
: the browser doesn't render the body at all while + the panel is shut, so there is no "from" for a transition to start at. The real + answer is ::details-content with interpolate-size, which Chrome has and the others + don't — and a panel that glides open on one phone and snaps on another is worse + than one that snaps on both. Measured either way, opening a panel moves nothing + else on the page, so the snap is contained. Left alone until support catches up. */ + /* A heading inside a panel, so one panel can hold two related things — the standings and the hand log are two views of the same session — without needing two identical-looking accordions to say so. */ .panelSubhead { - margin: 0.2rem 0 0; - color: #d8b46a; - font-size: 0.68rem; + margin: var(--sp-1) 0 0; + color: var(--gold); + font-size: var(--fs-xs); font-weight: 600; letter-spacing: 0.09em; text-transform: uppercase; } .panelSubhead + * { - margin-top: -0.2rem; + margin-top: -var(--sp-1); } /* Nested disclosure for the things a dealer sets once and then forgets, kept out of the way of the settings they actually touch. */ .subPanel { - border-top: 1px solid #2c2c2c; - padding-top: 0.5rem; + border-top: 1px solid var(--surface-3); + padding-top: var(--sp-2); } .subPanel summary { cursor: pointer; - color: #9a9a9a; - font-size: 0.78rem; + color: var(--text-muted); + font-size: var(--fs-sm); } @media (hover: hover) { @@ -1792,32 +1964,32 @@ button, .subPanelBody { display: flex; flex-direction: column; - gap: 0.5rem; - padding-top: 0.6rem; + gap: var(--sp-2); + padding-top: var(--sp-2); } .fieldRow { display: flex; align-items: center; justify-content: space-between; - gap: 0.6rem; - font-size: 0.85rem; + gap: var(--sp-2); + font-size: var(--fs-base); color: #ccc; } .fieldRow input, .fieldRow select { - background: #141414; - border: 1px solid #3a3a3a; - border-radius: 5px; - color: #eee; - padding: 0.35rem 0.5rem; + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + padding: var(--sp-1) var(--sp-2); max-width: 140px; } .fieldHint { - font-size: 0.75rem; - color: #949494; + font-size: var(--fs-sm); + color: var(--text-muted); line-height: 1.4; margin: 0; } @@ -1825,31 +1997,31 @@ button, #chipLegendList { display: flex; flex-direction: column; - gap: 0.4rem; + gap: var(--sp-2); } .chipLegendRow { display: flex; align-items: center; - gap: 0.5rem; + gap: var(--sp-2); } .chipColorInput { width: 32px; height: 28px; background: none; - border: 1px solid #3a3a3a; - border-radius: 5px; + border: 1px solid var(--line); + border-radius: var(--r-sm); padding: 0; } .chipValueInput { flex: 1; - background: #141414; - border: 1px solid #3a3a3a; - border-radius: 5px; - color: #eee; - padding: 0.3rem 0.5rem; + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + padding: var(--sp-1) var(--sp-2); } #standingsTable { @@ -1870,14 +2042,17 @@ button, container-type: inline-size; } +/* A rule under every row turned the standings into ruled paper — seven lines on a + six-player table, none of which said anything the row spacing wasn't already saying. + The rows are separated by the space between them; the header keeps its rule, because + that one marks a real change of kind. */ .standingsRow { display: grid; grid-template-columns: minmax(4rem, 1fr) auto auto; - gap: 0.5rem; + gap: var(--sp-2); align-items: baseline; - padding: 0.3rem 0.1rem; - font-size: 0.78rem; - border-bottom: 1px solid #232323; + padding: var(--sp-2) var(--sp-0); + font-size: var(--fs-sm); } .standingsRow > span:not(:first-child) { @@ -1927,7 +2102,7 @@ button, table, so it carries the weight. Up/down is read once, at the end. */ .standingsChips { font-weight: 600; - color: #e6e6e6; + color: var(--text); } .standingsCashOut { @@ -1939,26 +2114,26 @@ button, later, and if the chips outrun the box that promise can't be kept — which a dealer otherwise discovers when the last two players cash out and there's nothing left. */ .bankReconcile { - margin-top: 0.6rem; - padding: 0.55rem 0.6rem; + margin-top: var(--sp-2); + padding: var(--sp-2) var(--sp-2); border: 1px solid #2f2f2f; - border-radius: 8px; + border-radius: var(--r-md); background: #151515; } .bankReconcileRow { display: flex; justify-content: space-between; - gap: 0.75rem; - font-size: 0.78rem; + gap: var(--sp-3); + font-size: var(--fs-sm); color: #b8b8b8; font-variant-numeric: tabular-nums; - padding: 0.12rem 0; + padding: var(--sp-0) 0; } .bankReconcileVerdict { - margin: 0.4rem 0 0; - font-size: 0.74rem; + margin: var(--sp-2) 0 0; + font-size: var(--fs-sm); line-height: 1.35; } @@ -1975,7 +2150,7 @@ button, column of amounts lines up on the decimal point. */ .settleUpList { list-style: none; - margin: 0.35rem 0 0; + margin: var(--sp-1) 0 0; padding: 0; } @@ -1983,9 +2158,9 @@ button, display: flex; justify-content: space-between; align-items: baseline; - gap: 0.75rem; - font-size: 0.8rem; - padding: 0.18rem 0; + gap: var(--sp-3); + font-size: var(--fs-base); + padding: var(--sp-0) 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } @@ -1994,7 +2169,7 @@ button, } .settleUpWho { - color: #ddd; + color: var(--text); } .settleUpAmount { @@ -2011,18 +2186,21 @@ button, } .standingsHeader { - color: #8a8a8a; - font-size: 0.7rem; + color: var(--text-dim); + font-size: var(--fs-xs); letter-spacing: 0.04em; text-transform: uppercase; - border-bottom-color: #3a3a3a; + /* The one rule kept in this table: it separates the column names from the players, + which is a change of kind rather than just the next row down. Stated in full + now the rows themselves have none to inherit a colour from. */ + border-bottom: 1px solid var(--line); position: sticky; top: 0; background: #1b1b1b; } .standingsName { - color: #eee; + color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -2030,7 +2208,7 @@ button, .standingsIn, .standingsWon { - color: #999; + color: var(--text-muted); } .standingsNet { @@ -2050,17 +2228,17 @@ button, table at a glance — this is something the dealer says out loud — but not so loud it competes with the button underneath it. */ .showdownCall { - margin: 0 0 0.45rem; + margin: 0 0 var(--sp-2); text-align: center; - font-size: 0.82rem; + font-size: var(--fs-base); color: #f0d98c; } .handHistoryFilterRow { display: flex; align-items: center; - gap: 0.5rem; - margin-bottom: 0.45rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } .handHistoryFilter { @@ -2070,33 +2248,33 @@ button, the felt off screen mid-hand. Every other field in this panel is held at 16px for the same reason. */ font-size: 16px; - padding: 0.35rem 0.5rem; - background: #141414; - border: 1px solid #2c2c2c; - border-radius: 6px; - color: #e6e6e6; + padding: var(--sp-1) var(--sp-2); + background: var(--surface-0); + border: 1px solid var(--surface-3); + border-radius: var(--r-sm); + color: var(--text); } .handHistoryFilter::placeholder { - color: #8a8a8a; + color: var(--text-dim); } .handHistoryFilter:focus-visible { - outline: 2px solid #d8b46a; + outline: 2px solid var(--gold); outline-offset: 1px; } .handHistoryCount { flex: 0 0 auto; - font-size: 0.72rem; - color: #8a8a8a; + font-size: var(--fs-sm); + color: var(--text-dim); font-variant-numeric: tabular-nums; } #handHistoryList { display: flex; flex-direction: column; - gap: 0.4rem; + gap: var(--sp-2); /* Was a flat 260px, which is about four and a half rows — the same four and a half whether the dealer is on a phone or a 27" screen with room for twenty. The floor keeps it honest on a short window; the ceiling stops the panel from running away @@ -2106,49 +2284,49 @@ button, } .handHistoryRow { - background: #141414; - border: 1px solid #2c2c2c; - border-radius: 6px; - padding: 0.45rem 0.6rem; + background: var(--surface-0); + border: 1px solid var(--surface-3); + border-radius: var(--r-sm); + padding: var(--sp-2) var(--sp-2); } .handHistoryRowHeader { display: flex; align-items: baseline; - gap: 0.5rem; - font-size: 0.8rem; + gap: var(--sp-2); + font-size: var(--fs-base); } .handHistoryHandNumber { - color: #d8b46a; + color: var(--gold); font-weight: 600; } .handHistoryPot { - color: #eee; + color: var(--text); } .handHistoryTime { margin-left: auto; - color: #8a8a8a; - font-size: 0.72rem; + color: var(--text-dim); + font-size: var(--fs-sm); } .handHistoryPayouts { - margin-top: 0.15rem; + margin-top: var(--sp-0); color: #aaa; - font-size: 0.75rem; + font-size: var(--fs-sm); line-height: 1.4; } #tableNotes { width: 100%; box-sizing: border-box; - background: #141414; - border: 1px solid #3a3a3a; - border-radius: 6px; - color: #eee; - padding: 0.5rem; + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: var(--r-sm); + color: var(--text); + padding: var(--sp-2); resize: vertical; font-family: inherit; /* iOS zooms the whole page in when you focus a field under 16px, and the dealer @@ -2172,11 +2350,11 @@ button, margin: 0 auto; z-index: 55; max-width: 720px; - background: linear-gradient(180deg, #232323, #191919); - border: 1px solid #383838; + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); + border: 1px solid var(--line); border-bottom: none; - border-radius: 14px 14px 0 0; - padding: 0.6rem 0.75rem calc(0.75rem + env(safe-area-inset-bottom, 0px)); + border-radius: var(--r-lg) var(--r-lg) 0 0; + padding: var(--sp-2) var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-bottom, 0px)); box-shadow: 0 -12px 34px rgba(0, 0, 0, 0.6); } @@ -2193,23 +2371,58 @@ body.hasActionBar #holdemSidebar { display: flex; flex-wrap: wrap; align-items: baseline; - gap: 0.4rem 0.6rem; - margin-bottom: 0.55rem; - font-size: 0.78rem; + gap: var(--sp-2) var(--sp-2); + margin-bottom: var(--sp-2); + font-size: var(--fs-sm); } -/* Always there while there's something to take back, rather than a toast per tap. */ +/* The bar is fixed to the bottom, so anything that changes its height moves its top + edge — the one part of it the dealer is actually looking at. This row is where that + comes from: it wraps, and "PRE-FLOP · Pot $0 · 6 players" and "FLOP · Pot $128 · + 5 players" don't take the same number of lines at every width. + So while there is a hand running — which is when the undo pill is present, and the + only time the bar is tapped repeatedly — the row holds two lines' worth of room + whether it needs them or not. Reserving space is cheaper than moving. */ +.actionStatus:has(.actionUndo) { + /* The undo pill's own height, the gap under it, and one line of status text. */ + min-height: calc(30px + var(--sp-2) + 1.75rem); +} + +/* Except on a phone held sideways, where the row is wide enough that it never wraps in + the first place — there is nothing to reserve against, and reserving anyway made the + bar 36px taller than it needed to be. That came straight off --feltRoomPx, and a + felt squeezed to 117px pushed the top seat's dealer button up over the header, where + it covered a corner of Next Hand. Height is scarce in landscape; this buys nothing + there. */ +@media (orientation: landscape) and (max-height: 560px) { + .actionStatus:has(.actionUndo) { + min-height: 0; + } +} + +/* Always there while there's something to take back, rather than a toast per tap. + + Fixed width, not content width. The label names what would be taken back, so it + changes on every single action — "↶ dealing the hand" is 148px, "↶ Fay's call" is + 108 — and a control that resizes under the dealer's thumb after every tap reads as + the interface twitching rather than responding. Measured across one hand: 148, 110, + 109, 108, 109, 110, 120. Now it is 9rem whatever it says, and the long labels + ellipsize into it, which they were already set up to do. */ .actionUndo { flex: none; + display: inline-flex; + align-items: center; + gap: var(--sp-1); min-height: 30px; + width: 10rem; max-width: 100%; - padding: 0.15rem 0.6rem; - border-radius: 999px; + padding: var(--sp-0) var(--sp-2); + border-radius: var(--r-pill); border: 1px solid #414141; background: #262626; color: #bdbdbd; font-family: inherit; - font-size: 0.72rem; + font-size: var(--fs-sm); cursor: pointer; overflow: hidden; text-overflow: ellipsis; @@ -2218,18 +2431,18 @@ body.hasActionBar #holdemSidebar { @media (hover: hover) { .actionUndo:hover { - background: #333; + background: var(--surface-3); border-color: #4f4f4f; - color: #eee; + color: var(--text); } } .actionStreet { - color: #d8b46a; + color: var(--gold); font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; - font-size: 0.7rem; + font-size: var(--fs-xs); } .actionPot { @@ -2244,8 +2457,8 @@ body.hasActionBar #holdemSidebar { .actionRunout { margin-left: auto; - color: #d8b46a; - font-size: 0.74rem; + color: var(--gold); + font-size: var(--fs-sm); } .actionWho { @@ -2262,13 +2475,13 @@ body.hasActionBar #holdemSidebar { flex: none; min-width: 3.1rem; text-align: right; - color: #949494; + color: var(--text-muted); font-variant-numeric: tabular-nums; transition: color 0.3s ease; } .actionTank.tankNudge { - color: #d8b46a; + color: var(--gold); } .actionTank.tankSlow { @@ -2286,13 +2499,13 @@ body.hasActionBar #holdemSidebar { .actionClockBtn { flex: none; min-height: 44px; - padding: 0.25rem 0.6rem; - border-radius: 8px; + padding: var(--sp-1) var(--sp-2); + border-radius: var(--r-md); border: 1px solid #5b4a2a; background: rgba(216, 180, 106, 0.12); color: #e8cf9a; font-family: inherit; - font-size: 0.72rem; + font-size: var(--fs-sm); cursor: pointer; } @@ -2300,13 +2513,13 @@ body.hasActionBar #holdemSidebar { carries it out — and coloured like the thing it is, which is a hand about to be killed for slow play. */ .clockExpired { - margin: 0 0 0.5rem; - padding: 0.45rem 0.6rem; - border-radius: 8px; + margin: 0 0 var(--sp-2); + padding: var(--sp-2) var(--sp-2); + border-radius: var(--r-md); background: rgba(224, 123, 123, 0.12); border: 1px solid rgba(224, 123, 123, 0.45); color: #f3c9c9; - font-size: 0.8rem; + font-size: var(--fs-base); } .actionBtn.actionEnforceClock { @@ -2326,8 +2539,8 @@ body.hasActionBar #holdemSidebar { name — it doesn't need to be hard to read as well. */ .standingsRow.standingsGone { background: rgba(255, 255, 255, 0.04); - border-left: 2px solid #4a4a4a; - padding-left: 0.35rem; + border-left: 2px solid var(--line-strong); + padding-left: var(--sp-1); } .standingsRow.standingsGone .standingsName { @@ -2339,22 +2552,22 @@ body.hasActionBar #holdemSidebar { in, and a way off the list. */ .waitingAdd { display: flex; - gap: 0.4rem; - margin-bottom: 0.5rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } .waitingAdd input { flex: 1; min-width: 0; min-height: 44px; - background: #141414; - border: 1px solid #3a3a3a; - border-radius: 8px; - color: #eee; + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: var(--r-md); + color: var(--text); font-family: inherit; /* 16px, or iOS zooms the whole page in when it takes focus. */ font-size: 16px; - padding: 0.35rem 0.6rem; + padding: var(--sp-1) var(--sp-2); } .waitingAdd .holdemBtn { @@ -2368,17 +2581,17 @@ body.hasActionBar #holdemSidebar { .waitingList { list-style: none; counter-reset: waiting; - margin: 0 0 0.5rem; + margin: 0 0 var(--sp-2); padding: 0; display: flex; flex-direction: column; - gap: 0.35rem; + gap: var(--sp-1); } .waitingRow { display: flex; align-items: center; - gap: 0.4rem; + gap: var(--sp-2); counter-increment: waiting; } @@ -2387,8 +2600,8 @@ body.hasActionBar #holdemSidebar { content: counter(waiting) "."; flex: none; min-width: 1.2rem; - color: #9a9a9a; - font-size: 0.78rem; + color: var(--text-muted); + font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } @@ -2398,14 +2611,14 @@ body.hasActionBar #holdemSidebar { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: #e8e8e8; - font-size: 0.85rem; + color: var(--text); + font-size: var(--fs-base); } .waitingNote { flex: none; - color: #9a9a9a; - font-size: 0.75rem; + color: var(--text-muted); + font-size: var(--fs-sm); } .waitingRow .holdemBtn { @@ -2422,18 +2635,18 @@ body.hasActionBar #holdemSidebar { .potBreakdown { display: flex; flex-direction: column; - gap: 0.2rem; - margin-bottom: 0.55rem; - padding: 0.4rem 0.55rem; + gap: var(--sp-1); + margin-bottom: var(--sp-2); + padding: var(--sp-2) var(--sp-2); background: rgba(216, 180, 106, 0.07); border: 1px solid rgba(216, 180, 106, 0.25); - border-radius: 8px; + border-radius: var(--r-md); } .potBreakdownHeading { - margin: 0 0 0.35rem; - color: #d8b46a; - font-size: 0.68rem; + margin: 0 0 var(--sp-1); + color: var(--gold); + font-size: var(--fs-xs); letter-spacing: 0.08em; text-transform: uppercase; } @@ -2441,25 +2654,25 @@ body.hasActionBar #holdemSidebar { .potLayer { display: flex; align-items: baseline; - gap: 0.5rem; - font-size: 0.76rem; + gap: var(--sp-2); + font-size: var(--fs-sm); } .potLayerLabel { - color: #d8b46a; + color: var(--gold); font-weight: 600; flex: none; } .potLayerAmount { - color: #f2f2f2; + color: var(--text-bright); font-variant-numeric: tabular-nums; font-weight: 600; flex: none; } .potLayerWho { - color: #9a9a9a; + color: var(--text-muted); flex: 1; min-width: 0; text-align: right; @@ -2469,18 +2682,18 @@ body.hasActionBar #holdemSidebar { } .potBreakdownWizard { - margin-bottom: 1rem; + margin-bottom: var(--sp-4); } .potLayerReturn .potLayerLabel, .potLayerReturn .potLayerAmount { - color: #9a9a9a; + color: var(--text-muted); font-weight: 500; } .actionButtons { display: flex; - gap: 0.5rem; + gap: var(--sp-2); } .actionBtn { @@ -2490,23 +2703,23 @@ body.hasActionBar #holdemSidebar { flex-direction: column; align-items: center; justify-content: center; - gap: 0.1rem; + gap: var(--sp-0); min-height: 56px; - padding: 0.5rem 0.6rem; - border-radius: 11px; - border: 1px solid #3a3a3a; - background: linear-gradient(180deg, #2e2e2e, #232323); - color: #eee; + padding: var(--sp-2) var(--sp-2); + border-radius: var(--r-md); + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); + color: var(--text); font-family: inherit; - font-size: 0.95rem; + font-size: var(--fs-md); cursor: pointer; transition: all 0.12s ease; } @media (hover: hover) { .actionBtn:hover { - border-color: #4a4a4a; - background: linear-gradient(180deg, #383838, #2a2a2a); + border-color: var(--line-strong); + background: linear-gradient(180deg, var(--line), #2a2a2a); } } @@ -2519,21 +2732,32 @@ body.hasActionBar #holdemSidebar { } .actionBtnSub { - font-size: 0.72rem; + font-size: var(--fs-sm); color: #a8a8a8; font-variant-numeric: tabular-nums; } /* 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. */ +.actionUndoLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .actionUndoKey { /* See .actionKey below for why these are not fainter. */ - margin-left: 0.4rem; - padding: 0 0.25rem; - border-radius: 4px; + flex: none; + /* Pinned to the end of the pill rather than trailing the label, so it sits in the + same place whatever the label says. */ + margin-left: auto; + padding: 0 var(--sp-1); + border-radius: var(--r-sm); border: 1px solid currentColor; opacity: 0.75; font-family: inherit; - font-size: 0.6rem; + font-size: var(--fs-2xs); line-height: 1.4; text-transform: uppercase; pointer-events: none; @@ -2547,12 +2771,12 @@ body.hasActionBar #holdemSidebar { position: absolute; top: 5px; right: 7px; - padding: 0 0.25rem; - border-radius: 4px; + padding: 0 var(--sp-1); + border-radius: var(--r-sm); border: 1px solid currentColor; opacity: 0.75; font-family: inherit; - font-size: 0.6rem; + font-size: var(--fs-2xs); line-height: 1.4; text-transform: uppercase; pointer-events: none; @@ -2585,9 +2809,9 @@ body.hasActionBar #holdemSidebar { } .actionRaise { - background: linear-gradient(180deg, #3a2f18, #241d0f); - border-color: #a9782f; - color: #f0d9a8; + background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade)); + border-color: var(--gold-deep); + color: var(--gold-bright); } .actionAdvance { @@ -2600,43 +2824,43 @@ body.hasActionBar #holdemSidebar { .raiseQuick { display: flex; - gap: 0.4rem; - margin-bottom: 0.5rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } .raiseQuickBtn { min-height: 48px; - font-size: 0.85rem; + font-size: var(--fs-base); } .raiseQuickBtn.selected { - border-color: #d8b46a; - background: linear-gradient(180deg, #3a2f18, #241d0f); - color: #f0d9a8; + border-color: var(--gold); + background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade)); + color: var(--gold-bright); } .raiseEntry { display: flex; - gap: 0.4rem; + gap: var(--sp-2); } .raiseInput { flex: 1; min-width: 0; - background: #141414; - border: 1px solid #3a3a3a; - border-radius: 11px; - color: #f2f2f2; + background: var(--surface-0); + border: 1px solid var(--line); + border-radius: var(--r-md); + color: var(--text-bright); font-family: inherit; font-size: 16px; font-variant-numeric: tabular-nums; text-align: center; - padding: 0.5rem; + padding: var(--sp-2); } .raiseInput:focus { outline: none; - border-color: #d8b46a; + border-color: var(--gold); } .raiseCancel { @@ -2647,9 +2871,9 @@ body.hasActionBar #holdemSidebar { .raiseConfirm { flex: 0 0 auto; min-width: 116px; - background: linear-gradient(180deg, #3a2f18, #241d0f); - border-color: #a9782f; - color: #f0d9a8; + background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade)); + border-color: var(--gold-deep); + color: var(--gold-bright); } /* Whose turn it is, shown on the table itself as well as in the bar. @@ -2699,10 +2923,10 @@ body.hasActionBar #holdemSidebar { } .seatPuckStack { - font-size: 0.58rem; - color: #9a9a9a; + font-size: var(--fs-2xs); + color: var(--text-muted); font-variant-numeric: tabular-nums; - margin-top: 0.1rem; + margin-top: var(--sp-0); } /* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider @@ -2730,7 +2954,7 @@ body.hasActionBar #holdemSidebar { than a small one, and the exact figure is what the tool is for. */ .seatPuckStack.puckAmountLong, .seatPuckTotal.puckAmountLong { - font-size: 0.5rem; + font-size: var(--fs-2xs); letter-spacing: -0.01em; } @@ -2743,13 +2967,13 @@ body.hasActionBar #holdemSidebar { .blindsRow { display: flex; flex-wrap: wrap; - gap: 0.6rem; + gap: var(--sp-2); } .blindsRow .fieldRow { flex: 1 1 120px; min-width: 0; - gap: 0.4rem; + gap: var(--sp-2); } /* "Small blind" broke onto two lines beside its input, which made the pair look @@ -2769,11 +2993,11 @@ body.hasActionBar #holdemSidebar { position: fixed; left: 0; right: 0; - bottom: 1.25rem; + bottom: var(--sp-5); margin: 0 auto; z-index: 80; max-width: 560px; - padding: 0 0.75rem; + padding: 0 var(--sp-3); box-sizing: border-box; pointer-events: none; transition: bottom 0.18s ease; @@ -2793,12 +3017,12 @@ body.hasActionBar #toastHost { .toast { display: flex; align-items: center; - gap: 0.75rem; - background: linear-gradient(180deg, #2b2b2b, #202020); + gap: var(--sp-3); + background: linear-gradient(180deg, var(--surface-3), #202020); border: 1px solid #414141; - border-left: 3px solid #d8b46a; - border-radius: 10px; - padding: 0.7rem 0.8rem 0.7rem 0.9rem; + border-left: 3px solid var(--gold); + border-radius: var(--r-md); + padding: var(--sp-3) var(--sp-3) var(--sp-3) var(--sp-4); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.55); pointer-events: auto; opacity: 0; @@ -2815,19 +3039,19 @@ body.hasActionBar #toastHost { flex: 1; min-width: 0; color: #e9e9e9; - font-size: 0.85rem; + font-size: var(--fs-base); line-height: 1.35; } .toastAction { flex: none; min-height: 38px; - padding: 0.35rem 0.9rem; - border-radius: 8px; - border: 1px solid #d8b46a; + padding: var(--sp-1) var(--sp-4); + border-radius: var(--r-md); + border: 1px solid var(--gold); background: rgba(216, 180, 106, 0.12); - color: #f0d9a8; - font-size: 0.85rem; + color: var(--gold-bright); + font-size: var(--fs-base); font-weight: 600; font-family: inherit; cursor: pointer; @@ -2846,8 +3070,8 @@ body.hasActionBar #toastHost { border-radius: 50%; border: none; background: transparent; - color: #949494; - font-size: 0.8rem; + color: var(--text-muted); + font-size: var(--fs-base); font-family: inherit; cursor: pointer; /* Same treatment as the dealer puck: a 30px dismiss button in a toast a dealer is @@ -2858,8 +3082,8 @@ body.hasActionBar #toastHost { @media (hover: hover) { .toastClose:hover { - background: #333; - color: #ddd; + background: var(--surface-3); + color: var(--text); } } @@ -2873,7 +3097,7 @@ body.hasActionBar #toastHost { align-items: center; justify-content: center; z-index: 90; - padding: 1.5rem; + padding: var(--sp-5); } #confirmOverlay[hidden] { @@ -2881,34 +3105,34 @@ body.hasActionBar #toastHost { } #confirmModal { - background: #1e1e1e; - border: 1px solid #3a3a3a; - border-radius: 12px; + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: var(--r-md); width: 100%; max-width: 420px; - padding: 1.3rem 1.4rem 1.2rem; + padding: var(--sp-5) var(--sp-5); box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6); outline: none; } #confirmTitle { - margin: 0 0 0.5rem; - font-size: 1.05rem; + margin: 0 0 var(--sp-2); + font-size: var(--fs-lg); font-weight: 500; - color: #f5f5f5; + color: var(--text-bright); } #confirmMessage { - margin: 0 0 1.2rem; + margin: 0 0 var(--sp-5); color: #a5a5a5; - font-size: 0.85rem; + font-size: var(--fs-base); line-height: 1.5; } .confirmActions { display: flex; justify-content: flex-end; - gap: 0.6rem; + gap: var(--sp-2); } .confirmActions .holdemBtn { @@ -2928,7 +3152,7 @@ body.hasActionBar #toastHost { the Fold button — and a toast could sit over "Confirm Winner(s)". It's the odd one out rather than a deliberate choice, and it joins the others. */ z-index: 95; - padding: 1.5rem; + padding: var(--sp-5); } #potWizardOverlay[hidden] { @@ -2936,9 +3160,9 @@ body.hasActionBar #toastHost { } #potWizardModal { - background: #1e1e1e; - border: 1px solid #3a3a3a; - border-radius: 10px; + background: var(--surface-1); + border: 1px solid var(--line); + border-radius: var(--r-md); width: 100%; max-width: 480px; /* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over @@ -2948,44 +3172,44 @@ body.hasActionBar #toastHost { max-height: calc(85vh - var(--actionBarHeight, 0px)); overflow-y: auto; overscroll-behavior: contain; - padding: 1.2rem 1.4rem 1.6rem; + padding: var(--sp-5) var(--sp-5) var(--sp-5); } .potWizardHeader { display: flex; align-items: center; justify-content: space-between; - margin-bottom: 0.8rem; + margin-bottom: var(--sp-3); } .potWizardHeader h2 { margin: 0; - font-size: 1.2rem; + font-size: var(--fs-lg); font-weight: 400; - color: #f5f5f5; + color: var(--text-bright); } .potWizardLog { - margin-bottom: 0.8rem; + margin-bottom: var(--sp-3); } .potWizardLogLine { - font-size: 0.8rem; - color: #999; - margin: 0.2rem 0; + font-size: var(--fs-base); + color: var(--text-muted); + margin: var(--sp-1) 0; } .potWizardPrompt { - font-size: 0.95rem; - color: #eee; - margin-bottom: 0.8rem; + font-size: var(--fs-md); + color: var(--text); + margin-bottom: var(--sp-3); } .potWizardChoices { display: flex; flex-direction: column; - gap: 0.4rem; - margin-bottom: 0.6rem; + gap: var(--sp-2); + margin-bottom: var(--sp-2); } /* This is the screen where the money gets decided, so it holds the same 44px+ @@ -2994,21 +3218,21 @@ body.hasActionBar #toastHost { .potWizardChoice { display: flex; align-items: center; - gap: 0.7rem; + gap: var(--sp-3); min-height: 56px; - background: #141414; - border: 1px solid #333; - border-radius: 10px; - padding: 0.6rem 0.8rem; + background: var(--surface-0); + border: 1px solid var(--surface-3); + border-radius: var(--r-md); + padding: var(--sp-2) var(--sp-3); cursor: pointer; - font-size: 0.95rem; + font-size: var(--fs-md); transition: border-color 0.12s ease, background 0.12s ease; } @media (hover: hover) { .potWizardChoice:hover { border-color: #555; - background: #191919; + background: var(--surface-1); } } @@ -3035,26 +3259,26 @@ body.hasActionBar #toastHost { min-width: 0; display: flex; flex-direction: column; - gap: 0.1rem; + gap: var(--sp-0); } .potWizardChoiceName { - color: #f0f0f0; + color: var(--text-bright); font-weight: 600; } .potWizardChoiceUpTo { - color: #9a9a9a; - font-size: 0.76rem; + color: var(--text-muted); + font-size: var(--fs-sm); font-variant-numeric: tabular-nums; } .potWizardChoiceCards { flex: none; font-family: var(--font-mono, monospace); - font-size: 1rem; + font-size: var(--fs-md); font-weight: 700; - color: #ddd; + color: var(--text); } .potWizardChoiceCards .cardRed { @@ -3066,25 +3290,25 @@ body.hasActionBar #toastHost { disagree with the arithmetic before the chips move, and money taken quietly is the one thing a tool like this must never do. */ .potWizardRake { - margin: 0 0 0.7rem; - padding: 0.45rem 0.6rem; - border-radius: 8px; + margin: 0 0 var(--sp-3); + padding: var(--sp-2) var(--sp-2); + border-radius: var(--r-md); background: rgba(216, 180, 106, 0.1); border: 1px solid rgba(216, 180, 106, 0.35); color: #e8cf9a; - font-size: 0.82rem; + font-size: var(--fs-base); } .potWizardSummary ul { list-style: none; padding: 0; - margin: 0 0 1rem; + margin: 0 0 var(--sp-4); } .potWizardSummary li { - padding: 0.4rem 0; - border-bottom: 1px dashed #333; - font-size: 0.9rem; + padding: var(--sp-2) 0; + border-bottom: 1px dashed var(--surface-3); + font-size: var(--fs-md); color: #d6ffe0; } @@ -3092,15 +3316,15 @@ body.hasActionBar #toastHost { belongs to — the dealer reads the name and the money first, then counts. */ .payoutChips { display: block; - margin-top: 0.15rem; - font-size: 0.76rem; + margin-top: var(--sp-0); + font-size: var(--fs-sm); color: #9fb8a6; font-variant-numeric: tabular-nums; } .potWizardActions { display: flex; - gap: 0.6rem; + gap: var(--sp-2); } /* Every button in this modal is one a dealer presses with players waiting. */ @@ -3190,33 +3414,29 @@ body.hasActionBar #toastHost { 62px of felt was covered. `--actionBarHeight` is published by actionBar.js and is 0 when no hand is running, so the page doesn't hold a gap it doesn't need. */ #holdemApp { - padding: 1rem 0.75rem calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px)); + padding: var(--sp-4) var(--sp-3) calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px)); } #holdemHeader { - margin-bottom: 1rem; - gap: 0.6rem; + margin-bottom: var(--sp-4); + gap: var(--sp-2); } #holdemHeader h1 { - font-size: 1.4rem; - } - - .holdemSubtitle { - font-size: 0.8rem; + font-size: var(--fs-xl); } #holdemHeaderActions { width: 100%; - gap: 0.45rem; + gap: var(--sp-2); } #holdemHeaderActions .holdemBtn { flex: 1 1 auto; justify-content: center; min-height: 46px; - padding: 0.5rem 0.7rem; - font-size: 0.82rem; + padding: var(--sp-2) var(--sp-3); + font-size: var(--fs-base); } /* Keeps every puck inside the viewport: the ring is drawn within this box, @@ -3240,20 +3460,20 @@ body.hasActionBar #toastHost { .seat { width: 64px; min-height: 42px; - padding: 0.25rem 0.3rem; + padding: var(--sp-1) var(--sp-1); } #potBadge { - padding: 0.5rem 1.1rem; + padding: var(--sp-2) var(--sp-4); } #potBadgeAmount { - font-size: 1.2rem; + font-size: var(--fs-lg); } .tableHint { - margin-top: 0.75rem; - font-size: 0.75rem; + margin-top: var(--sp-3); + font-size: var(--fs-sm); } /* ---- Seat editor as a bottom sheet ---- */ @@ -3267,10 +3487,10 @@ body.hasActionBar #toastHost { margin: 0; max-height: 74vh; overflow-y: auto; - border-radius: 16px 16px 0 0; + border-radius: var(--r-lg) var(--r-lg) 0 0; border-bottom: none; - padding: 0.35rem 1rem 1.25rem; - background: linear-gradient(180deg, #242424, #1a1a1a); + padding: var(--sp-1) var(--sp-4) var(--sp-5); + background: linear-gradient(180deg, var(--surface-2), var(--surface-1)); box-shadow: 0 -14px 40px rgba(0, 0, 0, 0.65); transform: translateY(105%); transition: transform 0.25s ease; @@ -3285,9 +3505,9 @@ body.hasActionBar #toastHost { display: block; width: 38px; height: 4px; - margin: 0.35rem auto 0.5rem; - border-radius: 999px; - background: #4a4a4a; + margin: var(--sp-1) auto var(--sp-2); + border-radius: var(--r-pill); + background: var(--line-strong); } .seatSheetClose { @@ -3295,12 +3515,12 @@ body.hasActionBar #toastHost { align-items: center; flex: none; min-height: 44px; - padding: 0.35rem 1.1rem; - border-radius: 8px; - border: 1px solid #3a3a3a; - background: linear-gradient(180deg, #2e2e2e, #232323); - color: #ddd; - font-size: 0.8rem; + padding: var(--sp-1) var(--sp-4); + border-radius: var(--r-md); + border: 1px solid var(--line); + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); + color: var(--text); + font-size: var(--fs-base); font-family: inherit; cursor: pointer; } @@ -3321,7 +3541,7 @@ body.hasActionBar #toastHost { .seatEditorForm input:not([type="checkbox"]):not([type="color"]) { font-size: 16px; min-height: 44px; - padding: 0.4rem 0.6rem; + padding: var(--sp-2) var(--sp-2); } .seatEditorForm .chipCountInput { @@ -3330,7 +3550,7 @@ body.hasActionBar #toastHost { } .seatEditorForm .chipCounts { - gap: 0.6rem; + gap: var(--sp-2); } .seatEditorForm .chipSwatch { @@ -3343,7 +3563,7 @@ body.hasActionBar #toastHost { } .seatEditorForm .cardSlot { - font-size: 1.5rem; + font-size: var(--fs-xl); min-height: 62px; } @@ -3351,17 +3571,17 @@ body.hasActionBar #toastHost { the bottom of a phone. */ .pickerCard { min-height: 46px; - font-size: 1.05rem; + font-size: var(--fs-lg); } .seatEditorForm .seatCardToggles { - gap: 1rem; + gap: var(--sp-4); } .seatEditorForm .inHandToggle, .seatEditorForm .foldToggle { min-height: 44px; - font-size: 0.95rem; + font-size: var(--fs-md); } .seatEditorForm .inHandToggle input, @@ -3372,14 +3592,14 @@ body.hasActionBar #toastHost { .seatEditorForm .amountOverrideRow, .seatEditorForm .seatTotal { - font-size: 0.95rem; + font-size: var(--fs-md); } .seatEditorForm .addFieldBtn { width: 100%; justify-content: center; min-height: 44px; - margin-top: 0.4rem; + margin-top: var(--sp-2); } /* The sidebar's own controls never got the touch pass the seat form did — these @@ -3390,7 +3610,7 @@ body.hasActionBar #toastHost { .panelBody select { font-size: 16px; min-height: 44px; - padding: 0.4rem 0.6rem; + padding: var(--sp-2) var(--sp-2); } .panelBody .holdemBtnSmall, @@ -3443,7 +3663,7 @@ body.hasActionBar #toastHost { display: inline-flex; align-items: center; min-height: 44px; - padding: 0 0.4rem; + padding: 0 var(--sp-2); } /* The dealer button has to stay a 22px disc — it's a chip sitting on a seat, and @@ -3530,7 +3750,7 @@ body.hasActionBar #toastHost { /* The page's own top padding is 32px of nothing, on the screen that has the least to spare. */ #holdemApp { - padding-top: 0.5rem; + padding-top: var(--sp-2); } /* The header ate 148px of a 390px-tall window, which was most of the reason the @@ -3544,8 +3764,8 @@ body.hasActionBar #toastHost { reader and for the browser's own outline. The hand number stays — nothing else on the screen says which hand this is. */ #holdemHeader { - margin-bottom: 0.4rem; - gap: 0.35rem; + margin-bottom: var(--sp-2); + gap: var(--sp-1); flex-wrap: nowrap; align-items: center; } @@ -3568,20 +3788,16 @@ body.hasActionBar #toastHost { /* Tightened across, never down: the buttons keep the 44px a thumb needs. */ #holdemHeaderActions { flex-wrap: nowrap; - gap: 0.35rem; + gap: var(--sp-1); } #holdemHeaderActions .holdemBtn { - padding-left: 0.55rem; - padding-right: 0.55rem; - font-size: 0.78rem; + padding-left: var(--sp-2); + padding-right: var(--sp-2); + font-size: var(--fs-sm); white-space: nowrap; } - .holdemSubtitle { - display: none; - } - /* There's no vertical room left, but plenty either side of a table this narrow, so the toast moves out of the middle and sits beside the felt instead of on top of it. Centred, it was covering up to six seats at once — and it carries @@ -3612,10 +3828,19 @@ body.hasActionBar #toastHost { matched on height rather than width — so an 844px-wide phone on its side drew its ring against the felt's full box. With the rim a stadium, the top rail is flat, which put the button seat's dealer chip over the Resolve Pot button in the header - above. Percentages of a short felt, so the ring keeps the height it needs. */ + above. Percentages of a short felt, so the ring keeps the height it needs. + + The top edge is a floor rather than a ceiling, because it is the only edge with + something above it to hit. A seat puck hangs 29px above the ring path it sits on + and its dealer button another 25px, while landscape leaves 8px between the header + and the felt — so at 640x360, 12% of a 124px felt is 14.9px of inset and the + button lands 2px inside the header. Measured, not guessed: 24px puts it back to + the 7px of daylight this layout had before, and on any felt tall enough for 12% + to be the larger number, 12% still wins. The bottom edge keeps the ceiling: there + is nothing under the felt but the gap to the bar. */ #seatsLayer, #betsLayer { - inset: min(26px, 12%) 44px; + inset: max(24px, 12%) 44px min(26px, 12%); } } @@ -3635,24 +3860,20 @@ body.hasActionBar #toastHost { } .toast { - padding: 0.5rem 0.7rem; - font-size: 0.78rem; + padding: var(--sp-2) var(--sp-3); + font-size: var(--fs-sm); } } @media (max-width: 700px) and (max-height: 700px) { #holdemHeader { - margin-bottom: 0.5rem; - gap: 0.4rem; + margin-bottom: var(--sp-2); + gap: var(--sp-2); } #holdemHeader h1 { - font-size: 1.15rem; - padding-bottom: 0.2rem; - } - - .holdemSubtitle { - display: none; + font-size: var(--fs-lg); + padding-bottom: var(--sp-1); } } @@ -3720,7 +3941,7 @@ textarea:focus-visible { .subPanelBody input:not([type="checkbox"]):not([type="color"]), .panelBody select { min-height: 44px; - padding: 0.4rem 0.6rem; + padding: var(--sp-2) var(--sp-2); } } @@ -3736,8 +3957,8 @@ textarea:focus-visible { Under-minimum raises are the single most common thing that stops a home game, and the bar used to accept one without a word. */ .raiseNote { - margin: 0.45rem 0 0; - font-size: 0.74rem; + margin: var(--sp-2) 0 0; + font-size: var(--fs-sm); line-height: 1.35; text-align: center; } @@ -3757,8 +3978,8 @@ textarea:focus-visible { input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */ @media (max-height: 700px) { .raiseNote { - margin-top: 0.3rem; - font-size: 0.68rem; + margin-top: var(--sp-1); + font-size: var(--fs-xs); line-height: 1.25; } @@ -3777,19 +3998,19 @@ textarea:focus-visible { .dealPrompt { display: flex; flex-direction: column; - gap: 0.5rem; + gap: var(--sp-2); } .dealPromptLine { margin: 0; text-align: center; - font-size: 0.8rem; + font-size: var(--fs-base); color: #c8c8c8; line-height: 1.35; } .dealPromptLine.dealPromptBlocked { - color: #9a9a9a; + color: var(--text-muted); } .actionBtn.actionDeal { @@ -3808,14 +4029,14 @@ textarea:focus-visible { background: linear-gradient(180deg, #33304a, #24223a); border-color: #4b4770; color: #e6e1ff; - font-size: 0.82rem; - padding-left: 0.7rem; - padding-right: 0.7rem; + font-size: var(--fs-base); + padding-left: var(--sp-3); + padding-right: var(--sp-3); } .actionBtn.actionStraddleOff { - background: linear-gradient(180deg, #2e2e2e, #232323); - border-color: #4a4a4a; + background: linear-gradient(180deg, var(--surface-3), var(--surface-2)); + border-color: var(--line-strong); color: #dcdcdc; } @@ -3834,8 +4055,8 @@ textarea:focus-visible { /* The quieter half of the deal prompt: things the deal is about to do that a dealer would otherwise only discover afterwards. */ .dealPromptLine.dealPromptAside { - font-size: 0.72rem; - color: #9a9a9a; + font-size: var(--fs-sm); + color: var(--text-muted); } /* An aside that has something to press. It wraps rather than squeezing the button: @@ -3846,7 +4067,7 @@ textarea:focus-visible { flex-wrap: wrap; align-items: center; justify-content: center; - gap: 0.4rem 0.55rem; + gap: var(--sp-2) var(--sp-2); } .dealPromptDebtBtn { @@ -3855,8 +4076,8 @@ textarea:focus-visible { that looks like the button. */ flex: 0 0 auto; min-height: 32px; - padding: 0.24rem 0.7rem; - font-size: 0.72rem; + padding: var(--sp-1) var(--sp-3); + font-size: var(--fs-sm); background: rgba(245, 213, 71, 0.1); border: 1px solid rgba(245, 213, 71, 0.45); color: #f0d98c; @@ -3866,7 +4087,7 @@ textarea:focus-visible { /* A fingertip needs 44px, and this one moves money. */ .dealPromptDebtBtn { min-height: 44px; - padding: 0.4rem 0.9rem; + padding: var(--sp-2) var(--sp-4); } } @@ -3888,7 +4109,7 @@ textarea:focus-visible { .panelBody select { font-size: 16px; min-height: 44px; - padding: 0.4rem 0.6rem; + padding: var(--sp-2) var(--sp-2); } .panelBody .holdemBtnSmall, @@ -3931,6 +4152,6 @@ textarea:focus-visible { display: inline-flex; align-items: center; min-height: 44px; - padding: 0 0.4rem; + padding: 0 var(--sp-2); } } diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index b0bc889..05957cc 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -231,7 +231,17 @@ const ActionBar = { undo.type = "button"; undo.className = "actionUndo"; undo.title = `Undo ${undoLabel}`; - undo.textContent = `↶ ${undoLabel}`; + // 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 + // truncated *through* the keycap below it, which then sat squashed against + // the cut-off text looking like a rendering fault. + const label = document.createElement("span"); + label.className = "actionUndoLabel"; + label.textContent = `↶ ${undoLabel}`; + 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 // key printed on it. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js index 6f4048b..10f23fd 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/betting.js @@ -459,6 +459,25 @@ const Betting = { return (state.street || "preflop") === "preflop" && state.seats.every(p => !p.hasActed); }, + // Are the blinds actually down? Moving the button or naming a blind means two + // completely different things depending on the answer, and `onlyBlindsIn` alone + // cannot tell them apart: a table sitting in setup, with no cards out and nothing + // committed, satisfies it just as well as a hand that has only just been dealt. + // + // With a hand open, the blinds are chips on the felt. Re-aiming them has to take + // those chips back and post them again from the new positions, or the badges and + // the money disagree. + // + // Before the deal it is a marker being slid round a table with nothing in the + // middle. Re-posting there would *deal the hand* — betting opened, blinds taken off + // two stacks, action on the seat after the big blind — which is not what a dealer + // means by "she's the big blind this one". Setting the positions is how a dealer + // gets ready to deal; the cards come out when they press the button, and not a + // moment before. + blindsArePosted(state) { + return !!state.bettingOpen && this.onlyBlindsIn(state); + }, + // Chips the pot has been priced with but nobody has actually paid. // // Two ways a contribution gets into this tool. Tapping the action moves chips: the diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index d7555ac..f5b0a34 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -176,8 +176,7 @@ document.addEventListener("DOMContentLoaded", () => { // After it, the blinds have to follow the button or the badges and the // money disagree — but that's a real thing happening to real chips, and // the dealer gets told rather than finding out later. - const willRepost = state.bettingOpen && Betting.onlyBlindsIn(state) && - HoldemState.unresolvedContributions(state) > Betting.EPSILON_CHIPS; + const willRepost = Betting.blindsArePosted(state); seatAction(index, willRepost ? `Button on ${nameOf(index)} — blinds taken back and re-posted.` @@ -204,18 +203,29 @@ document.addEventListener("DOMContentLoaded", () => { if (partner === index || partner === null) partner = Betting.nextLiveIndex(state, index); const label = which === "sb" ? "small blind" : "big blind"; - seatAction(index, `${nameOf(index)} posts the ${label}.`, () => { + // Only a hand that is already open has blinds to move. Before the deal this + // is aiming the next hand, and re-posting would have dealt it — the dealer + // says who the blind is well before they say "cards". + const posted = Betting.blindsArePosted(state); + seatAction(index, posted + ? `${nameOf(index)} posts the ${label}.` + : `${nameOf(index)} has the ${label} next hand.`, () => { state.blindOverride = which === "sb" ? { sbIndex: index, bbIndex: partner } : { sbIndex: partner, bbIndex: index }; - if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); + if (posted) Betting.repostBlinds(state); }); }, - clearBlindOverride: () => seatAction(state.dealerSeatIndex, "Blinds follow the button again.", () => { - state.blindOverride = null; - if (Betting.onlyBlindsIn(state)) Betting.repostBlinds(state); - }), + clearBlindOverride: () => { + const posted = Betting.blindsArePosted(state); + seatAction(state.dealerSeatIndex, posted + ? "Blinds follow the button again — taken back and re-posted." + : "Blinds follow the button again.", () => { + state.blindOverride = null; + if (posted) Betting.repostBlinds(state); + }); + }, editSeat: index => Render.toggleSeatEditor(index), @@ -247,6 +257,22 @@ document.addEventListener("DOMContentLoaded", () => { Render.mount(state, hooks); + // Table Setup used to ship open, so the tool's first screen was its most crowded + // one: seat count, blind direction, dedicated dealer, both blinds, starting stack + // and two sub-panels, all under a felt the dealer had come here to look at. It is + // the right panel to have open on a table nobody has touched, and the wrong one to + // have open on the fourth hand of a night. + // + // Decided once, here, rather than on every render — a panel that reopened itself + // whenever the table emptied would fight the dealer who had just shut it. + (() => { + const panel = document.getElementById("tableSetupPanel"); + if (!panel) return; + const fresh = (Number(state.handNumber) || 1) === 1 && + !state.seats.some(seat => HoldemState.seatInPlay(seat)); + panel.open = fresh; + })(); + const openPotWizard = () => { // Chips still in front of players belong in the pot being priced, so bank them // before the wizard reads anyone's contribution. diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js index d275926..15d5daf 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/render.js @@ -88,6 +88,19 @@ const Render = { hint.textContent = this.canDragSeats() ? "Tap a seat to edit · double-tap to fold · press and hold for quick actions · drag to swap seats." : "Tap a seat to edit · double-tap to fold · press and hold for quick actions, including moving a seat."; + + // But only on the first hand. Two lines of instructions under the felt are worth + // having on a table nobody has dealt at yet, and worth nothing on the fifth hand + // of the night — by then the dealer either knows the gestures or is not going to + // learn them from a caption they have scrolled past forty times. + // + // Keyed to the hand number rather than to whether a hand is currently out, on + // purpose. `bettingOpen` was the obvious test and it is the wrong one: the hint + // would vanish at every deal and come back at every payout, so a two-line block + // under the felt would appear and disappear twice a hand, shunting the rulings + // link and the whole panel stack up and down all night. This way it goes once, + // at the first deal of the session, and stays gone. + hint.hidden = (Number(this.state.handNumber) || 1) > 1 || !!this.state.bettingOpen; }, renderNotes() { @@ -553,8 +566,21 @@ const Render = { // font down keeps every digit, which matters: a dealer misreading a stack is a // worse outcome than a small one, so this shrinks rather than abbreviating. fitAmount(el) { - const length = (el.textContent || "").length; + const formatted = el.textContent || ""; + // The separators go before the type size does. "$1,234,567" is ten characters, + // which used to drop the puck to 7px — and 7px is not a size anybody reads a + // stack off a felt at, so the shrink-rather-than-abbreviate rule above was + // buying precision the dealer then couldn't see. Without its commas the same + // number is eight characters, and eight fits at 10px with every digit intact. + // Grouping is a reading aid for a column of figures; this is one number on a + // 64px puck, and the aid costs more than it gives. + const text = formatted.length >= 10 ? formatted.replace(/,/g, "") : formatted; + if (text !== formatted) el.textContent = text; + + const length = text.length; el.classList.toggle("puckAmountLong", length >= 8 && length < 10); + // Only a stack of $100,000,000 or more still lands here. It is an overflow + // guard rather than a size anybody is expected to read. el.classList.toggle("puckAmountHuge", length >= 10); }, diff --git a/tests/holdem/aimingTheBlinds.test.js b/tests/holdem/aimingTheBlinds.test.js new file mode 100644 index 0000000..28e6e04 --- /dev/null +++ b/tests/holdem/aimingTheBlinds.test.js @@ -0,0 +1,128 @@ +// Saying who the blind is, before saying "cards". +// +// A dealer sets the table up out loud — button here, she's the small blind, he's the +// big blind — and only then deals. The seat menu offers "Make small blind" for exactly +// that, and it was answering by dealing the hand: betting opened, two stacks docked, +// the action bar up and waiting on the seat after the big blind. The dealer had said +// where the blinds go, and the tool heard "deal". +// +// The condition it was asking is `onlyBlindsIn`, which means "nothing has happened in +// this hand except the blinds" — true of a hand one second old, and equally true of a +// table in setup with no cards out and nothing committed. Those two need telling apart, +// which is what `blindsArePosted` is for. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const { Betting, HoldemState, makeTable, chipsOnTable } = require("./load"); + +describe("before the deal", () => { + test("the blinds are not down, however much the table looks ready", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] }); + assert.equal(state.bettingOpen, false); + // The old condition, on its own, says yes here — this is the whole bug. + assert.equal(Betting.onlyBlindsIn(state), true); + assert.equal(Betting.blindsArePosted(state), false); + }); + + test("a table straight out of setup, with nobody seated, is the same answer", () => { + assert.equal(Betting.blindsArePosted(HoldemState.createDefault()), false); + }); + + test("re-posting would have dealt the hand, which is why it is guarded", () => { + // Not a rule, a demonstration: repostBlinds ends in beginHand. Calling it in + // setup is the deal, and nothing about naming a blind should reach it. + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] }); + Betting.repostBlinds(state); + assert.equal(state.bettingOpen, true, "chips off two stacks and the action bar up"); + assert.notEqual(state.actingSeatIndex, null); + }); + + test("naming a blind moves a badge and no chips", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] }); + const before = chipsOnTable(state); + + // What the seat menu does: pin the override, repost only if the blinds are down. + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + if (Betting.blindsArePosted(state)) Betting.repostBlinds(state); + + assert.equal(state.bettingOpen, false, "still setup — the dealer hasn't dealt"); + assert.equal(state.actingSeatIndex, null, "nobody is being asked to act"); + assert.deepEqual(state.seats.map(p => Betting.committed(p)), [0, 0, 0, 0, 0, 0]); + assert.equal(chipsOnTable(state), before); + + // And the badges say what the dealer just said. + const blinds = HoldemState.getBlindSeats(state); + assert.equal(blinds.sbIndex, 3); + assert.equal(blinds.bbIndex, 0); + }); + + test("and the hand that follows posts from where they were aimed", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], smallBlind: 1, bigBlind: 2 }); + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + if (Betting.blindsArePosted(state)) Betting.repostBlinds(state); + + Betting.beginHand(state); + assert.equal(Betting.committed(state.seats[3]), 1); + assert.equal(Betting.committed(state.seats[0]), 2); + }); + + test("giving the button away is a marker moving too", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3] }); + const before = chipsOnTable(state); + + const willRepost = Betting.blindsArePosted(state); + state.dealerSeatIndex = 2; + if (willRepost) Betting.repostBlinds(state); + + assert.equal(willRepost, false); + assert.equal(state.bettingOpen, false); + assert.equal(chipsOnTable(state), before); + }); +}); + +describe("once the hand is dealt", () => { + test("the blinds are down, and re-aiming them moves real chips", () => { + const state = makeTable({ seatCount: 6, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + const before = chipsOnTable(state); + Betting.beginHand(state); + assert.equal(Betting.blindsArePosted(state), true); + assert.equal(Betting.committed(state.seats[1]), 1); + + state.blindOverride = { sbIndex: 3, bbIndex: 0 }; + if (Betting.blindsArePosted(state)) Betting.repostBlinds(state); + + assert.equal(Betting.committed(state.seats[1]), 0, "the old small blind got their chip back"); + assert.equal(Betting.committed(state.seats[3]), 1); + assert.equal(Betting.committed(state.seats[0]), 2); + assert.equal(chipsOnTable(state), before, "no chips created or lost in the shuffle"); + }); + + test("but not once somebody has answered them", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.call(state, state.actingSeatIndex); + assert.equal(Betting.blindsArePosted(state), false, "there is a call to unpick now"); + }); + + test("nor on a later street", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + state.street = "flop"; + assert.equal(Betting.blindsArePosted(state), false); + }); + + test("and a misdealt hand goes back to being a table in setup", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.takeHandBack(state); + assert.equal(Betting.blindsArePosted(state), false, "nothing is posted until the dealer deals again"); + }); + + test("as does a hand that has been paid out", () => { + const state = makeTable({ seatCount: 4, seated: [0, 1, 2, 3], dealerSeatIndex: 0 }); + Betting.beginHand(state); + Betting.sweepToHandTotals(state); + HoldemState.startNextHand(state); + assert.equal(Betting.blindsArePosted(state), false); + }); +}); diff --git a/tests/holdem/designTokens.test.js b/tests/holdem/designTokens.test.js new file mode 100644 index 0000000..e5bdf97 --- /dev/null +++ b/tests/holdem/designTokens.test.js @@ -0,0 +1,149 @@ +// The stylesheet has to keep picking from the scales. +// +// Before the token block went in, holdem.css used 42 different spacing values, 31 font +// sizes, 14 border-radii and 139 colours, and defined three custom properties. None of +// that was anybody's decision — each number was chosen at the moment it was needed, and +// the result read as crowded even where nothing overlapped, because a 0.42rem gap next +// to a 0.45rem gap next to a 0.5rem gap gives the eye no rhythm to lock onto. +// +// A one-off cleanup would last until the next `padding: 0.35rem`. So the scales are +// checked here instead: a new value has to be a step on one of them, or an exception +// with a reason written next to it. +// +// This reads the stylesheet as text rather than through a CSS parser, on purpose — the +// rest of this suite has no dependencies and runs in a second, and a regex over +// declarations is enough to catch the thing that actually happens, which is somebody +// (me) typing a number instead of reaching for a token. +const test = require("node:test"); +const { describe } = require("node:test"); +const assert = require("node:assert"); +const fs = require("fs"); +const path = require("path"); + +const CSS_PATH = path.join(__dirname, "..", "..", "JoshHeaps.Net", "wwwroot", "css", "casino", "holdem.css"); +const css = fs.readFileSync(CSS_PATH, "utf8"); + +// Comments carry measurements and rationale — "measured at 342px tall on a phone" — +// and none of that is a declaration. Strip them before looking for values. +const declarations = css.replace(/\/\*[\s\S]*?\*\//g, ""); + +// Everything after the token block. The :root block is where the raw values are +// supposed to be. +const body = declarations.slice(declarations.indexOf("#potBadgeAmount,")); + +const valuesIn = (properties, source = body) => { + const found = []; + const re = new RegExp(`\\b(${properties.join("|")})\\s*:([^;}]*)`, "g"); + let m; + while ((m = re.exec(source)) !== null) { + const [, prop, value] = m; + // calc() and env() do their own arithmetic on tokens; the tokens inside them + // are still checked, because the regex below looks at bare numbers only. + (value.match(/(? found.push({ prop, value: v })); + } + return found; +}; + +describe("the spacing scale", () => { + // Hairlines and structural clearances, each one deliberate: + // 0px/1px/3px/4px/22px — borders, hairline offsets, and the 22px of room a seat + // puck's name gives the dealer button beside it. + // 5rem/9rem — how far the page has to scroll past the fixed action + // bar. Not rhythm; clearance for a thing of a known size. + const ALLOWED = new Set(["0px", "1px", "2px", "3px", "4px", "22px", "5rem", "9rem"]); + + test("every gap, margin and padding comes off the scale", () => { + const offenders = valuesIn( + ["padding", "padding-top", "padding-right", "padding-bottom", "padding-left", + "margin", "margin-top", "margin-right", "margin-bottom", "margin-left", + "gap", "row-gap", "column-gap"]) + .filter(v => !ALLOWED.has(v.value)); + + assert.deepEqual(offenders, [], + "use var(--sp-0 … --sp-6), or add the value to ALLOWED with a reason"); + }); + + test("and the scale is the one the tokens define", () => { + ["--sp-0", "--sp-1", "--sp-2", "--sp-3", "--sp-4", "--sp-5", "--sp-6"] + .forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`)); + }); +}); + +describe("the type scale", () => { + // 16px — the size below which iOS zooms the page when a field is focused, so + // every text input states it. Not a typographic choice. + // 1.05em, 0.44rem — relative bumps inside a component, and the shrink-to-fit for + // a six-figure stack on a 58px seat puck. + const ALLOWED = new Set(["16px", "1.05em", "0.44rem"]); + + test("every font-size comes off the scale", () => { + const offenders = valuesIn(["font-size"]).filter(v => !ALLOWED.has(v.value)); + assert.deepEqual(offenders, [], + "use var(--fs-2xs … --fs-2xl), or add the value to ALLOWED with a reason"); + }); + + test("and the scale is the one the tokens define", () => { + ["--fs-2xs", "--fs-xs", "--fs-sm", "--fs-base", "--fs-md", "--fs-lg", "--fs-xl", "--fs-2xl"] + .forEach(token => assert.match(css, new RegExp(`\\${token}:`), `${token} is not defined`)); + }); +}); + +describe("the corner scale", () => { + test("every border-radius comes off it", () => { + const offenders = valuesIn(["border-radius"]).filter(v => v.value !== "2px"); + assert.deepEqual(offenders, [], "use var(--r-sm | --r-md | --r-lg | --r-pill)"); + }); +}); + +describe("the tokens themselves", () => { + test("every var() the stylesheet reaches for exists", () => { + const used = new Set((css.match(/var\((--[a-zA-Z0-9-]+)/g) || []).map(s => s.slice(4))); + // Published from JS at runtime rather than declared here — see actionBar.js. + const fromScript = new Set(["--actionBarHeight", "--feltRoomPx"]); + // Defined by the site's own stylesheet, not this one. + const fromSite = new Set(["--font-mono"]); + const missing = [...used].filter(t => + !fromScript.has(t) && !fromSite.has(t) && !new RegExp(`\\${t}\\s*:`).test(css)); + assert.deepEqual(missing, []); + }); + + test("and the dark surfaces stay off pure black", () => { + // Shadows are invisible on #000, so depth has to come from a lighter surface — + // and white text on true black is the pairing that smears on an OLED phone, + // which is what this tool is used on. + const surfaces = [...css.matchAll(/--surface-\d+:\s*(#[0-9a-fA-F]{3,8})/g)].map(m => m[1].toLowerCase()); + assert.ok(surfaces.length >= 4, "the surface ramp should have four steps"); + surfaces.forEach(hex => { + assert.notEqual(hex, "#000"); + assert.notEqual(hex, "#000000"); + }); + }); +}); + +describe("numbers that change while the dealer watches", () => { + test("are set in tabular figures", () => { + // A "1" is narrower than an "8" in a proportional face, so a pot ticking from + // $18 to $21 shuffles its digits sideways and the eye reads movement where + // there is only arithmetic. + assert.match(css, /font-variant-numeric:\s*tabular-nums/); + ["#potBadgeAmount", ".seatPuckStack", ".actionPot", ".standingsRow"] + .forEach(sel => assert.ok(css.includes(sel), + `${sel} should be in the tabular-figures list`)); + }); +}); + +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 + // one hand before this: 148, 110, 109, 108, 109, 110, 120. + const rule = css.match(/\.actionUndo\s*\{[^}]*\}/); + assert.ok(rule, ".actionUndo rule not found"); + assert.match(rule[0], /width:\s*[0-9.]+rem/); + }); + + test("and its label is the part that ellipsizes", () => { + const rule = css.match(/\.actionUndoLabel\s*\{[^}]*\}/); + assert.ok(rule, ".actionUndoLabel rule not found"); + assert.match(rule[0], /text-overflow:\s*ellipsis/); + }); +});