Files
JoshHeaps.Net/JoshHeaps.Net/wwwroot/css/casino/holdem.css
T
jheapsandClaude Opus 5 f07144acd8 Give the table the screen back when the phone is on its side
A phone held sideways is 640x360: short of height and swimming in width. The layout
spent the whole of that shortage on one column — 70px of header, 85px of felt, 205px
of action bar. Two rows of seat pucks 55px tall have to fit inside 85px of felt, which
they cannot, so seven of nine finished past the rail with up to 17px of puck floating
on the page. That single fault was 96 of the 98 offenders the layout audit could find
anywhere.

No amount of shrinking fixes it, because the shortage was never in the table. The bar
is 205px tall and 529 wide to hold three buttons and a status line, and it was taking
that height from the one element that needed height. So in landscape it moves: the bar
becomes a rail down the right-hand side, the table takes the column beside it, and the
felt goes from 529x85 to 409x205 — an actual oval, with room for two rows of pucks and
a gap between them. It also puts Fold, Call and Raise under the right thumb rather than
along the bottom edge, where a landscape grip has the palm.

Which layout the bar is in is declared by the stylesheet in a custom property and read
back by actionBar.js, because the one thing that must not exist is a second copy of
that media query written in JavaScript.

Then the ring stopped predicting itself. seatRingRadii works out the widest ring whose
outermost puck still finishes inside the rail, and it is right about a puck it measured
before the width search settled and before a name wrapped or was shortened — each worth
up to 15px of height arriving after the answer. Four viewports finished with the top
seat 5-6px over. render.js now checks the drawn result the way the audit judges it: if
the worst puck hangs 6px past the rail, the ring comes in by 6px and they are placed
again. A layout that predicts itself has to be right about the future; one that measures
itself only has to be right about the present.

Also in this round:

- Names shorten at a word instead of being chopped mid-one. "Mike from work" on a
  nine-handed phone was "Mike f…"; he is Mike. A name never ends on a joining word,
  because "Mike from" looks cut off rather than shortened, and the whole name is on the
  tooltip either way.

- The blind badge is counted in what a seat needs. It always points away from the table,
  which was taken to mean it costs nothing between two seats — true of a straight rail
  and false of a ring, where neighbours sit at different heights. Three tables had a
  player's stack painted under the next player's SB or BB, and that badge outranks
  everything else on the felt. It also got shorter, 15px to 12: clearing its own name at
  5px of overhang instead of 9 hands every chair in the ring four pixels back, which at
  640x360 is the difference between a 66px puck and a 48px one.

- Border-box, inherited, for the whole app rather than nine elements opting in one at a
  time. Both elements that hadn't went wrong in the same afternoon: the rail overran the
  page's padding by 25px and put Resolve Pot underneath itself, and the raise pad's
  amount field pushed a horizontal scrollbar across a 640px phone.

- The raise amount field is thumb-sized. The rule that sizes fields for a finger names
  the settings panels and the seat form, so the one field on the one surface built for
  thumbs was 36px on every phone in the list. The sweep never saw it because it had no
  step that opened the raise pad; it has one now, and that step also caught the rail
  squeezing its own status line under the pad.

- mobile-web-app-capable alongside Apple's older name, which Chrome has been calling
  deprecated in this page's console on every load.

The audit goes from 19 failing configurations and 98 offenders to 2 and 3, across 294
audits — thirteen viewports, three table shapes, a mouse and a thumb. What is left is
twelve players on a 360px phone with the raise pad open, where twelve pucks at the 44px
minimum genuinely do not fit on the felt; the search reports that rather than papering
over it. Each new mechanism was checked by breaking it on purpose: removing the settle
pass puts 26 offenders back, dropping the badge from the footprint 5, and un-sizing the
raise field is caught by the step that now opens it.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb
2026-09-04 17:19:11 -06:00

4835 lines
150 KiB
CSS

/* ---------- 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;
}
/* Read aloud, never drawn. `display: none` and `visibility: hidden` both take an
element out of the accessibility tree along with the layout, which would silence the
live region entirely — so it stays in the tree at 1px, clipped. */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* 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%, var(--surface-1) 0%, var(--surface-0) 55%, #0a0a0a 100%);
min-height: 100vh;
margin: 0;
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* ---------- Touch ----------
A dealer runs this one-handed on a phone with players waiting, so a tap has to
register the instant their thumb lands.
`touch-action: manipulation` is the whole game here. Without it a mobile browser
holds every tap for ~300ms to see whether a second one is coming that would mean
"zoom in" — on top of which double-tapping a seat, which this tool uses for fold,
*is* the browser's zoom gesture, so the dealer's shortcut fights the page zooming
under their thumb. `manipulation` keeps panning and pinch-zoom and drops only the
double-tap-to-zoom, which is exactly the one we don't want.
The tap highlight goes with it: every control below gets a real pressed state, and
the browser's translucent grey rectangle on top of a designed one just looks like
a rendering artefact. Text selection goes too — a fast tap-and-hold on a seat
should open the seat menu, not raise a text-selection magnifier over the felt. */
.holdemBtn,
.seat,
.dealerChip,
.cardSlot,
.linkBtn,
.seatMenuBtn,
.seatSheetClose,
.pickerCard,
.actionBtn,
.raiseQuickBtn,
.actionUndo,
.toastAction,
.toastClose,
.potWizardChoice,
.subPanel summary,
button,
[role="button"],
input,
select,
textarea,
a {
touch-action: manipulation;
}
/* The grey rectangle goes from every control, fields included — a tapped input
already shows a caret and a focus ring, which say the same thing more quietly. */
.holdemBtn,
.seat,
.dealerChip,
.cardSlot,
.linkBtn,
.seatMenuBtn,
.seatSheetClose,
.pickerCard,
.actionBtn,
.raiseQuickBtn,
.actionUndo,
.toastAction,
.toastClose,
.potWizardChoice,
.subPanel summary,
button,
[role="button"],
input,
select,
textarea,
a {
-webkit-tap-highlight-color: transparent;
}
/* Text selection is switched off only on the things you press, never on the fields
you type into — a dealer editing a player's name still needs to select the text. */
.holdemBtn,
.seat,
.dealerChip,
.cardSlot,
.linkBtn,
.seatMenuBtn,
.seatSheetClose,
.pickerCard,
.actionBtn,
.raiseQuickBtn,
.actionUndo,
.toastAction,
.toastClose,
.potWizardChoice,
.subPanel summary,
button,
[role="button"] {
-webkit-user-select: none;
user-select: none;
}
/* ---------- Pressed states ----------
Touch has no hover, so the press is the only feedback a dealer gets that the tap
landed — and with the grey highlight suppressed above, these have to carry it.
Everything presses the same way it would on a real table: down and slightly
darker, no travel time. */
.holdemBtn:active:not(:disabled),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.toastAction:active,
.toastClose:active,
.potWizardChoice:active,
.raiseQuickBtn:active:not(:disabled),
.actionUndo:active:not(:disabled) {
transform: translateY(1px);
filter: brightness(1.25);
transition-duration: 0.04s;
}
.cardSlot:active,
.pickerCard:active:not(:disabled),
.dealerChip:active {
transform: translateY(1px) scale(0.97);
transition-duration: 0.04s;
}
/* The action bar already presses down; the brightness is what it was missing, and
it's the one place a dealer taps without looking. */
.actionBtn:active:not(:disabled) {
filter: brightness(1.3);
transition-duration: 0.04s;
}
/* Seats deliberately have no `:active` rule. They already dip via `.seatPressing`,
which `pointerdown` adds in render.js — that fires earlier than `:active` resolves,
and, more importantly, a seat's `transform` is what positions it around the felt
(`translate(-50%, -50%)` plus its own left/top). A press state setting `transform`
would drop that translate and throw every pressed seat about 38px off its spot. */
/* Nobody should get a stuck press state from a tap that turned into a scroll. */
@media (prefers-reduced-motion: reduce) {
.holdemBtn:active:not(:disabled),
.linkBtn:active:not(:disabled),
.seatMenuBtn:active:not(:disabled),
.seatSheetClose:active:not(:disabled),
.cardSlot:active,
.pickerCard:active:not(:disabled),
.dealerChip:active,
.raiseQuickBtn:active:not(:disabled),
.actionUndo:active:not(:disabled) {
transform: none;
}
}
/* ---------- Border-box, everywhere ----------
A width is the width the thing is drawn at. Under the default `content-box` it isn't:
padding and border are added on afterwards, so `width: 192px` on a padded panel draws
217, and every other rule that reserved room for it was 25px short in the same
direction.
That is not hypothetical here. Nine elements in this file already opt in one at a
time — the seat puck's comment explains that it had to, because the search that sizes
it compares the number against distances on the felt — and the two that hadn't both
went wrong in the same afternoon: the action rail overran the page's padding by 25px
and put Resolve Pot underneath itself, and the raise pad's amount field at `100%`
plus 16px of padding pushed a horizontal scrollbar across a 640px phone.
Opting in one element at a time leaves the trap set for the next one. This is the
inherited form, so a new element is border-box unless it says otherwise, and the
`inherit` means anything that does say otherwise still governs its own children. */
*,
*::before,
*::after {
box-sizing: inherit;
}
#holdemApp,
#actionBar,
#toastHost,
[id$="Overlay"] {
box-sizing: border-box;
}
#holdemApp {
max-width: 1400px;
margin: 0 auto;
padding: var(--sp-6);
}
/* ---------- Header ---------- */
#holdemHeader {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: var(--sp-4);
margin-bottom: var(--sp-5);
}
#holdemHeader h1 {
margin: 0;
font-size: var(--fs-2xl);
font-weight: 300;
letter-spacing: 0.04em;
color: var(--text-bright);
position: relative;
display: inline-block;
padding-bottom: var(--sp-2);
}
#holdemHeader h1::after {
content: "";
position: absolute;
left: 0;
bottom: 0;
width: 42px;
height: 3px;
border-radius: 2px;
background: linear-gradient(90deg, var(--gold), var(--gold-deep));
}
/* 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: 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: var(--gold);
font-size: var(--fs-sm);
letter-spacing: 0.04em;
}
#holdemHeaderActions {
display: flex;
gap: var(--sp-2);
flex-wrap: wrap;
}
.holdemBtn {
/* Stated, because this class is worn by both <button> and <a> and the browser
does not give them the same box. Chromium's own stylesheet makes a button
border-box and leaves a link content-box, so `min-height: 46px` on the header
row came out as 46px on the buttons and 64px on the two links — and the row
stretches to its tallest item, so every button became 64px too. A 46px touch
target was quietly costing 64px of a phone screen, twice over, because the
header wraps to two rows. */
box-sizing: border-box;
/* And the touch floor is stated here rather than only under a width breakpoint.
It used to arrive by accident — the content-box links were 64px tall and the
row stretched everything to match — so once the boxes agree, a landscape phone
(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, 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
lagging a step behind the Tab key. Nothing else here changes. */
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease,
box-shadow 0.15s ease, color 0.15s ease, opacity 0.15s ease, filter 0.15s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
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, var(--line), #2a2a2a);
border-color: var(--line-strong);
transform: translateY(-1px);
}
}
.holdemBtnAccent {
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
}
@media (hover: hover) {
.holdemBtnAccent:hover {
background: linear-gradient(180deg, #1a8a44, #0f602c);
}
}
.holdemBtnDanger {
background: linear-gradient(180deg, #4a1f1f, #351515);
border-color: #6b2626;
color: #ffd6d6;
}
@media (hover: hover) {
.holdemBtnDanger:hover {
background: linear-gradient(180deg, #572525, #401818);
}
}
.holdemBtnGhost {
background: transparent;
}
.holdemBtnSmall {
padding: var(--sp-1) var(--sp-2);
font-size: var(--fs-sm);
}
.holdemBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.handHistoryActions {
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-2);
}
/* ---------- Layout ---------- */
#holdemLayout {
display: grid;
grid-template-columns: 1fr 320px;
gap: var(--sp-5);
align-items: start;
}
#tableSection {
min-width: 0;
}
.tableHint {
color: var(--text-muted);
font-size: var(--fs-base);
text-align: center;
margin-top: var(--sp-4);
}
/* `hidden` means hidden.
The attribute only carries `display: none` from the browser's own stylesheet, which
is the weakest rule there is: any class that sets a display — and every button here
sets `inline-flex` — beats it. That is not a hypothetical. Seven overlays in this
file already carry their own `[hidden] { display: none }` because each one was found
the hard way, and the eighth was a button that shipped as a 21x44 empty box under the
bank check on every table that didn't need it.
One rule, for everything, so there is no ninth. */
[hidden] {
display: none !important;
}
/* ---------- Poker table ---------- */
#pokerTableWrap {
position: relative;
/* The shape is stated once, and both the height and the cap below are derived from
it. It used to be a `padding-bottom: 72%` box, which has a trap in it: percentage
padding resolves against the *containing block's* width, not the element's own,
so narrowing the wrap left it exactly as tall as before. Capping the width was
therefore no way to shorten the table — measured, after trying it: the felt went
822px wide to 746px and stayed 617px tall, and the bottom seat sat as far into
the action bar as it had. aspect-ratio ties the two together properly. */
--feltAspect: 0.72;
width: 100%;
aspect-ratio: 1 / var(--feltAspect);
margin: 0 auto;
/* Capping the width is how the felt is told to stay above the action bar.
--feltRoomPx is published by actionBar.js with the bar's real height and the
bottom seat's overhang already taken out; the fallback leaves the table its full
size if that ever fails to arrive. */
max-width: calc(var(--feltRoomPx, 100vh) / var(--feltAspect));
}
#pokerTable {
position: absolute;
inset: 6%;
border-radius: 50%;
background: radial-gradient(ellipse at 50% 42%, #227a45 0%, #175f37 45%, #0e4527 75%, #082c19 100%);
border: 14px solid #6b4225;
box-shadow:
inset 0 0 0 3px rgba(216, 180, 106, 0.55),
inset 0 0 80px rgba(0, 0, 0, 0.55),
0 0 0 4px #2c1a0d,
0 20px 50px rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
}
/* The felt used to carry a "TEXAS HOLD'EM" wordmark across its middle. It came out
from behind the pot badge in two pieces — measured at every seat count from two to
ten, the middle 20% of it was covered, so the felt read "TEXAS LD'EM" — and no
placement fixed that everywhere. Thirty per cent down cleared the badge on a desktop
and landed on the seats of a phone's square felt (43-70% covered); a smaller mark at
20% or 78% was clean on desktop and tablet and no better on a phone; the bottom one
fell off the oval entirely at 360px. A square felt with six to nine seats has no
clear band wide enough for a word.
So it's gone. A watermark that looks broken is worse than no watermark, and the felt
has a gradient, a rim, an inner gold line and a shadow to be a table with — plus a
header saying "Texas Hold'em Dealer" a few inches above it. */
/* The middle of the felt: the board above, the pot below it. One box rather than two
loose ones, because the bet chips have to keep clear of whatever is in the middle and
that is a single question about a single rectangle — see feltCentreBox() in render.js. */
#tableCentre {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
min-width: 0;
/* The real ceiling is set in pixels by sizeTableCentre(), from the room the seat
ring actually leaves. This is the answer before the first measurement and the
backstop if there is nothing to measure. */
max-width: 86%;
overflow: hidden;
}
/* The community cards, in the order they were turned. Sized off the felt rather than
the viewport: the same five cards have to sit on a 248px phone table and an 839px
desktop one, and a fixed width is wrong at one end or the other. */
/* Where the board goes when the middle of the felt cannot hold it — see placeBoard()
in render.js. Its own strip above the table, at the same size, with nothing over it. */
#boardStrip {
display: flex;
justify-content: center;
margin-bottom: var(--sp-2);
}
#boardStrip[hidden] {
display: none;
}
#boardStrip #boardRow {
width: auto;
max-width: 100%;
}
#boardStrip .boardCard {
flex: 0 0 auto;
min-width: 44px;
min-height: 48px;
}
#boardRow {
display: flex;
gap: var(--sp-1);
justify-content: center;
width: 100%;
max-width: 100%;
}
#boardRow[hidden] {
display: none;
}
.boardCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
/* They share whatever the middle of the felt turned out to be, down to a floor a
rank and a suit still fit in. Every one of these is a control that opens the
picker, so the floor is a thumb's worth of card rather than a legible one. */
flex: 1 1 0;
min-width: 24px;
max-width: 44px;
min-height: 44px;
padding: var(--sp-0) var(--sp-1);
border-radius: var(--r-sm);
border: 1px solid var(--line-strong);
background: linear-gradient(180deg, #f4f1e8, #ded9cb);
color: #16181c;
font-family: inherit;
font-size: var(--fs-md);
font-weight: 700;
line-height: 1.05;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45);
}
.boardCard .boardCardSuit {
font-size: var(--fs-sm);
font-weight: 400;
}
/* A playing card is a narrow thing and a control is a 44px thing, and on a felt with
150px of middle to share between five of them the two cannot both be true of the
drawn card. Same answer as the toast's dismiss button and the dealer puck: the hit
area grows past the paint. Five cards in a row means the overlap can only be
sideways, so it reaches out rather than all round, and the gap between two cards
belongs half to each. */
.boardCard {
position: relative;
}
.boardCard::after {
content: "";
position: absolute;
top: -2px;
bottom: -2px;
left: -10px;
right: -10px;
}
/* Except on the strip, where the board has the whole width of the page and can simply
be big enough. */
#boardStrip .boardCard::after {
content: none;
}
.boardCard.cardRed {
color: #b3202b;
}
/* A card not yet turned. Dashed and empty on the felt itself rather than a white
rectangle, so a board half dealt reads as three cards and two gaps at a glance —
which is the same language an empty chair already speaks on this table. */
.boardCard.boardCardEmpty {
background: rgba(0, 0, 0, 0.28);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.32);
color: rgba(255, 255, 255, 0.5);
box-shadow: none;
font-weight: 400;
}
@media (hover: hover) {
.boardCard:hover {
border-color: var(--gold);
}
}
#potBadge {
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: var(--r-pill);
padding: var(--sp-3) var(--sp-5);
text-align: center;
display: flex;
flex-direction: column;
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: var(--fs-xs);
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--gold);
}
#potBadgeAmount {
font-size: var(--fs-xl);
font-weight: 700;
color: #fff;
}
#seatsLayer {
position: absolute;
inset: 0;
}
/* ---------- Bets on the felt ---------- */
/* Shares the seat ring's box exactly, so a bet always lands on the spoke between its
player and the pot. When the seat ring gets inset on a phone, this moves with it —
measured the alternative, and a bets ring on its own geometry ends up either behind
the pucks or on top of the pot badge. */
#betsLayer {
position: absolute;
inset: 0;
pointer-events: none;
}
.betChip {
position: absolute;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
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: var(--r-pill);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
white-space: nowrap;
}
/* Reads as a chip seen from above: a lighter top-left, a rim, and the notches a
casino chip has around its edge. */
.betChipDisc {
width: 13px;
height: 13px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.45);
box-shadow:
inset 0 1px 1px rgba(255, 255, 255, 0.45),
inset 0 -1px 2px rgba(0, 0, 0, 0.45);
}
.betChipAmount {
font-size: var(--fs-2xs);
font-weight: 700;
color: var(--text-bright);
font-variant-numeric: tabular-nums;
}
/* Clinging to a puck rather than sitting on its own ring — the phone layout.
It has to fit the puck it is clinging to, which it did not: measured on a Galaxy S8,
the puck was 48px across and every one of the five chips on the felt was 50, so each
one hung over both edges of its own seat and three of them reached onto the seat next
door. The same fault the small blind badge had, on the other thing that hangs off a
puck — and the one that carries a number rather than two letters.
The disc is what gives way. It is 13px of decoration plus a 4px gap next to a figure
the dealer has to read, and dropping it takes the chip from 50px to 33 — inside the
narrowest puck the layout will ever draw. Its colour moves to the chip's own rim, so
the denomination the legend assigns is still there at no width at all. Same trade the
tight puck already makes with the hole cards. */
.betChipAnchored {
left: 50%;
z-index: 3;
max-width: 100%;
padding: var(--sp-0) var(--sp-1);
border-color: var(--betChipColour, rgba(255, 255, 255, 0.14));
}
.betChipAnchored .betChipDisc {
display: none;
}
/* Where along the puck's edge it lands is set by render.js, which walks the seat's own
spoke out to just past the puck — see anchoredChipOffset(). The `top` here is only what
it wears before that lands, and on the one path where there is nothing to measure. */
.betChipAnchored {
top: -11px;
transform: translate(-50%, -50%);
}
/* ---------- Seats (compact pucks on the table) ---------- */
.seat {
position: absolute;
transform: translate(-50%, -50%);
/* Set by render.js, which measures the widest puck that lets every seat keep its
own box on this particular felt. The 96px is what a table gets before the first
measurement lands, and what it keeps whenever there is room for it.
border-box so the number means the width the puck is drawn at. As content-box it
did not: a 64px puck measured 74 across once its padding and border were counted,
and the search that sets this compares it against distances on the felt. */
box-sizing: border-box;
width: var(--seatPuckW, 96px);
/* Every min-height on a seat is a drawn height now, border and padding included,
because of the box-sizing above. They were content heights before, so each one
here and in the three blocks that override it has gained the 8px of padding and
2px of border it used to be quoted without — the puck is the size it always was.
Caught by the audit rather than by reading: an empty chair went from a 52px tap
target to a 42px one on every phone in the sweep, which is under the floor. */
min-height: 64px;
background: linear-gradient(160deg, var(--surface-3), #1b1b1b);
border: 1px solid rgba(255, 255, 255, 0.08);
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;
text-align: center;
transition: box-shadow 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
/* A long press on a phone otherwise selects the player's name or raises the
system callout menu instead of ours. */
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.seat.dragging {
opacity: 0.4;
}
.seat.dropTarget {
outline: 2px dashed var(--gold);
outline-offset: 2px;
}
/* Four states a dealer needs to tell apart from across the table, without
reading anything: live in this hand, folded, sitting out, and nobody there.
Live gets a green edge, folded gets a red one and a struck-through name,
sitting-out just fades, and an empty chair is the only dashed outline. */
.seat.seatLive {
border-color: rgba(78, 201, 122, 0.45);
box-shadow:
0 0 0 1px rgba(78, 201, 122, 0.18),
0 3px 10px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
/* Sitting out, not gone: still a person the dealer may need to deal back in, so
the name has to stay readable rather than fading almost to the felt. */
/* Out of the hand, still readable.
Both of these used to fade the whole seat — opacity 0.5 folded, 0.58 sitting out —
and opacity on a box fades the text you still have to read along with the box you
wanted to push back. Measured off the rendered pixels at 11.5px text, where AA asks
for 4.5:1:
live name 13.28 stack 4.75
folded name 4.75 stack 2.22
sat out name 7.00 stack 3.22
A folded player's stack is not decoration. It's what they'll have next hand and the
answer to "how much has Ben got?", and at 2.22:1 it was barely there.
So the box recedes and the text doesn't: a flatter, darker background and a muted
border say "not in this hand", while the name and the stack keep colours that
measure. The folded seat keeps its red border and the line through the name, which
is the part that actually names the state. */
.seat.seatInactive,
.seat.seatFolded {
background: linear-gradient(160deg, var(--surface-2), #171717);
border-color: rgba(255, 255, 255, 0.06);
}
/* A folded seat already says so twice — a red border and a line through the name — but
a seat that's sitting out said it only by being faint, and taking the faintness away
took the whole signal with it. A dashed edge is the same thing an empty chair wears,
which is right: neither is in this hand. The name and the stack tell them apart. */
.seat.seatInactive {
border-style: dashed;
border-color: rgba(255, 255, 255, 0.22);
}
.seat.seatInactive .seatPuckName,
.seat.seatFolded .seatPuckName {
color: #b0b0b0;
}
.seat.seatInactive .seatPuckStack,
.seat.seatFolded .seatPuckStack,
.seat.seatInactive .seatPuckTotal,
.seat.seatFolded .seatPuckTotal {
color: #8f8f8f;
}
.seat.seatFolded {
border-color: rgba(224, 123, 123, 0.4);
}
.seat.seatFolded .seatPuckName {
text-decoration: line-through;
text-decoration-color: rgba(224, 123, 123, 0.75);
}
.seat.seatEmpty {
background: rgba(255, 255, 255, 0.02);
border-style: dashed;
border-color: rgba(255, 255, 255, 0.13);
box-shadow: none;
/* Deliberately louder than seatInactive: an open seat is information a
dealer wants, not something to squint at. */
opacity: 0.7;
}
.seatEmptyLabel {
color: var(--text-dim);
font-size: var(--fs-2xs);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.seat.seatSelected {
border-color: rgba(9, 255, 0, 0.6);
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: 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. */
.seatChairNumber {
position: absolute;
top: 2px;
left: 6px;
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 {
/* 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: var(--text-bright);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
/* 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.
Reserving its width means a long name ends in an ellipsis before it reaches the
chip, rather than disappearing underneath one — on the one seat at the table a
dealer looks at most. */
.seat.seatHasButton .seatPuckName {
padding-right: 22px;
}
.seatPuckCards {
font-size: var(--fs-xs);
font-weight: 700;
font-family: var(--font-mono, monospace);
color: var(--text);
min-height: 0.95em;
letter-spacing: 0.05em;
}
.seatPuckCards .cardRed {
color: #ff6b6b;
}
.seatPuckTotal {
display: inline-block;
margin-top: var(--sp-0);
font-size: var(--fs-2xs);
font-weight: 700;
color: #a9f5b8;
background: rgba(9, 255, 0, 0.1);
border-radius: var(--r-pill);
padding: 0 var(--sp-1);
min-height: 1.1em;
line-height: 1.4;
}
.dealerChip {
position: absolute;
/* Inside its own seat, not on the rim.
It used to sit at -10px/-10px, hanging over the seat's corner — which on an oval
of overlapping seats means hanging over the seat *next to it*. Measured on a
nine-seat table: tapping the centre of seat 1 gave the button to seat 9. That
was always slightly true and the enlarged hit area made it much worse; three of
nine seats were losing taps, one of them losing its own middle.
Tucked inside, the overlap is zero at every size. It reads as a puck in the
corner of the seat rather than on its edge, which is a small price for tapping
a player and getting that player. */
top: 3px;
right: 3px;
width: 22px;
height: 22px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #fff, #d8d8d8 55%, #b8b8b8 100%);
color: var(--surface-2);
border: 2px solid var(--text-muted);
font-weight: 700;
font-size: var(--fs-xs);
cursor: pointer;
z-index: 2;
transition: opacity 0.15s ease;
/* A dealer puck should look like a puck — small, sitting on the rim of its seat.
But tapping it hands the button to that seat and re-posts the blinds, and 22px
is half the 44px a fingertip needs. The hit area grows without the chip doing
so: the visual stays 22px and an invisible pad around it catches the tap.
Deliberately not centred on the chip — it's pushed down and left, away from the
neighbouring seat the chip juts towards, so the bigger target can't start
stealing taps meant for the seat next door. */
isolation: isolate;
}
.dealerChip::after {
content: "";
position: absolute;
/* Grows inward, over its own seat, not outward onto the neighbours.
The chip already sits outside its seat by design — top:-10px, right:-10px — so
a pad that also reaches up and right lands on whatever seat is next around the
oval. It did: three of nine seats had taps taken by a neighbour's chip, seat 1
losing 10 of 49 sampled points including its own centre, which meant tapping
the middle of seat 1 gave the button to seat 9.
Down and left is its own seat, which is a large target that can spare the
corner. The two pixels of outward reach are just to cover the chip's border. */
top: -3px;
right: -3px;
bottom: -18px;
left: -18px;
}
/* A poker table has one dealer button. This one drew nine.
Every seat carried a puck — the button's, and eight ghosts at 0.32 opacity that
were there to be pressed. Measured on a nine-seat table: eight pale discs, all
eight showing the letter D, one of them real. Finding the button meant comparing
nine identical shapes for the one that's gold, when it should be the only thing
of its kind on the felt. The ghosts also sat on top of their neighbours: every
remaining "something important is covered" offender on a crowded table was a
seat covered by the seat-next-door's ghost puck.
So a seat that doesn't have the button doesn't draw one. The affordance is still
there — hovering a seat fades its slot in, which is a preview of where the button
would go — and on touch, where there is no hover, "Give the button" has always
been in the seat's long-press menu. Keyboard reaches it by focus.
pointer-events matters as much as the opacity: an invisible puck that still takes
taps would be worse than a visible one. */
.dealerChip:not(.active) {
opacity: 0;
pointer-events: none;
}
@media (hover: hover) {
.seat:hover .dealerChip:not(.active) {
opacity: 0.5;
pointer-events: auto;
}
}
.dealerChip:not(.active):focus-visible {
opacity: 1;
pointer-events: auto;
}
.dealerChip.active {
background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%);
border-color: var(--gold-deep);
color: #3a2e00;
opacity: 1;
}
.dealerChip.hidden {
display: none;
}
/* The dead button. It's on an empty chair on purpose, and that reads as a mistake —
a gold puck on nobody looks exactly like a button that has drifted and wants
dragging back. So it keeps the button's gold, to stay findable as the button, but
goes hollow and dashed: the same shape, visibly holding a place rather than
marking a player. The deal prompt says why in words; this is so the felt agrees
with it at a glance instead of contradicting it. */
.dealerChip.active.dealerChipDead {
background: rgba(245, 213, 71, 0.14);
border-style: dashed;
color: #f0d98c;
}
/* The badge hangs off the puck's edge, and how far is not a taste question — it is
two measurements pulling in opposite directions.
Downward, it has to stay off its own player's name. On a tight puck the name starts
8px in, so a badge 15px tall had to hang 9px off to clear it, and 9px is what the
seat search has to charge every chair in the ring for (see seatFootprint in
render.js). Measured at 640x360, that charge is the difference between a 66px puck
and a 48px one — which is "Christopher, Mike, Alexandra" against "Christo…, Mike…,
Alexan…" on nine chairs.
Upward, it has to stay off the neighbour. The badge is the thing painted on top of
everything else on the felt, so where it reaches past its own puck onto the next
one, what the dealer reads there is somebody else's stack.
So the badge gets shorter instead of hanging further. Tight leading and no vertical
padding take it from 15px to 12, which clears the name at 5px of overhang instead of
9 — and four pixels off what every seat is charged is a whole size step of puck back.
The label is the same 10px bold it always was; what went was the air around it. */
.blindBadge {
display: none;
position: absolute;
top: -5px;
font-size: var(--fs-2xs);
font-weight: 700;
line-height: 1.1;
padding: 0 var(--sp-1);
border-radius: var(--r-pill);
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.4);
}
.blindBadge.visible {
display: inline-block;
}
/* Seats on the bottom of the ring wear their badge underneath, because that's the
edge the pot isn't on. Bets sit between a seat and the pot, so on the bottom arc
the chip comes down onto the seat's top edge — which is exactly where this badge
was, and the chip is a layer above it. See render.js for the measurement. */
.seat.seatBelowCentre .blindBadge {
top: auto;
bottom: -5px;
}
/* And the badge outranks the chips regardless, so a layout this doesn't anticipate
can't hide it again. Which blind a seat is posting is the one thing on that puck
nothing else on the felt repeats — a bet chip's amount is on the seat's own total
line as well. Measured across 12 table sizes: no chip loses its amount to this. */
.blindBadge {
z-index: 4;
}
/* Both badges failed WCAG AA against their own gradients — measured at the worst
stop, the small blind was 3.52:1 and the big blind 2.15:1, in 9px bold text a
dealer is meant to read at a glance across a table.
The small blind keeps its blue and just darkens (4.77:1 at its worst stop). The
big blind flips to dark text on brighter gold (5.84:1), which is how a gold chip
is marked anyway and has the side benefit of making the two badges far easier to
tell apart at speed — light-on-blue against dark-on-gold, rather than two pale
labels differing only in hue. */
/* All three sit in the same place, and inside the puck's own width.
They used to be spread along the top edge — the small blind at -6px, so hanging off
the left corner, the big blind at 22px — and that cost twice over. The overhang put
the badge onto whatever was to the left of the seat: measured on an iPhone Pro Max at
six seats, where the pucks were not touching at all, "SB" was painted across the next
player's $1,137. And two badges in two different places means the dealer's eye has to
find which of two spots this seat is using before it can read which blind this is,
forty times a night. A seat only ever wears one of these — the small and the big
blind are different players by construction, and the straddle chain starts left of
the big blind and stops short of the small one — so there was never anything for the
second position to avoid.
Left rather than centred because the dealer button's chip owns the top-right corner,
and the two are read together: who has the button, and who is posting. */
.sbBadge {
left: 2px;
background: linear-gradient(180deg, #2f6cb5, #22508c);
color: #eaf3ff;
}
.bbBadge {
left: 2px;
background: linear-gradient(180deg, #e6ad4d, #c98a2a);
color: #241a05;
}
/* The straddle. It takes the big blind's slot on the puck, which is free by
construction: the chain starts left of the big blind and stops short of the small
one, so no seat ever wears two of these.
Violet rather than a third shade of gold or blue, because the badge a dealer is
looking for mid-hand is the one telling them where the action comes back to.
Dark text on the light end of the ramp, measured 5.76:1 against the darker stop. */
.strBadge {
left: 2px;
background: linear-gradient(180deg, #cbaaf0, #a97fd6);
color: #1b1030;
}
/* 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 var(--text-bright);
outline-offset: 1px;
}
/* Seats close enough together that a puck this narrow can't carry the full layout. The
class is set from a measurement render.js takes off the ring itself, because no media
query can see it: the same 390px phone is roomy at six seats and crowded at nine, and
a wide landscape felt crowds at its ends while its rim says there is space. Not scoped
to a screen size for the same reason.
It used to carry the width too — 58px, against the roomy layout's 96 — and those two
numbers were the whole vocabulary the felt had. A table that needed 68 got 96 and
overlapped, or got 58 and threw away ten pixels of name it could have kept. The width
is now searched for and set as a custom property; what's left here is the typography,
which does still want to change in one step rather than slide. */
.seatsTight .seat {
/* 38px of content plus 6 of padding and 2 of border, which is what this drew before
the box became a border-box — and 46 clears the 44px a chair has to answer a thumb
with. A seat is a control: tapping it opens that player's form. */
min-height: 46px;
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: 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: var(--fs-2xs);
}
/* The hole cards give up their line here. A puck this close to its neighbours is 64px
tall and the seats at the ends of a landscape felt are 55px apart, so the line has to
come from somewhere — and of the four things on a puck, the cards are the one the
dealer is holding in their other hand. Name, bet and stack stay. The seat editor
still shows the cards in full. */
.seatsTight .seatPuckCards {
display: none;
}
/* ---------- Seat editor panel (sidebar form for the selected seat) ---------- */
.seatEditorPanel {
padding: var(--sp-4);
}
.seatEditorHeader {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.seatEditorHeader h2 {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Grip and Done button are the sheet's furniture — no reason for either on a
desktop sidebar, where the panel is just a panel. */
.seatSheetGrip,
.seatSheetClose {
display: none;
}
.seatEditorPanel h2 {
margin: 0 0 var(--sp-2);
padding-bottom: var(--sp-2);
font-size: var(--fs-md);
font-weight: 500;
color: var(--text-bright);
border-bottom: 1px solid rgba(216, 180, 106, 0.35);
}
.seatEditorForm {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.seatCardToggles {
display: flex;
gap: var(--sp-2);
font-size: var(--fs-base);
color: #aaa;
}
.inHandToggle,
.foldToggle {
display: flex;
align-items: center;
gap: var(--sp-1);
cursor: pointer;
}
.seatNameInput {
flex: 1;
min-width: 0;
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: var(--sp-1);
margin-bottom: var(--sp-2);
}
/* The two hole-card slots: they look like cards face-up on the felt, and tapping
one opens the picker rather than a keyboard. */
.cardSlot {
width: 100%;
min-height: 52px;
text-align: center;
background: #fdfdfd;
color: #111;
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);
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
@media (hover: hover) {
.cardSlot:hover {
transform: translateY(-1px);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4);
}
}
.cardSlot.cardRed {
color: #c0392b;
}
.cardSlot.cardSlotEmpty {
background: var(--surface-1);
color: var(--text-dim);
border-style: dashed;
border-color: #3f3f3f;
box-shadow: none;
font-weight: 400;
}
/* ---------- Add chips ---------- */
.seatBuyIns {
margin-top: -var(--sp-1);
color: var(--text-muted);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
#addChipsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 98;
}
#addChipsOverlay[hidden] {
display: none;
}
#addChipsSheet {
width: 100%;
max-width: 420px;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--line);
border-bottom: none;
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: var(--sp-3);
}
.addChipsHeader h2 {
margin: 0;
font-size: var(--fs-md);
font-weight: 500;
color: var(--text-bright);
}
#addChipsSubtitle {
color: var(--text-muted);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
#addChipsQuick {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.addChipsQuickBtn {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--sp-0);
min-height: 52px;
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: var(--gold);
background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade));
color: var(--gold-bright);
}
.addChipsQuickLabel {
font-size: var(--fs-sm);
font-weight: 600;
}
.addChipsQuickAmount {
font-size: var(--fs-xs);
color: #a8a8a8;
font-variant-numeric: tabular-nums;
}
.addChipsQuickBtn.selected .addChipsQuickAmount {
color: var(--gold);
}
.addChipsEntry {
display: flex;
align-items: center;
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: var(--gold);
}
.addChipsCurrency {
color: var(--text-dim);
font-size: var(--fs-xl);
}
#addChipsInput {
flex: 1;
min-width: 0;
background: none;
border: none;
outline: none;
color: var(--text-bright);
font-family: inherit;
font-size: var(--fs-xl);
font-weight: 500;
font-variant-numeric: tabular-nums;
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: var(--sp-2);
}
.addChipsActions .holdemBtn {
width: 100%;
justify-content: center;
min-height: 48px;
}
/* ---------- Rulings reference ---------- */
.tableHintActions {
margin: var(--sp-1) 0 0;
text-align: center;
}
/* A bare checkbox ships at 13x13, which misses the 24px minimum with a mouse as well
as with a thumb. The row around it is the real target on touch, but the box itself
should be findable on any device. */
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
.linkBtn {
background: none;
border: none;
/* An underlined word was a 91x14 target, which misses the 24px minimum with a
mouse as well as with a thumb. The coarse-pointer block takes it to 44; this is
the floor everywhere else, and it changes nothing about how it looks. */
display: inline-flex;
align-items: center;
box-sizing: border-box;
min-height: 24px;
padding: 0;
color: var(--gold);
font-family: inherit;
font-size: var(--fs-sm);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
@media (hover: hover) {
.linkBtn:hover {
color: var(--gold);
}
}
#rulingsOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 97;
}
#rulingsOverlay[hidden] {
display: none;
}
#rulingsSheet {
width: 100%;
max-width: 460px;
max-height: 86vh;
overflow-y: auto;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--line);
border-bottom: none;
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: var(--sp-2);
margin-bottom: var(--sp-2);
}
.rulingsHeader h2 {
flex: 1;
margin: 0;
font-size: var(--fs-lg);
font-weight: 500;
color: var(--text-bright);
}
.rulingsIntro {
margin: 0 0 var(--sp-4);
color: var(--text-muted);
font-size: var(--fs-base);
line-height: 1.5;
}
/* Amber, like the bank check's disagreement — both are "your record of the night is
wrong and you can still do something about it". */
.storageWarning {
margin: 0;
padding: var(--sp-2) var(--sp-2);
border: 1px solid rgba(240, 180, 106, 0.45);
border-radius: var(--r-sm);
background: rgba(240, 180, 106, 0.08);
color: #f0b46a;
font-size: var(--fs-sm);
line-height: 1.4;
}
/* Each stack and the chips that make it, laid out like the shortcut legend below —
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: var(--sp-1) 0 0;
}
.colourUpWho {
color: var(--text);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.colourUpChips {
margin: 0 0 var(--sp-2);
color: #9fb8a6;
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.colourUpChips:last-child {
margin-bottom: 0;
}
/* The shortcut legend. A touch device has no keys to press, so it doesn't get a panel
telling it about them — the same pointer question the touch-target rules ask. */
@media (pointer: coarse) {
#keyboardPanel {
display: none;
}
}
.shortcutLegend {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--sp-2) var(--sp-2);
margin: 0;
align-items: baseline;
}
.shortcutLegend dt {
display: flex;
gap: var(--sp-1);
}
.shortcutLegend dd {
margin: 0;
color: #b8b8b8;
font-size: var(--fs-sm);
line-height: 1.35;
}
.shortcutKey {
background: var(--surface-2);
border: 1px solid #3f3f3f;
border-bottom-width: 2px;
border-radius: var(--r-sm);
padding: var(--sp-0) var(--sp-1);
font-family: inherit;
font-size: var(--fs-xs);
color: var(--gold);
white-space: nowrap;
}
/* One chip per ruling, wrapped across the top of the sheet. Names rather than icons:
a dealer scanning this is looking for the words somebody at the table just said. */
.rulingsIndex {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
margin: 0 0 var(--sp-4);
}
.rulingsIndexBtn {
box-sizing: border-box;
min-height: 32px;
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: var(--fs-sm);
line-height: 1.25;
text-align: left;
cursor: pointer;
}
@media (hover: hover) {
.rulingsIndexBtn:hover {
background: var(--surface-3);
border-color: var(--line-strong);
}
}
/* A thumb needs the same 44px here as everywhere else. */
@media (pointer: coarse) {
.rulingsIndexBtn {
min-height: 44px;
font-size: var(--fs-sm);
}
}
/* The heading the index jumps to takes focus, and a focus ring on a heading that the
dealer just asked for reads as noise rather than as help. */
.ruling h3:focus {
outline: none;
}
.ruling {
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 var(--sp-1);
color: var(--gold);
font-size: var(--fs-base);
font-weight: 600;
letter-spacing: 0.03em;
}
.ruling p,
.ruling li {
color: #c4c4c4;
font-size: var(--fs-base);
line-height: 1.5;
}
.ruling p {
margin: 0;
}
.ruling ul {
margin: 0;
padding-left: var(--sp-4);
}
.ruling li + li {
margin-top: var(--sp-1);
}
.rulingsSource {
margin: 0;
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;
}
/* ---------- Seat long-press menu ---------- */
/* Held down, before the menu opens — the only feedback that a press is being
counted rather than ignored. */
.seat.seatPressing {
transform: translate(-50%, -50%) scale(0.94);
}
#seatMenuOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 96;
}
#seatMenuOverlay[hidden] {
display: none;
}
#seatMenuSheet {
width: 100%;
max-width: 420px;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--line);
border-bottom: none;
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
bottom of the screen with no way to reach them. A dealer in landscape could
long-press a seat and then not fold it. */
max-height: 85vh;
overflow-y: auto;
overscroll-behavior: contain;
}
.seatMenuHeader {
margin-bottom: var(--sp-3);
}
.seatMenuHeader h2 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 500;
color: var(--text-bright);
}
#seatMenuSubtitle {
color: var(--text-muted);
font-size: var(--fs-base);
font-variant-numeric: tabular-nums;
}
#seatMenuActions {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.seatMenuBtn {
width: 100%;
min-height: 52px;
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: var(--fs-md);
cursor: pointer;
transition: all 0.12s ease;
}
@media (hover: hover) {
.seatMenuBtn:hover {
border-color: var(--line-strong);
background: linear-gradient(180deg, var(--line), #2a2a2a);
}
}
.seatMenuBtn.menuDanger {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.seatMenuBtn.menuAccent {
background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade));
border-color: var(--gold-deep);
color: var(--gold-bright);
}
#seatMenuCancelBtn {
width: 100%;
justify-content: center;
min-height: 48px;
}
/* ---------- Card picker ---------- */
#cardPickerOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: flex-end;
justify-content: center;
z-index: 95;
}
#cardPickerOverlay[hidden] {
display: none;
}
#cardPickerSheet {
width: 100%;
max-width: 460px;
max-height: 88vh;
overflow-y: auto;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--line);
border-bottom: none;
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: var(--sp-2);
margin-bottom: var(--sp-2);
}
.cardPickerHeader h2 {
flex: 1;
min-width: 0;
margin: 0;
font-size: var(--fs-md);
font-weight: 500;
color: var(--text-bright);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardPickerHeader .holdemBtn {
min-height: 40px;
}
#cardPickerSlots {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-3);
}
.pickerSlot {
flex: 1;
min-height: 46px;
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: var(--fs-lg);
font-weight: 700;
cursor: pointer;
}
/* The slot the next tap will fill. */
.pickerSlot.active {
border-color: var(--gold);
background: var(--gold-shade);
color: var(--gold-bright);
}
.pickerSlot.cardRed {
color: #e07b7b;
}
#cardPickerGrid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-1);
}
.pickerCard {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-0);
min-height: 44px;
border-radius: var(--r-sm);
border: 1px solid #d5d5d5;
background: #fdfdfd;
color: #111;
font-family: var(--font-mono, monospace);
font-size: var(--fs-md);
font-weight: 700;
cursor: pointer;
transition: transform 0.1s ease;
}
@media (hover: hover) {
.pickerCard:hover:not(:disabled) {
transform: translateY(-1px);
}
}
.pickerCard.cardRed {
color: #c0392b;
}
.pickerCard.selected {
outline: 3px solid var(--gold);
outline-offset: -1px;
}
/* Already dealt somewhere else at the table — visibly out of play rather than
silently accepting a duplicate. */
.pickerCard:disabled {
background: #1d1d1d;
border-color: #303030;
color: var(--line-strong);
cursor: not-allowed;
}
.pickerSuit {
font-size: 1.05em;
line-height: 1;
}
.seatContribution {
border-top: 1px dashed var(--line);
padding-top: var(--sp-2);
margin-bottom: var(--sp-1);
}
.chipCounts {
display: flex;
flex-wrap: wrap;
gap: var(--sp-1);
margin-bottom: var(--sp-1);
}
.chipCountRow {
display: flex;
align-items: center;
gap: var(--sp-1);
}
.chipSwatch {
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.3);
display: inline-block;
}
.chipCountInput {
width: 34px;
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: var(--fs-sm);
color: #aaa;
margin-bottom: var(--sp-1);
}
.amountOverrideInput {
width: 70px;
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: var(--fs-base);
color: #d6ffe0;
font-weight: 600;
text-align: right;
}
.customFields {
display: flex;
flex-direction: column;
gap: var(--sp-1);
margin-bottom: var(--sp-1);
}
.customFieldRow {
display: flex;
gap: var(--sp-1);
}
.customFieldLabel,
.customFieldValue {
min-width: 0;
flex: 1;
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,
.removeChipColorBtn {
line-height: 1;
}
/* ---------- Sidebar ---------- */
#holdemSidebar {
display: flex;
flex-direction: column;
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, 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);
}
.sidebarPanel summary {
cursor: pointer;
/* These headers are the sidebar's whole navigation and were 38px tall on every
device, phones included — the touch pass further down never reached them.
Padding rather than a flex box with min-height, because a <summary> only draws
its disclosure triangle while it is display: list-item. */
padding: var(--sp-3) 0;
/* A <summary> 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: var(--fs-md);
color: var(--text);
letter-spacing: 0.03em;
}
.panelBody {
padding-bottom: var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
/* Deliberately not animated. The obvious move here is the grid 0fr -> 1fr trick, and
it does not work on a <details>: 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: 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: -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 var(--surface-3);
padding-top: var(--sp-2);
}
.subPanel summary {
cursor: pointer;
color: var(--text-muted);
font-size: var(--fs-sm);
}
@media (hover: hover) {
.subPanel summary:hover {
color: #c5c5c5;
}
}
.subPanelBody {
display: flex;
flex-direction: column;
gap: var(--sp-2);
padding-top: var(--sp-2);
}
.fieldRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
font-size: var(--fs-base);
color: #ccc;
}
.fieldRow input,
.fieldRow select {
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: var(--fs-sm);
color: var(--text-muted);
line-height: 1.4;
margin: 0;
}
#chipLegendList {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.chipLegendRow {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.chipColorInput {
width: 32px;
height: 28px;
background: none;
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 0;
}
.chipValueInput {
flex: 1;
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 {
max-height: 260px;
overflow-y: auto;
}
/* The sidebar is about 290px wide, and four numeric columns at a 4.5rem minimum
plus gaps come to 320px — so the name column, being the only flexible one, was
collapsing to *zero* and player names disappeared entirely. Names were already
down to 50px with four columns; adding the cash-out column finished them off.
Measured at 360, 390 and 1280 alike, since the sidebar is narrow at all of them.
So the table earns its columns instead of assuming them. Narrow: the player and
the two numbers a banker settles up from. Wide enough: the per-hand detail as
well. Everything is in the CSV export either way. */
.standingsTableWrap {
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: var(--sp-2);
align-items: baseline;
padding: var(--sp-2) var(--sp-0);
font-size: var(--fs-sm);
}
.standingsRow > span:not(:first-child) {
text-align: right;
font-variant-numeric: tabular-nums;
min-width: 3.6rem;
}
.standingsRow > .standingsName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The per-hand columns are detail: how the night's swings happened. They're the first
to go when there isn't room, and Net goes with them — it only moves when a pot is
resolved through the wizard, so on a table where the dealer settles pots by hand it
reads $0 all night beside two columns that don't.
What survives at every width is the pair the banker actually works from: what the
player is holding, and whether that leaves them up or down. */
.standingsIn,
.standingsWon,
.standingsNet,
.standingsInHead,
.standingsWonHead,
.standingsNetHead {
display: none;
}
@container (min-width: 30rem) {
.standingsRow {
grid-template-columns: minmax(5rem, 1fr) auto auto auto auto auto;
}
.standingsIn,
.standingsWon,
.standingsNet,
.standingsInHead,
.standingsWonHead,
.standingsNetHead {
display: block;
}
}
/* The chips column is the one a banker's hands follow while counting money onto the
table, so it carries the weight. Up/down is read once, at the end. */
.standingsChips {
font-weight: 600;
color: var(--text);
}
.standingsCashOut {
font-weight: 600;
}
/* ---------- Bank reconciliation ----------
Cash taken versus chips on the table. Every chip is a promise to pay cash for it
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: var(--sp-2);
padding: var(--sp-2) var(--sp-2);
border: 1px solid #2f2f2f;
border-radius: var(--r-md);
background: #151515;
}
.bankReconcileRow {
display: flex;
justify-content: space-between;
gap: var(--sp-3);
font-size: var(--fs-sm);
color: #b8b8b8;
font-variant-numeric: tabular-nums;
padding: var(--sp-0) 0;
}
.bankReconcileVerdict {
margin: var(--sp-2) 0 0;
font-size: var(--fs-sm);
line-height: 1.35;
}
.bankReconcileVerdict.bankBalanced {
color: #7fc99a;
}
.bankReconcileVerdict.bankOff {
color: #f0b46a;
}
/* The settle-up list is read out loud, one line per payment, so it's laid out like
the bank rows above it: name on the left, money hard right on tabular figures so a
column of amounts lines up on the decimal point. */
.settleUpList {
list-style: none;
margin: var(--sp-1) 0 0;
padding: 0;
}
.settleUpRow {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: var(--sp-3);
font-size: var(--fs-base);
padding: var(--sp-0) 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.settleUpRow:last-child {
border-bottom: 0;
}
.settleUpWho {
color: var(--text);
}
.settleUpAmount {
color: #f2d489;
font-weight: 600;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Same amber the bank check uses when the columns don't agree — it's the same fault
being reported twice, and it should look like it. */
.fieldHint.settleUpOff {
color: #f0b46a;
}
.standingsHeader {
color: var(--text-dim);
font-size: var(--fs-xs);
letter-spacing: 0.04em;
text-transform: uppercase;
/* 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: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.standingsIn,
.standingsWon {
color: var(--text-muted);
}
.standingsNet {
color: #bbb;
font-weight: 600;
}
.standingsNet.netUp {
color: #4ec97a;
}
.standingsNet.netDown {
color: #e07b7b;
}
/* The showdown call, sat above the resolve button. Loud enough to be read across a
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 var(--sp-2);
text-align: center;
font-size: var(--fs-base);
color: #f0d98c;
}
.handHistoryFilterRow {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.handHistoryFilter {
flex: 1 1 auto;
min-width: 0;
/* 16px or iOS zooms the whole page on focus, which on a phone at the table throws
the felt off screen mid-hand. Every other field in this panel is held at 16px
for the same reason. */
font-size: 16px;
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: var(--text-dim);
}
.handHistoryFilter:focus-visible {
outline: 2px solid var(--gold);
outline-offset: 1px;
}
.handHistoryCount {
flex: 0 0 auto;
font-size: var(--fs-sm);
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
#handHistoryList {
display: flex;
flex-direction: column;
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
on a tall one and pushing everything below it out of reach. */
max-height: clamp(200px, 42vh, 560px);
overflow-y: auto;
}
.handHistoryRow {
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: var(--sp-2);
font-size: var(--fs-base);
}
.handHistoryHandNumber {
color: var(--gold);
font-weight: 600;
}
.handHistoryPot {
color: var(--text);
}
.handHistoryTime {
margin-left: auto;
color: var(--text-dim);
font-size: var(--fs-sm);
}
.handHistoryPayouts {
margin-top: var(--sp-0);
color: #aaa;
font-size: var(--fs-sm);
line-height: 1.4;
}
#tableNotes {
width: 100%;
box-sizing: border-box;
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
is then pinching back out to see the table. Every other input here is already
at 16px; this one was the straggler. */
font-size: 16px;
}
/* ---------- Pot wizard ---------- */
/* ---------- Action bar ---------- */
#actionBar {
/* Which of the two shapes this bar is in, declared for actionBar.js to read back —
see isRail(). The layout is chosen by a media query, and the one thing that must
not happen is the same breakpoint written out a second time in JavaScript. */
--actionBarLayout: bar;
position: fixed;
/* Centred with auto margins rather than 100vw + translate: on a phone with a
scrollbar, 100vw is wider than the viewport and the overflow drags the whole
table sideways. */
left: 0;
right: 0;
bottom: 0;
margin: 0 auto;
z-index: 55;
max-width: 720px;
background: linear-gradient(180deg, var(--surface-2), var(--surface-1));
border: 1px solid var(--line);
border-bottom: none;
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);
}
#actionBar[hidden] {
display: none;
}
/* The bar is fixed over the page, so the sidebar needs room to scroll past it. */
body.hasActionBar #holdemSidebar {
padding-bottom: 9rem;
}
.actionStatus {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--sp-2) var(--sp-2);
margin-bottom: var(--sp-2);
font-size: var(--fs-sm);
}
/* 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: var(--sp-0) var(--sp-2);
border-radius: var(--r-pill);
border: 1px solid #414141;
background: #262626;
color: #bdbdbd;
font-family: inherit;
font-size: var(--fs-sm);
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (hover: hover) {
.actionUndo:hover {
background: var(--surface-3);
border-color: #4f4f4f;
color: var(--text);
}
}
.actionStreet {
color: var(--gold);
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
font-size: var(--fs-xs);
}
.actionPot {
color: #e9e9e9;
font-variant-numeric: tabular-nums;
}
.actionPlayers {
color: #9c9c9c;
font-variant-numeric: tabular-nums;
}
.actionRunout {
margin-left: auto;
color: var(--gold);
font-size: var(--fs-sm);
}
.actionWho {
margin-left: auto;
color: #9c9c9c;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Stopwatch, not a shot clock: it colours up as a prompt to the dealer, and does
nothing else. */
.actionTank {
flex: none;
min-width: 3.1rem;
text-align: right;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
transition: color 0.3s ease;
}
.actionTank.tankNudge {
color: var(--gold);
}
.actionTank.tankSlow {
color: #e07b7b;
font-weight: 600;
}
/* Starting and stopping a clock. Quiet, because it only appears once somebody has
been thinking long enough for the table to ask.
44px like everything else a thumb has to find. It was drawn at 36 to keep the status
line short, and the audit called it at every touch viewport the moment it ran — which
is the rule doing its job: this control kills a hand, and the argument for shaving it
was that the row it sits in is made of text. */
.actionClockBtn {
flex: none;
min-height: 44px;
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: var(--fs-sm);
cursor: pointer;
}
/* The count has run out. Said in the dealer's own words, above the button that
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 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: var(--fs-base);
}
.actionBtn.actionEnforceClock {
background: linear-gradient(180deg, #6b2b2b, #4a1d1d);
border-color: #8c3a3a;
color: #ffe2e2;
font-weight: 600;
}
/* A player who has taken their chips and gone. Still on the list, because the box took
their cash and the night's record has to say so, but set apart so nobody counts money
out to them twice at 2am.
Set apart by a stripe rather than by fading the row, which is what this first did:
at 0.72 the contrast check measured the In and Won cells at 3.83:1, under AA, on the
list a dealer settles the night from. The row says "paid out" in words next to the
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 var(--line-strong);
padding-left: var(--sp-1);
}
.standingsRow.standingsGone .standingsName {
font-style: italic;
}
/* The people standing behind the chairs. A short list in a panel that already has a
lot in it, so it is built from rows rather than a table: a name, the seat it can go
in, and a way off the list. */
.waitingAdd {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.waitingAdd input {
flex: 1;
min-width: 0;
min-height: 44px;
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: var(--sp-1) var(--sp-2);
}
.waitingAdd .holdemBtn {
flex: none;
min-height: 44px;
/* "Add" is three letters, which came out 43px wide — one pixel under the rule, and
the audit called it. A thumb doesn't know the word is short. */
min-width: 56px;
}
.waitingList {
list-style: none;
counter-reset: waiting;
margin: 0 0 var(--sp-2);
padding: 0;
display: flex;
flex-direction: column;
gap: var(--sp-1);
}
.waitingRow {
display: flex;
align-items: center;
gap: var(--sp-2);
counter-increment: waiting;
}
/* The position in the queue, which is the whole point of the list. */
.waitingRow::before {
content: counter(waiting) ".";
flex: none;
min-width: 1.2rem;
color: var(--text-muted);
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;
}
.waitingName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: var(--fs-base);
}
.waitingNote {
flex: none;
color: var(--text-muted);
font-size: var(--fs-sm);
}
.waitingRow .holdemBtn {
flex: none;
min-height: 44px;
}
.waitingDropBtn {
min-width: 44px;
}
/* ---------- Side-pot breakdown ---------- */
.potBreakdown {
display: flex;
flex-direction: column;
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: var(--r-md);
}
.potBreakdownHeading {
margin: 0 0 var(--sp-1);
color: var(--gold);
font-size: var(--fs-xs);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.potLayer {
display: flex;
align-items: baseline;
gap: var(--sp-2);
font-size: var(--fs-sm);
}
.potLayerLabel {
color: var(--gold);
font-weight: 600;
flex: none;
}
.potLayerAmount {
color: var(--text-bright);
font-variant-numeric: tabular-nums;
font-weight: 600;
flex: none;
}
.potLayerWho {
color: var(--text-muted);
flex: 1;
min-width: 0;
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.potBreakdownWizard {
margin-bottom: var(--sp-4);
}
.potLayerReturn .potLayerLabel,
.potLayerReturn .potLayerAmount {
color: var(--text-muted);
font-weight: 500;
}
.actionButtons {
display: flex;
gap: var(--sp-2);
}
.actionBtn {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-0);
min-height: 56px;
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: var(--fs-md);
cursor: pointer;
transition: all 0.12s ease;
}
@media (hover: hover) {
.actionBtn:hover {
border-color: var(--line-strong);
background: linear-gradient(180deg, var(--line), #2a2a2a);
}
}
.actionBtn:active {
transform: translateY(1px);
}
.actionBtnLabel {
font-weight: 600;
}
.actionBtnSub {
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. */
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: var(--fs-2xs);
line-height: 1.4;
text-transform: uppercase;
pointer-events: none;
}
/* A key hint is small — 9.6px — so AA asks 4.5:1 of it, and at the opacity these used
to carry they measured 2.97 to 3.33 against their buttons. That's the same fault as
the folded seats above: a fade chosen for looks pushing text a dealer is meant to
read under the threshold. They're still quieter than the label they sit beside. */
.actionKey {
position: absolute;
top: 5px;
right: 7px;
padding: 0 var(--sp-1);
border-radius: var(--r-sm);
border: 1px solid currentColor;
opacity: 0.75;
font-family: inherit;
font-size: var(--fs-2xs);
line-height: 1.4;
text-transform: uppercase;
pointer-events: none;
}
/* No keyboard, no key badges — on a phone they'd just be clutter on the buttons
the dealer is already tapping. */
@media (hover: none) {
.actionKey {
display: none;
}
}
.actionFold {
background: linear-gradient(180deg, #3d1f1f, #2c1515);
border-color: #5b2626;
color: #ffd6d6;
}
.actionCheck,
.actionCall {
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
}
.actionCheck .actionBtnSub,
.actionCall .actionBtnSub {
color: #bfe8cb;
}
.actionRaise {
background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade));
border-color: var(--gold-deep);
color: var(--gold-bright);
}
.actionAdvance {
background: linear-gradient(180deg, #2b3f57, #1b2a3a);
border-color: #3d5f83;
color: #dceaf8;
}
/* ---------- Raise pad ---------- */
.raiseQuick {
display: flex;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
.raiseQuickBtn {
min-height: 48px;
font-size: var(--fs-base);
}
.raiseQuickBtn.selected {
border-color: var(--gold);
background: linear-gradient(180deg, var(--gold-tint), var(--gold-shade));
color: var(--gold-bright);
}
.raiseEntry {
display: flex;
gap: var(--sp-2);
}
.raiseInput {
flex: 1;
min-width: 0;
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: var(--sp-2);
}
.raiseInput:focus {
outline: none;
border-color: var(--gold);
}
.raiseCancel {
flex: 0 0 auto;
min-width: 84px;
}
.raiseConfirm {
flex: 0 0 auto;
min-width: 116px;
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.
Dealers say the biggest single time sink at a home table isn't how fast the cards
come out — it's players not noticing the action is on them. So this is the one
state that has to be unmistakable from across a table at a glance, and it was
distinguished only by a rim colour: a gold border and a 2px ring where a live seat
has a green border and a 1px one. Same fill, same size, nothing moving.
It now reads as lit rather than merely outlined: a solid ring at full strength, a
warm glow that separates it from the felt, and a lift of its own. The seat's
`transform` positions it around the table, so the lift is box-shadow rather than
translate — setting transform here would throw the seat off its spot. */
.seat.seatActing {
border-color: #f0cd85;
background: linear-gradient(160deg, #3a3222, #241f16);
box-shadow:
0 0 0 3px rgba(216, 180, 106, 0.9),
0 0 18px 2px rgba(216, 180, 106, 0.35),
0 6px 20px rgba(0, 0, 0, 0.6);
z-index: 4;
}
/* A slow breath, so a dealer glancing up finds it without hunting. Deliberately
gentle and deliberately optional — the ring above carries the whole job on its own
for anyone who'd rather nothing moved. */
@media (prefers-reduced-motion: no-preference) {
.seat.seatActing {
animation: seatActingPulse 2.4s ease-in-out infinite;
}
@keyframes seatActingPulse {
0%, 100% {
box-shadow:
0 0 0 3px rgba(216, 180, 106, 0.9),
0 0 18px 2px rgba(216, 180, 106, 0.35),
0 6px 20px rgba(0, 0, 0, 0.6);
}
50% {
box-shadow:
0 0 0 3px rgba(216, 180, 106, 0.65),
0 0 26px 5px rgba(216, 180, 106, 0.5),
0 6px 20px rgba(0, 0, 0, 0.6);
}
}
}
.seatPuckStack {
font-size: var(--fs-2xs);
color: var(--text-muted);
font-variant-numeric: tabular-nums;
margin-top: var(--sp-0);
}
/* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider
letters and a warm colour separate it from the grey money above it at a glance —
which is the whole job, because the dealer is scanning the ring for who can still
be bet at. */
.seatPuckStack.seatPuckAllIn {
color: #ffcf6b;
font-weight: 700;
letter-spacing: 0.06em;
}
/* And the seat itself carries a hint of it, so the mark survives being read out of
the corner of an eye. Deliberately quieter than .seatActing — all-in is a standing
fact about a player, not a request for the dealer's attention. */
.seat.seatAllIn {
border-color: rgba(255, 207, 107, 0.45);
}
/* Tournament-sized amounts. A number has no spaces to break on, so an amount too
wide for the 64px puck neither wraps nor ellipsises — it draws straight out over
the seat next to it. These step the size down so every digit stays on its own
puck. Shrinking rather than abbreviating is deliberate: "$1.25M" would fit more
comfortably, but a dealer reading a rounded stack off the felt is a worse failure
than a small one, and the exact figure is what the tool is for. */
.seatPuckStack.puckAmountLong,
.seatPuckTotal.puckAmountLong {
font-size: var(--fs-2xs);
letter-spacing: -0.01em;
}
.seatPuckStack.puckAmountHuge,
.seatPuckTotal.puckAmountHuge {
font-size: 0.44rem;
letter-spacing: -0.02em;
}
.blindsRow {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2);
}
.blindsRow .fieldRow {
flex: 1 1 120px;
min-width: 0;
gap: var(--sp-2);
}
/* "Small blind" broke onto two lines beside its input, which made the pair look
like a mistake rather than a pair. */
.blindsRow .fieldRow > span {
white-space: nowrap;
}
.blindsRow input {
min-width: 0;
width: 3.4rem;
}
/* ---------- Toasts ---------- */
#toastHost {
position: fixed;
left: 0;
right: 0;
bottom: var(--sp-5);
margin: 0 auto;
z-index: 80;
max-width: 560px;
padding: 0 var(--sp-3);
box-sizing: border-box;
pointer-events: none;
transition: bottom 0.18s ease;
}
/* Sit above the action bar rather than on top of it, so an Undo never covers the
Fold button the dealer is reaching for. */
/* Sit the toast directly on top of the action bar, whatever height it happens to be.
This was a hardcoded 9rem, which is roughly right for the bar at rest and wrong the
moment the raise pad opens — and on a short phone it put the toast, Undo button and
all, straight over the bottom seats of the table, so a tap meant for a player hit
"Undo" instead. */
body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 9rem) + 0.75rem + env(safe-area-inset-bottom, 0px));
}
.toast {
display: flex;
align-items: center;
gap: var(--sp-3);
background: linear-gradient(180deg, var(--surface-3), #202020);
border: 1px solid #414141;
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;
transform: translateY(12px);
transition: opacity 0.18s ease, transform 0.18s ease;
}
.toast.toastVisible {
opacity: 1;
transform: translateY(0);
}
.toastText {
flex: 1;
min-width: 0;
color: #e9e9e9;
font-size: var(--fs-base);
line-height: 1.35;
}
.toastAction {
flex: none;
min-height: 38px;
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: var(--gold-bright);
font-size: var(--fs-base);
font-weight: 600;
font-family: inherit;
cursor: pointer;
}
@media (hover: hover) {
.toastAction:hover {
background: rgba(216, 180, 106, 0.22);
}
}
.toastClose {
flex: none;
width: 30px;
height: 30px;
border-radius: 50%;
border: none;
background: transparent;
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
swatting away mid-hand, given a fingertip's worth of hit area without becoming
a bigger piece of furniture. */
position: relative;
}
@media (hover: hover) {
.toastClose:hover {
background: var(--surface-3);
color: var(--text);
}
}
/* ---------- Confirmation dialog ---------- */
#confirmOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 90;
padding: var(--sp-5);
}
#confirmOverlay[hidden] {
display: none;
}
#confirmModal {
background: var(--surface-1);
border: 1px solid var(--line);
border-radius: var(--r-md);
width: 100%;
max-width: 420px;
padding: var(--sp-5) var(--sp-5);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6);
outline: none;
}
#confirmTitle {
margin: 0 0 var(--sp-2);
font-size: var(--fs-lg);
font-weight: 500;
color: var(--text-bright);
}
#confirmMessage {
margin: 0 0 var(--sp-5);
color: #a5a5a5;
font-size: var(--fs-base);
line-height: 1.5;
}
.confirmActions {
display: flex;
justify-content: flex-end;
gap: var(--sp-2);
}
.confirmActions .holdemBtn {
min-height: 44px;
}
#potWizardOverlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
/* Every other sheet in here sits at 90-98; this one was at 50, underneath both
the action bar (55) and the toast (80). So the bar drew on top of the dialog
that pays people — in landscape one of its inputs was completely covered by
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: var(--sp-5);
}
#potWizardOverlay[hidden] {
display: none;
}
#potWizardModal {
background: var(--surface-1);
border: 1px solid var(--line);
border-radius: var(--r-md);
/* So the fade over the bottom of the list has something to hang off. */
position: relative;
width: 100%;
max-width: 480px;
/* 85vh is most of a 390px-tall landscape window, and the action bar is fixed over
the bottom of it — so the last of the wizard sat underneath the bar and its
controls couldn't be tapped. Leaving the bar's own height out of the budget
keeps the whole dialog above it.
That was true while this overlay sat at z-index 50, below the bar's 55. It sits
at 95 now and the bar cannot draw on it, so the subtraction buys nothing and
costs 110px of a 640px screen — which on a Galaxy S8 is the difference between
a three-way pot showing all three names and showing two of them. */
max-height: 85vh;
/* A column of three: the title, the part that scrolls, and the buttons. The modal
itself no longer scrolls — only its middle does — which is what keeps Confirm on
screen without asking a sticky footer to do something it cannot. */
display: flex;
flex-direction: column;
overflow: hidden;
padding: var(--sp-5) var(--sp-5) var(--sp-5);
}
#potWizardBody {
overflow-y: auto;
overscroll-behavior: contain;
/* Without this a flex child refuses to be shorter than its content, and the middle
would push the buttons back off the bottom of the dialog. */
min-height: 0;
/* Room for a focus ring on the first and last rows, which a flush edge clips. */
padding: 2px;
margin: -2px;
scroll-padding: var(--sp-4);
}
/* The footer is itself the row of buttons, so a step that appends a single button gets
the same full-width treatment as one that appends a pair — the winner step used to
put a lone Confirm in the left half of the dialog, looking like one option out of
several that hadn't drawn yet. */
/* There is more list above the buttons than the dialog can show.
Measured on a Galaxy S8: three players are in the pot, the list holds three rows, and
two of them fit. The third was cut off exactly on a row boundary, immediately under a
hairline that reads as the end of the list — so the dialog that pays the table looked
finished with a name missing from it, and a dealer ticking the two they could see
would have paid two thirds of a three-way pot. The list does scroll; nothing said so.
Now the cut runs through a row rather than between them, under a fade, which is what
a list that continues looks like. */
#potWizardModal.potWizardMoreBelow #potWizardFooter::before {
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 100%;
height: 2.25rem;
background: linear-gradient(rgba(30, 30, 30, 0), var(--surface-1) 85%);
pointer-events: none;
}
#potWizardFooter {
flex: none;
position: relative;
display: flex;
gap: var(--sp-2);
margin-top: var(--sp-4);
/* A hairline, so the buttons read as sitting below the list rather than as its
last row — the body scrolls under them and the edge is what says so. */
padding-top: var(--sp-3);
border-top: 1px solid var(--line);
}
#potWizardFooter > .holdemBtn,
#potWizardFooter > .potWizardActions {
flex: 1;
}
#potWizardFooter:empty {
display: none;
}
/* No longer sticky, and doesn't need to be: the modal stopped scrolling when its body
became the only thing that does, so the title and the ✕ stay put on their own. */
.potWizardHeader {
display: flex;
align-items: center;
justify-content: space-between;
flex: none;
margin-bottom: var(--sp-3);
}
.potWizardHeader h2 {
margin: 0;
font-size: var(--fs-lg);
font-weight: 400;
color: var(--text-bright);
}
.potWizardLog {
margin-bottom: var(--sp-3);
}
.potWizardLogLine {
font-size: var(--fs-base);
color: var(--text-muted);
margin: var(--sp-1) 0;
}
.potWizardPrompt {
font-size: var(--fs-md);
color: var(--text);
margin-bottom: var(--sp-3);
}
/* This is the one screen in the tool that divides money, and its Confirm button sat
below a scroll at every size — measured with six players and a side pot: 244px of it
off the bottom on an iPhone 14, 417 on a Galaxy S8, 583 on a phone held sideways,
172 on a desktop. A dealer with the table waiting had to scroll a list of names to
find the only control that finishes the hand, and nothing on screen said there was
anything below it.
Capping the list rather than pinning the button, because a sticky footer cannot work
here: the actions are the last child of the scrolling content, so their containing
block ends exactly where they do and there is no room for `bottom` to lift them. The
list is the only part that grows without limit anyway — the pot breakdown, the
prompt and the buttons are all a fixed size — so bounding it keeps the whole dialog
inside the screen and the button where the dealer's thumb already is. */
/* What the tool read off the felt, above the list of names. Two of them: the one that
settles the pot, and the one that says why it could not. */
.potWizardVerdict {
margin: 0 0 var(--sp-3);
padding: var(--sp-2) var(--sp-3);
border-radius: var(--r-md);
border-left: 3px solid var(--line-strong);
background: var(--surface-0);
font-size: var(--fs-base);
line-height: 1.4;
}
.potWizardVerdictSure {
border-left-color: var(--gold);
color: var(--text-bright);
}
/* Not a warning — the dealer picking the winner themselves is the tool's own default
and always has been. This says only that the reading is theirs to make this time. */
.potWizardVerdictUnsure {
color: var(--text-muted);
}
/* The hand each player is holding, under their name. Muted, because the row's job is
still to be a name and a target — this is the working, shown so the answer above can
be checked rather than taken. */
.potWizardChoiceHand {
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.3;
}
/* What this player can win, out beside the name rather than under it.
It used to sit in the same column as the name, and the read hand now has that line —
three stacked lines makes a 100px row, and a nine-handed showdown is nine of them.
Beside is also where the phone layout had already moved it, for the same reason. */
.potWizardChoiceUpTo {
flex: none;
align-self: center;
white-space: nowrap;
}
.potWizardChoices {
display: flex;
flex-direction: column;
gap: var(--sp-2);
margin-bottom: var(--sp-2);
}
/* This is the screen where the money gets decided, so it holds the same 44px+
targets as the rest of the tool rather than the smallest ones in it. Picking the
wrong winner is the one mistake here that costs somebody real money. */
.potWizardChoice {
display: flex;
align-items: center;
gap: var(--sp-3);
min-height: 56px;
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: var(--fs-md);
transition: border-color 0.12s ease, background 0.12s ease;
}
@media (hover: hover) {
.potWizardChoice:hover {
border-color: #555;
background: var(--surface-1);
}
}
/* The last raw browser control in the tool.
`accent-color` only paints the tick; the box around it stayed the platform's own —
a bright white square, which on this near-black dialog was the loudest thing on
screen and read as unfinished next to controls that had all been drawn. Painted to
match the rest, on the same surface ramp and the same corner radius. */
.potWizardChoice input {
flex: none;
appearance: none;
-webkit-appearance: none;
width: 22px;
height: 22px;
border: 2px solid var(--line-strong);
border-radius: var(--r-sm);
background: var(--surface-2);
display: grid;
place-content: center;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease;
}
/* The tick is drawn rather than typed, so it can't pick up a font the device happens
to substitute — and it scales with the box instead of with the text around it. */
.potWizardChoice input::before {
content: "";
width: 12px;
height: 12px;
transform: scale(0);
transition: transform 0.12s ease;
background: var(--surface-0);
clip-path: polygon(14% 46%, 0 60%, 40% 100%, 100% 22%, 86% 8%, 38% 72%);
}
.potWizardChoice input:checked {
background: #1e8449;
border-color: #2fa563;
}
.potWizardChoice input:checked::before {
transform: scale(1);
}
@media (prefers-reduced-motion: reduce) {
.potWizardChoice input,
.potWizardChoice input::before {
transition: none;
}
}
/* Selected has to be obvious at a glance — a dealer confirms this with players
watching, and a checkbox alone is easy to misread across a table. */
.potWizardChoice.chosen {
border-color: #1e8449;
background: linear-gradient(180deg, #16301f, #12241a);
}
.potWizardChoice.chosen .potWizardChoiceName {
color: #d6ffe0;
}
.potWizardChoiceText {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--sp-0);
}
.potWizardChoiceName {
color: var(--text-bright);
font-weight: 600;
}
.potWizardChoiceUpTo {
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: var(--fs-md);
font-weight: 700;
color: var(--text);
}
.potWizardChoiceCards .cardRed {
color: #ff6b6b;
}
/* What the house takes, above the payouts it comes out of. Stated in the summary
rather than only in the log afterwards: this is the dealer's last chance to
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 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: var(--fs-base);
}
.potWizardSummary ul {
list-style: none;
padding: 0;
margin: 0 0 var(--sp-4);
}
.potWizardSummary li {
padding: var(--sp-2) 0;
border-bottom: 1px dashed var(--surface-3);
font-size: var(--fs-md);
color: #d6ffe0;
}
/* The chips to push, under the amount they add up to. Quieter than the payout it
belongs to — the dealer reads the name and the money first, then counts. */
.payoutChips {
display: block;
margin-top: var(--sp-0);
font-size: var(--fs-sm);
color: #9fb8a6;
font-variant-numeric: tabular-nums;
}
.potWizardActions {
display: flex;
gap: var(--sp-2);
}
/* Every button in this modal is one a dealer presses with players waiting. */
#potWizardBody .holdemBtn {
min-height: 48px;
justify-content: center;
}
/* The confirm is the whole point of the step, so it gets the whole width rather
than being sized to the length of its own label. */
.potWizardRound > .holdemBtn {
width: 100%;
}
.potWizardActions .holdemBtn {
flex: 1;
}
.potWizardSummary li {
font-variant-numeric: tabular-nums;
}
@media (max-width: 700px) {
/* The cap lives here now, and the reason it didn't is worth keeping.
It was measured and rejected once: at 360x640 it took the table from 321px to
226px, which fixed the six-seat ring but broke the nine-seat one — two covered
seats traded for two seats whose centre landed on a neighbour. The narrow layout
fell back on reserving the bar's height as scroll room, on the grounds that a
phone is a thing you scroll.
That trade was real then and isn't now: seats are spaced by distance around the
rim rather than by angle, so a shorter felt no longer piles them up. Re-measured
at 360x640 with the bar grown by a side pot, where the bottom seat sat 38px
inside it: the ring now clears the bar by 50. Scroll room is not an answer for a
seat that is covered rather than merely below the fold.
The room is spent on height, not on both. Capping the *width* by it — the wide
layout's way, because there the felt is sized from its width — kept the table
square and threw away 96px of a 360px screen: 240x240 inside a 336px column. A
max-height leaves the width alone and lets the aspect ratio give way, so a short
screen gets a wide oval, which is the shape a poker table is anyway. Measured at
360x640: 336x240 rather than 240x240, same clearance, and the twelve-seat ring
that was crowded at 240 comes out clean. Where the room isn't the binding
constraint — 390x844 and up — nothing changes and the table stays round. */
#pokerTableWrap {
max-width: none;
max-height: var(--feltRoomPx, 100vh);
}
/* Stacked, with the action that ends the hand furthest from a stray thumb. */
.potWizardActions {
flex-direction: column-reverse;
}
.potWizardChoice {
min-height: 60px;
}
}
@media (max-width: 900px) {
#holdemLayout {
grid-template-columns: 1fr;
}
/* One column, so the table has the full width and can afford to be round. */
#pokerTableWrap {
--feltAspect: 1;
}
/* No width here any more. This was 78px, and two blocks below it were 64 and 62 —
three guesses, at three breakpoints, at the one number that depends on something
a media query cannot see: how many people are sitting down. The same 390px phone
has room for a 90px puck at four seats and 61px at nine. render.js measures it. */
.seat {
min-height: 54px;
}
}
/* ---------- Phones ----------
Two things break on a narrow screen: seat pucks ring the table on a radius
measured from the wrap, so the left- and right-most ones hang off the edge of
the viewport; and the seat editor lives far enough down the page that tapping a
seat looks like it did nothing. Inset the ring by half a puck, and float the
editor up as a bottom sheet over the table. */
@media (max-width: 700px) {
/* The bottom padding has to clear the fixed action bar, plus room for a toast to
sit above it. Without it the table just carries on underneath the bar and the
lower seats of the oval are behind the Fold button — measured at 360x640, where
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: var(--sp-4) var(--sp-3) calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px));
}
#holdemHeader {
margin-bottom: var(--sp-4);
gap: var(--sp-2);
}
#holdemHeader h1 {
font-size: var(--fs-xl);
}
#holdemHeaderActions {
width: 100%;
gap: var(--sp-2);
}
#holdemHeaderActions .holdemBtn {
flex: 1 1 auto;
justify-content: center;
min-height: 46px;
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,
so half a puck of padding is exactly enough to stop the overhang.
Half a puck is the right answer when the felt is tall enough to spare it. On a
phone held sideways it isn't: the felt is 96px tall there once the action bar has
grown, and 26px off the top and bottom left the ring 44px of height to work with
— less than half the table. That is what kept the seats at the ends stacked no
matter what shape the rim was.
Above about 216px of felt the flat 26px still wins, so nothing upright changes. */
#seatsLayer,
#betsLayer {
inset: min(26px, 12%) 44px;
}
/* The puck is only a summary — the real form is the sheet — so a phone gives it
tighter padding and a shorter floor. How *wide* it gets is measured, not declared
here: see the note on the 900px block above. */
.seat {
min-height: 52px;
padding: var(--sp-1) var(--sp-1);
}
#potBadge {
padding: var(--sp-2) var(--sp-4);
}
#potBadgeAmount {
font-size: var(--fs-lg);
}
.tableHint {
margin-top: var(--sp-3);
font-size: var(--fs-sm);
}
/* ---- Seat editor as a bottom sheet ---- */
#seatEditorPanel {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 60;
margin: 0;
max-height: 74vh;
overflow-y: auto;
border-radius: var(--r-lg) var(--r-lg) 0 0;
border-bottom: none;
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;
-webkit-overflow-scrolling: touch;
}
body.seatSheetOpen #seatEditorPanel {
transform: translateY(0);
}
.seatSheetGrip {
display: block;
width: 38px;
height: 4px;
margin: var(--sp-1) auto var(--sp-2);
border-radius: var(--r-pill);
background: var(--line-strong);
}
.seatSheetClose {
display: inline-flex;
align-items: center;
flex: none;
min-height: 44px;
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;
}
/* The sheet covers the bottom of the page while it's open — give the sidebar
room so nothing is stranded underneath it. */
body.seatSheetOpen #holdemSidebar {
padding-bottom: 40vh;
}
/* The seat form was sized for a 320px desktop sidebar. In the sheet it has a
whole phone width to work with, so everything grows to thumb size — and
every field goes to 16px, below which iOS zooms the page on focus and the
dealer has to pinch back out mid-hand. */
/* Every text or number field in the form, rather than a list of the ones that
existed when this was written. The stack field was added later and sat at 28px
next to its 44px neighbours precisely because it wasn't on that list. */
.seatEditorForm input:not([type="checkbox"]):not([type="color"]) {
font-size: 16px;
min-height: 44px;
padding: var(--sp-2) var(--sp-2);
}
.seatEditorForm .chipCountInput {
width: 56px;
text-align: center;
}
.seatEditorForm .chipCounts {
gap: var(--sp-2);
}
.seatEditorForm .chipSwatch {
width: 16px;
height: 16px;
}
.seatEditorForm .amountOverrideInput {
width: 110px;
}
.seatEditorForm .cardSlot {
font-size: var(--fs-xl);
min-height: 62px;
}
/* Thirteen rows of cards need to stay reachable without the sheet running off
the bottom of a phone. */
.pickerCard {
min-height: 46px;
font-size: var(--fs-lg);
}
.seatEditorForm .seatCardToggles {
gap: var(--sp-4);
}
.seatEditorForm .inHandToggle,
.seatEditorForm .foldToggle {
min-height: 44px;
font-size: var(--fs-md);
}
.seatEditorForm .inHandToggle input,
.seatEditorForm .foldToggle input {
width: 22px;
height: 22px;
}
.seatEditorForm .amountOverrideRow,
.seatEditorForm .seatTotal {
font-size: var(--fs-md);
}
.seatEditorForm .addFieldBtn {
width: 100%;
justify-content: center;
min-height: 44px;
margin-top: var(--sp-2);
}
/* The sidebar's own controls never got the touch pass the seat form did — these
are settings a dealer changes at the table, on the same phone. Written against
the panel rather than each field, so a setting added later inherits it. */
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select {
font-size: 16px;
min-height: 44px;
padding: var(--sp-2) var(--sp-2);
}
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn,
/* The card picker's Clear and Done sat at 40px: the touch rule was scoped to
.panelBody and the picker is a sheet, so it never reached them. Clear wipes a
player's cards, which is not a thing to make a dealer aim at. */
.cardPickerHeader .holdemBtn,
/* Undo was 30px, and it is the control a dealer reaches for having just recorded
the wrong thing with five people waiting — the worst place to be fourteen pixels
short of a fingertip. Growing the box costs bar height, which comes out of the
felt, but measured at 390x844 the felt is 366px against a 220px floor, so there
is room for it. A hit-area pad was tried first and does not work here: the pill
clips its overflow to keep a long label on one line, which clips the pad too. */
.actionUndo {
min-height: 44px;
}
/* Destructive and tiny is the worst combination: these delete a chip colour or a
custom field, and were 28px wide. */
.removeChipColorBtn,
.removeFieldBtn {
min-width: 44px;
min-height: 44px;
}
/* flex-shrink would take a stated width straight back off again in a row this
narrow, which is why it stayed 32px wide with the height applied. */
.chipColorInput {
flex: none;
width: 44px;
height: 44px;
}
/* A bare checkbox can't grow into a target, but the row it sits in can — the
label is what a tap actually lands on. */
.panelBody .fieldRow {
min-height: 44px;
}
/* A bare checkbox with no label box around it to grow. */
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
/* An underlined word is a 14px-tall target; give it room without making it look
like a button. */
.linkBtn {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 var(--sp-2);
}
/* The dealer button has to stay a 22px disc — it's a chip sitting on a seat, and
drawing it thumb-sized would swallow the puck. So the disc keeps its size and
an invisible pad around it takes the tap. It reaches outward, away from the
puck's own tap target, so pressing the seat still opens the seat. */
.dealerChip::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 44px;
height: 44px;
transform: translate(-50%, -50%);
}
.dealerChip {
overflow: visible;
}
.toastAction {
min-height: 44px;
}
.confirmActions {
flex-direction: column-reverse;
}
.confirmActions .holdemBtn {
width: 100%;
justify-content: center;
}
}
/* ---------- A phone on its side ----------
Deliberately last in the file. The felt is an aspect-ratio box whose height comes
from `padding-bottom`, and the `max-width: 900px` block above re-sets that to 100%
— so a landscape rule placed any earlier is overridden at equal specificity and
does nothing, which is exactly what happened on the first attempt.
Landscape is where this layout falls apart worst: sized from width, the table came
out 765px tall in a 390px-tall window, putting six of the nine seats below the fold
and the rest behind the action bar. Sizing it from the height that's actually left
makes every seat reachable without scrolling. The table ends up small, but a seat
you have to go hunting for is worse than a seat that's small. */
@media (orientation: landscape) and (max-height: 560px) {
/* Set outright rather than via padding-bottom: a percentage padding resolves
against the containing block's width, not the element's own, so narrowing the
wrap on its own left it full height regardless. */
#pokerTableWrap {
/* A wide ellipse, which is what a real poker table is.
Capping the width by the height to hold the portrait aspect ratio was
throwing away most of the screen: at 640x360 the felt came out 135px wide
inside a 601px column — 22% of the width — leaving 366px of rim for nine
seats that need 662px. Adjacent seat centres ended up 13px apart, which is
why a seat's centre belonged to its neighbour.
Landscape has width and no height, so the table takes the width it has and
is only as tall as the room left over.
The height comes from --feltRoomPx, the same measured value the wide layout
caps its width by: the bar's real height, the wrap's real offset and the
bottom seat's overhang, all taken off the window. This branch used to work
it out itself as `100vh - bar - 7rem`, guessing that everything above the
felt came to 7rem. It comes to 108px at 640x360, which the guess covers, and
119px at 844x390, which it doesn't — so on an iPhone on its side the bottom
three seats sat up to 26px inside the action bar. Nothing here needs to
guess; the measurement was already being published. */
width: 100%;
height: var(--feltRoomPx, calc(100vh - var(--actionBarHeight, 0px) - 7rem));
/* This branch sets its own height outright, so neither the aspect ratio nor the
cap derived from it has anything to say here — and the cap would only narrow
a table that is already as short as the screen allows. */
aspect-ratio: auto;
max-width: none;
/* This branch already spends the measured room on its own height. The phone
block's max-height is the same number arriving twice, and applying it here
took the felt down to the floor value and crowded the ring — measured at
640x360, two seats lost their centres to their neighbours. */
max-height: none;
}
/* The page's own top padding is 32px of nothing, on the screen that has the least
to spare. */
#holdemApp {
padding-top: var(--sp-2);
}
/* The header ate 148px of a 390px-tall window, which was most of the reason the
table started below the fold. Here it only needs to be a title bar.
It still wrapped to two rows, and that second row is 44px of the 390 — the title
block and the five buttons came to 829px inside a 765px header at 844x390, so
they missed sharing one line by 64px, and at 640x360 by rather more. What goes is
the page title: the one thing on this screen a dealer already knows. It is hidden
from the eye rather than removed, so the page keeps its heading for a screen
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: var(--sp-2);
gap: var(--sp-1);
flex-wrap: nowrap;
align-items: center;
}
#holdemHeader h1 {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.handCounterBadge {
margin-top: 0;
}
/* Tightened across, never down: the buttons keep the 44px a thumb needs. */
#holdemHeaderActions {
flex-wrap: nowrap;
gap: var(--sp-1);
}
#holdemHeaderActions .holdemBtn {
padding-left: var(--sp-2);
padding-right: var(--sp-2);
font-size: var(--fs-sm);
white-space: nowrap;
}
/* 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
an Undo button, so a tap meant for a player was landing on "Undo". */
body.hasActionBar #toastHost,
#toastHost {
left: auto;
right: 0.5rem;
max-width: 17rem;
bottom: calc(var(--actionBarHeight, 0px) + 0.5rem + env(safe-area-inset-bottom, 0px));
}
/* The clock's ruling, said in a sentence, is a line this screen cannot pay for:
measured at 640x360, the bar goes from 152px to 269px when the count runs out and
the bottom seats end up behind it. The button carries the same ruling in three
words — "Kill Dee's hand" — so the sentence is what goes, not the call. */
.clockExpired {
display: none;
}
/* The same trade, for the pot total. Measured on a phone held sideways, the badge is
86x66 on a felt that is 692x113 — it takes 58% of the table's height for a number
the action bar is already printing three inches below it, in the line that starts
"PRE-FLOP Pot $65". Two seat pucks are 55px tall and the felt has to hold two rows
of them, so the badge is not overlapping some empty middle: it is standing in the
only band a player's bet chip has to go in, and the bottom-middle seat's chip had
nowhere left to be put but on top of its own player's name.
Upright it stays. There the felt is 248x280, the badge is a fifth of it, and it is
the only thing in the middle of the table. */
#potBadge {
display: none;
}
/* The board stays — it is the one thing in the middle of the felt that is not
written down anywhere else. It gets the room the pot badge gave up. */
.boardCard {
min-width: 26px;
min-height: 34px;
font-size: var(--fs-sm);
padding: 0 2px;
}
.boardCard .boardCardSuit {
font-size: var(--fs-xs);
}
/* Seats around a table this small overlap each other, so they shrink to suit — by
measurement now rather than by this block's old guess of 62px. */
.seat {
min-height: 50px;
}
/* The same inset the phone layout uses, which this branch never had because it is
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.
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: max(24px, 12%) 44px min(26px, 12%);
}
}
/* ---------- Short phones, upright ----------
A 640px-tall screen spends ~150px on the header before the felt even starts, which
left the bottom of the table sitting behind the action bar until you scrolled. The
header carries no information a dealer needs mid-hand, so it gives the space up. */
@media (max-height: 700px) {
/* Nowhere on a screen this short is the toast clear of both the table and the
action bar, so it overlaps the one that costs least: it sits over the bar's
status line, which is information a dealer can re-read a second later, rather
than over the seats and buttons, which are things they need to hit. Overlapping
a readout beats covering a control. */
body.hasActionBar #toastHost {
bottom: calc(var(--actionBarHeight, 0px) - 3.1rem);
}
.toast {
padding: var(--sp-2) var(--sp-3);
font-size: var(--fs-sm);
}
}
@media (max-width: 700px) and (max-height: 700px) {
#holdemHeader {
margin-bottom: var(--sp-2);
gap: var(--sp-2);
}
#holdemHeader h1 {
font-size: var(--fs-lg);
padding-bottom: var(--sp-1);
}
}
/* ---------- A phone on its side: the bar goes beside the table ----------
Everything above spends its effort dividing one column of height between a table and
a bar, on the screen that has the least height in the world. Measured at 640x360
mid-hand, the split comes out: 70px of header, 85px of felt, 205px of bar. The felt
is 529x85. Two rows of seat pucks 55px tall have to fit in 85px of it, which they
cannot, so seven of the nine finish past the rail — up to 17px of puck floating on
the page. That is 96 of the 98 offenders the layout audit finds anywhere.
No amount of shrinking fixes it, because the shortage is not in the table. A phone
on its side is 640x360: short of height and swimming in width. The bar is 205px tall
and 529 wide to hold three buttons and a status line, and it is taking that height
from the one element on the page that needs height.
So it moves. In landscape the bar becomes a right-hand rail, the table takes the
whole column beside it, and the felt goes from 529x85 to roughly 420x270 — an actual
oval, with room for two rows of pucks and a gap between them.
The rail is also where the hand already is. This is the layout every mapping and
drawing app uses on a rotated phone for the same reason, and it puts Fold/Call/Raise
under the right thumb rather than across the bottom edge where a landscape grip has
the palm.
Scoped to phones. Above 900px the sidebar is a right-hand column already (see
#holdemLayout) and a rail would land on top of it; short desktop windows have height
to spare and don't have the problem. */
@media (orientation: landscape) and (max-height: 560px) and (max-width: 900px) {
:root {
/* Wide enough for "Raise to" and a four-figure amount side by side, and for a
48px control to keep its own padding; never more than a third of the screen,
because the rest of it is the table. */
--actionRailW: clamp(11.5rem, 30vw, 15rem);
}
#actionBar {
--actionBarLayout: rail;
top: 0;
bottom: 0;
left: auto;
right: 0;
/* Border-box, so --actionRailW is the whole rail. Content-box made it 217px of
a 192px token, and everything that reserves room for it — the page's padding,
the seat sheet's right edge, the toast — was 25px short in the same direction:
Resolve Pot finished 13px underneath it. One number, one meaning. */
box-sizing: border-box;
width: var(--actionRailW);
max-width: none;
margin: 0;
display: flex;
flex-direction: column;
gap: var(--sp-2);
/* The raise pad is the tallest thing that appears here. On the shortest screen
in the list it can outgrow the rail, and a control that has fallen off the
end of a fixed panel is unreachable rather than merely awkward. */
overflow-y: auto;
overscroll-behavior: contain;
border-radius: var(--r-lg) 0 0 var(--r-lg);
border: 1px solid var(--line);
border-right: none;
padding: var(--sp-3) calc(var(--sp-3) + env(safe-area-inset-right, 0px)) var(--sp-3) var(--sp-3);
box-shadow: -12px 0 34px rgba(0, 0, 0, 0.6);
}
/* Anchored low: the stack reads top-to-bottom in the order it always has — status,
then the buttons — and sitting at the bottom of the rail puts Fold, Call and
Raise where the thumb rests rather than where the hand has to reach up.
Done with a margin on the first child rather than `justify-content: flex-end`,
which is the same picture until the content outgrows the rail. Then flex-end
overflows the *start* edge, and a scroll container cannot scroll back past its
own start — the status line would be painted off the top and unreachable. A
margin pushes the same distance and collapses to nothing when there is none to
give, so the overflow lands at the end, where scrolling can reach it. Measured at
640x360 the rail is 358px of content in 360px: it is one side pot away from
needing this. */
#actionBar > :first-child {
margin-top: auto;
}
/* And nothing in the rail gives up its own height to make room. A flex item shrinks
by default, so once the raise pad and the status line together outgrew the rail —
which they do at 640x360 the moment the amount field is thumb-sized — the status
line was squeezed under the pad and the undo pill came out two-fifths covered by
the Min button. Overflow is the honest outcome, and the scroll above can reach
it; a control drawn on top of another one cannot be reached at all. */
#actionBar > * {
flex: none;
}
/* The page gives up the width instead of the height. The bottom padding that was
clearing the bar has nothing left to clear — and 5rem of it on a 360px-tall
screen is a seventh of the window held empty for a bar that isn't there. */
#holdemApp {
padding: var(--sp-2) calc(var(--actionRailW) + var(--sp-3)) var(--sp-2) var(--sp-3);
}
/* Everything in the rail is a column now: three buttons stacked, and a status line
that was written to sit on one row of a 529px bar. */
.actionButtons,
.dealPromptButtons {
flex-direction: column;
}
/* This row already wraps, which is the right behaviour in a narrow column — it just
has to stop reserving two lines it no longer needs. That reservation exists so a
bar fixed to the bottom edge doesn't move its top edge under the dealer's thumb
as the text rewraps; a rail is anchored at the bottom the same way, but it is
190px wide, where every one of these readings is on its own line regardless. */
.actionStatus,
.actionStatus:has(.actionUndo) {
gap: var(--sp-1) var(--sp-2);
margin-bottom: 0;
min-height: 0;
}
.actionUndo {
width: 100%;
}
/* Four quick-raise buttons across 190px is 40px each. Two rows of two is 90px each,
which is a control rather than a target to aim at. */
.raiseQuick {
display: grid;
grid-template-columns: 1fr 1fr;
}
.raiseEntry {
flex-wrap: wrap;
}
.raiseInput {
flex: 1 0 100%;
}
.raiseCancel,
.raiseConfirm {
flex: 1 1 0;
min-width: 0;
}
/* Back to the middle of the screen. The old landscape rule pushed the toast to the
right edge to keep it off a full-width felt; that edge is the rail now, and the
felt no longer runs under it. */
body.hasActionBar #toastHost,
#toastHost {
left: var(--sp-3);
right: calc(var(--actionRailW) + var(--sp-3));
max-width: none;
bottom: var(--sp-3);
}
/* The pot total comes back. It was hidden here because the badge took 58% of an
85px felt and stood in the only band a bet chip could go in — on a felt three
times as tall it is the middle of the table again, which is where a dealer looks
for the pot. */
#potBadge {
display: flex;
}
/* And the ring goes back to being measured rather than held off the edges by hand.
The insets above exist to stop a flat felt's top seat putting its dealer button
through the header; with the felt this tall the ring has its own room, and the
44px side inset was reserving a fifth of the width of a felt that is now the
narrower dimension. */
#seatsLayer,
#betsLayer {
inset: 0;
}
/* And the felt fills the box it is given. The 6% inset everywhere else is the strip
a seat puck straddles — half on the rail, half off it, the way a player sits at a
table. In this column that strip is 25px of a 409px width taken off both ends of
the widest dimension the table has left, and the ring does not want it: the seats
are held inside the rail here by measurement, so nothing is ever painted in it.
Measured, giving it back is 49px of felt, which is a puck size for every chair. */
#pokerTable {
inset: 0;
}
/* The links under the table are the last thing in the column, and on the old layout
they were simply behind the bar. There is room for them now, but not for the
margins of a desktop. */
.tableHintActions {
margin: var(--sp-1) 0 0;
}
/* The phone layout gives this row the full width, which puts it on a line of its
own — 36px of a 360px-tall screen for three buttons that need 217. Beside the
title instead, which is where they sit at every other size and which the header
has room for now that it is a column rather than the whole window. The wrap stays
as the backstop for a narrower screen than any in the list. */
#holdemHeader {
flex-wrap: wrap;
}
#holdemHeaderActions {
width: auto;
margin-left: auto;
}
/* The seat editor comes up as a sheet from the bottom on a phone, full width. Full
width now runs under the rail, and it sits a layer above it, so it was covering
Fold, Call and Raise with a form. It stops at the rail instead — which also means
the dealer can still see the hand they are in while editing a chair. */
#seatEditorPanel {
right: var(--actionRailW);
border-top-right-radius: var(--r-lg);
}
}
/* ---------- Keyboard focus ----------
Three focus rules existed for the whole tool, so tabbing through it relied on
whatever ring the browser draws by default — which on these dark, custom-painted
controls is close to invisible. `:focus-visible` rather than `:focus` keeps it to
keyboard use: a dealer tapping Fold shouldn't be left with a ring on it. */
.holdemBtn:focus-visible,
.linkBtn:focus-visible,
.seatMenuBtn:focus-visible,
.seatSheetClose:focus-visible,
.pickerCard:focus-visible,
.actionBtn:focus-visible,
.raiseQuickBtn:focus-visible,
.actionUndo:focus-visible,
.toastAction:focus-visible,
.toastClose:focus-visible,
.potWizardChoice:focus-visible,
.cardSlot:focus-visible,
.subPanel summary:focus-visible,
.seat:focus-visible,
button:focus-visible,
[role="button"]:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 2px solid #7fc4ff;
outline-offset: 2px;
/* The ring has to sit above the neighbouring seat that overlaps this one. */
position: relative;
z-index: 3;
}
/* A sheet with nothing focusable in it takes focus itself so its title is read out.
That shouldn't paint a ring around the whole panel. */
[role="dialog"]:focus,
[role="alertdialog"]:focus {
outline: none;
}
/* ---------- Fields on a tablet ----------
The 16px minimum and the 44px touch targets were both written into the phone
media query, so every iPad size missed them — measured at 768, 834 and 1024 wide,
where all ten setup fields sat at 13.3px. iOS zooms the page whenever a field
under 16px takes focus, so a dealer on an iPad was getting the table zoomed every
time they touched the blind amount, then pinching back out. The reason for the
rule was never "narrow screen", it was "iOS", and an iPad is iOS.
Font size applies everywhere now — 13.3px was small for a settings field on a
desktop anyway. The touch sizing keys off `hover: none`, which is what actually
distinguishes a finger from a mouse; a tablet gets thumb-sized controls and a
desktop keeps its compact sidebar. */
.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select,
.panelBody textarea {
font-size: 16px;
}
@media (hover: none) {
.seatEditorForm input:not([type="checkbox"]):not([type="color"]),
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select,
/* The raise amount, which this rule reached for the settings fields and missed —
it is in the action bar rather than in a panel or the seat form, so it is the
one field the list didn't name. Measured on a phone: 36px, on the one surface in
the tool that exists to be hit with a thumb, holding the one number that is the
size of somebody's bet. The audit never caught it either, because the sweep had
no step that opened the raise pad; it has one now. */
.raiseInput {
min-height: 44px;
padding: var(--sp-2) var(--sp-2);
}
}
.toastClose::after {
content: "";
position: absolute;
inset: -7px;
}
/* A note under the raise entry when the amount typed isn't a legal raise. It informs
and never blocks: this tool records what the humans at the table actually did, and
a dealer overruling the arithmetic is right more often than the arithmetic is.
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: var(--sp-2) 0 0;
font-size: var(--fs-sm);
line-height: 1.35;
text-align: center;
}
.raiseNote.raiseNoteWarn {
color: #f0b46a;
}
.raiseNote.raiseNoteInfo {
color: #9fc7e8;
}
/* On a short phone the raise pad already makes the bar tall enough to cover the
lowest seats, and the note must not make that materially worse. Measured at
360x640: bar 122px closed, 178px with the pad open, 217px with a note. Tightened
here so the note costs a line rather than a paragraph — the bar is a transient
input mode with Cancel and Escape, but it shouldn't grow for the sake of it. */
@media (max-height: 700px) {
/* The same economy for the pot dialog's list of winners, which is the other place
on a short screen where a line costs a name: two lines become one, a row goes
from 78px to 56, and a three-way pot fits without scrolling on a 640px phone.
The row is still 56px, so it is still a target a thumb can hit — this is where
the money is decided and that floor does not move. */
.potWizardChoice {
font-size: var(--fs-base);
}
.potWizardChoiceHand {
font-size: var(--fs-2xs);
}
.raiseNote {
margin-top: var(--sp-1);
font-size: var(--fs-xs);
line-height: 1.25;
}
/* The pot breakdown is reference material — which side pots exist and who can win
them. While an amount is being typed it isn't what the dealer is reading, and
it's the tallest thing competing for the strip. */
body.hasActionBar #actionBar:has(.raisePad) .potBreakdown {
display: none;
}
}
/* ---------- The deal step ----------
Between hands the bar shows who will post and waits. Dealing used to happen the
instant the previous hand ended, which committed blinds while people were still
sitting down — and left the blind badges disagreeing with the money already in. */
.dealPrompt {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.dealPromptLine {
margin: 0;
text-align: center;
font-size: var(--fs-base);
color: #c8c8c8;
line-height: 1.35;
}
.dealPromptLine.dealPromptBlocked {
color: var(--text-muted);
}
.actionBtn.actionDeal {
background: linear-gradient(180deg, #16783a, #0d5225);
border-color: #1e8449;
color: #ecffef;
font-weight: 600;
}
/* The straddle rides on the deal row, so it must not take half of it: .actionBtn is
flex: 1 and would have split the row evenly with the button the dealer presses every
hand. It sizes to its own words instead, and stays quiet next to the green one —
choosing a straddle is the exception, dealing is the rule. */
.actionBtn.actionStraddle {
flex: 0 1 auto;
background: linear-gradient(180deg, #33304a, #24223a);
border-color: #4b4770;
color: #e6e1ff;
font-size: var(--fs-base);
padding-left: var(--sp-3);
padding-right: var(--sp-3);
}
.actionBtn.actionStraddleOff {
background: linear-gradient(180deg, var(--surface-3), var(--surface-2));
border-color: var(--line-strong);
color: #dcdcdc;
}
/* Three buttons across a 360px phone is a squeeze, and the two ways out are both
worse than this one. Letting the row wrap put the deal button on its own line and
made the bar 64px taller — measured at 360x640, that is the bottom seat three fifths
behind the bar, and this layout deliberately doesn't shrink the felt to suit (see the
max-width: none override, where capping it was tried and rejected). Letting flex
squash freely took the deal button to 84px with its label over three lines.
A floor of 7rem keeps the label to two lines and the row to one, so arming a straddle
costs no felt at all. */
.dealPromptButtons .actionDeal {
min-width: 7rem;
}
/* A prompt with one thing to do gives that thing the whole row. The empty-table
prompt is the first control a dealer ever meets here, and a button sitting in the
left third of the bar reads as one option among several that haven't loaded yet. */
.dealPromptButtons:has(> :only-child) .actionDeal {
flex: 1;
width: 100%;
}
/* 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: var(--fs-sm);
color: var(--text-muted);
}
/* An aside that has something to press. It wraps rather than squeezing the button:
on a phone the sentence takes the width it needs and the button drops under it,
still at full tap size, instead of being crushed to a sliver at the end of a line. */
.dealPromptLine.dealPromptDebt {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--sp-2) var(--sp-2);
}
.dealPromptDebtBtn {
/* Sized down from a full action button — it's an option beside a sentence, not
the thing the dealer came here to press. "Post blinds & deal" stays the button
that looks like the button. */
flex: 0 0 auto;
min-height: 32px;
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;
}
@media (max-width: 700px), (pointer: coarse) {
/* A fingertip needs 44px, and this one moves money. */
.dealPromptDebtBtn {
min-height: 44px;
padding: var(--sp-2) var(--sp-4);
}
}
/* ---------- Touch targets on a wide touch screen ----------
Every touch-target rule in this file sits behind `max-width: 700px`, and width was
never the question. A phone held sideways is 844px wide, so none of that work
applied to it. Measured on an emulated touch device at 844x390, with the sidebar
panels open: twelve controls under 44px, including the rulings link at 91x14 and
the dedicated-dealer checkbox at 13x13.
Restated here against the pointer instead of the viewport. The width-scoped block
keeps its layout changes — one column, stacked dialogs — because those really are
about how much room there is. This is only about what a thumb can hit, so it asks
about the thumb. */
@media (pointer: coarse) {
.panelBody input:not([type="checkbox"]):not([type="color"]),
.subPanelBody input:not([type="checkbox"]):not([type="color"]),
.panelBody select {
font-size: 16px;
min-height: 44px;
padding: var(--sp-2) var(--sp-2);
}
.panelBody .holdemBtnSmall,
.handHistoryActions .holdemBtn,
.toastAction,
.cardPickerHeader .holdemBtn,
.actionUndo {
min-height: 44px;
}
/* Destructive and tiny is the worst combination: these delete a chip colour or a
custom field, and are 28px wide. */
.removeChipColorBtn,
.removeFieldBtn {
min-width: 44px;
min-height: 44px;
}
/* flex-shrink would take a stated width straight back off again. */
.chipColorInput {
flex: none;
width: 44px;
height: 44px;
}
/* A bare checkbox can't grow into a target, but the row it sits in can — the
label is what a tap actually lands on. */
.panelBody .fieldRow {
min-height: 44px;
}
#dedicatedDealerToggle {
width: 24px;
height: 24px;
}
/* An underlined word is a 14px-tall target; give it room without making it look
like a button. */
.linkBtn {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0 var(--sp-2);
}
}