diff --git a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml index dbd7cbf..b6dea7b 100644 --- a/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml +++ b/JoshHeaps.Net/Pages/Casino/TexasHoldem.cshtml @@ -16,18 +16,38 @@ danger is on the label rather than at the end of a scroll — Quick Split down with Common rulings where the other "open something else" link already lives, and the way back to the site up top where a back link goes. *@ + @* The word is its own element so a phone can drop it and keep the arrow, and the + aria-label is what a screen reader gets either way. It contains the visible + text on the sizes that still show it, which is what WCAG 2.5.3 asks of a name + that says more than the label does. *@
- ← Casino + ← Casino

Texas Hold'em Dealer

- Hand #1
- + @* Not the accent any more. It is a shortcut to a sheet, available all night, + and it is never the next step — the bar always has that, and at showdown + the bar says so in as many words. See .actionAdvance in holdem.css. *@ +
+ @* The seat sheet was the only one of the tool's seven overlays with nothing behind + it. On a phone it covers the felt from 250px down and the action bar with it, so + it is modal in everything but appearance — and what stayed lit above it was the + header, with Misdeal, Next Hand and Resolve Pot live under a thumb that was + reaching for a name field. NN/g and Material both say the same thing about this: + a modal sheet with an invisible scrim tells the user they can still touch what + they can still see, and they can't. + + Not an `*Overlay` id, deliberately — Modal watches those and would take the + sidebar panel this becomes on a desktop for a dialog. It is driven entirely by + body.seatSheetOpen, and it is `display: none` above 700px where the panel is + just a panel. *@ +
+
@@ -87,10 +107,21 @@

Turn this on if a person outside the game deals — no seat shows the dealer button, but blinds still rotate automatically each hand.

-

Puts the community cards on the felt and asks for them as each street is dealt. Turn the players’ cards over at showdown and Resolve Pot works out who won and how much, instead of asking you.

+ @* The label used to lead with "Track the board", which is the feature's name + rather than what pressing it does to your evening. What it does is put a + card sheet in front of you at four moments: each street, and the showdown. *@ +

Every time you deal a street, a sheet asks which cards came off the deck, and puts them on the felt. At the showdown it asks for each hand that turns over — then Resolve Pot reads the winner and the amount instead of asking you. Close the sheet at any point to go back to picking the winner yourself.

+ + @* On by default, and the only setting here that is. A dealer has to + opt in to the tool doing more than they asked; they should not have + to opt in to it staying readable. *@ +

Stops the phone dimming and locking between the flop and the turn. It only holds while betting is open, so the screen sleeps as usual during breaks — and the tool says so the first time it takes hold.

+@* The value used to be a `title` attribute, which is a tooltip — and a tooltip is + nothing at all on the device this is used on. Five coloured dots with five number + boxes and no way to tell which one was the $25 without opening the Chip Legend and + matching colours by eye. It is text now. *@ @@ -699,6 +777,8 @@ + @* After feedback.js, because the first lock it takes announces itself with a Toast. *@ + diff --git a/JoshHeaps.Net/Program.cs b/JoshHeaps.Net/Program.cs index 15e164a..b9e6df1 100644 --- a/JoshHeaps.Net/Program.cs +++ b/JoshHeaps.Net/Program.cs @@ -53,6 +53,39 @@ if (!app.Environment.IsDevelopment()) app.UseHttpsRedirection(); +// One spelling of the casino URLs, because the Hold'em tool's offline support depends +// on it and nothing warns you when it breaks. +// +// Razor Pages routing is case-insensitive, so /Casino/TexasHoldem and +// /casino/texasholdem both serve the dealer tool and look identical. A service worker's +// scope is not: the spec matches it as a plain string prefix, so a worker scoped to +// "/casino/" does not control a page at "/Casino/TexasHoldem". Measured, that meant the +// capital-C spelling registered the worker, activated it, logged nothing — and then left +// the cache completely empty, because the page was never controlled and the precache +// handshake it waited on could not resolve. A dealer who bookmarked that URL had a tool +// that looked loaded and had nothing stored for the night the signal went. +// +// So one spelling wins and the other is redirected to it, permanently, before anything +// else in the pipeline gets a look. +// +// Deliberately scoped to /casino rather than applied site-wide. Every file under +// wwwroot/casino and wwwroot/images/casino is already lowercase, so lowercasing a path +// here can never miss a real file on a case-sensitive filesystem — a guarantee that does +// not hold for the rest of the site, and one that /chessHub in particular would break. +app.Use(async (context, next) => +{ + var path = context.Request.Path.Value; + if (path is not null + && path.StartsWith("/casino", StringComparison.OrdinalIgnoreCase) + && path.Any(char.IsUpper)) + { + context.Response.Redirect(path.ToLowerInvariant() + context.Request.QueryString, permanent: true); + return; + } + + await next(); +}); + // .webmanifest isn't in the default MIME map, and an unknown extension isn't served // with the wrong type — it isn't served at all. Without this the Hold'em tool's // manifest 404s, and a 404 manifest is exactly the silent failure that stops a browser diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index 9b53a95..e0ca5b0 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -57,11 +57,31 @@ --line: #3a3a3a; --line-strong: #4a4a4a; - /* Text. */ + /* Text. + These two were picked to clear WCAG AA — several rules in this file still say so + in their own comments, one of them "measured 4.5:1 at #a5a5a5 exactly, so it sits + above that". That was the right instinct with the wrong instrument. WCAG 2's + ratio is a luminance gap, and a luminance gap overstates readability once one of + the colours is near black, which every reading on this page is: the page is + #141414 and the panels are darker than the rows. Tuning to 4.5:1 against a + near-black ground lands on text that passes the audit and still reads as a smudge + across a table in a dim room. + + Re-measured with APCA, which scores the pair for the size and weight it is + actually drawn at: #9a9a9a on a panel came out at Lc 46 and #8a8a8a at Lc 38, + against a floor of Lc 60 for a label and Lc 75 for anything read to make a + decision. Fifty-four separate pieces of text were below their level while passing + AA. These are the values that clear it — muted at Lc ~71, dim at Lc ~59. + + They are lighter than they were, and the hierarchy did not come out of it: --text + is still Lc 95, so the three tiers are 95 / 71 / 59, which is a wider spread than + the 95 / 46 / 38 it replaces. Fading is not the only way to say "secondary", and + on a dark ground it is the most expensive one — size and weight say it without + spending contrast, which is what the seat puck does now. */ --text-bright: #f5f5f5; --text: #eee; - --text-muted: #9a9a9a; - --text-dim: #8a8a8a; + --text-muted: #c8c8c8; + --text-dim: #b4b4b4; /* The one accent. Desaturated against the dark ground — a fully saturated gold vibrates on a near-black surface and stops reading as a highlight. */ @@ -249,19 +269,55 @@ button, (`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. */ +/* Nothing on this page moves under the pointer for anyone who asked it not to. + `:hover` and `:active` together, because they are the same 1px of travel and the + setting does not distinguish them. + + This list used to hold nine `:active` selectors and its membership looked arbitrary, + because it was: it was written to stop a *stuck press state* when a tap turns into a + scroll, and it reached for the reduced-motion query as the nearest available proxy. + Solving one problem with another's tool is how it came to cover `.dealerChip` and + `.raiseQuickBtn` while missing `.actionBtn` — the class on Fold, Call, Raise and Post + blinds & deal, which is to say the most-tapped control in the tool — and every + `:hover` in the file. + + Read as its own rule rather than as a side effect of that one, the membership is not + a judgement call at all: every `:hover` and `:active` transform in the stylesheet + belongs here. reducedMotion.test.js scans for them and fails on any that is missing, + so a new one cannot be added without this being updated. */ @media (prefers-reduced-motion: reduce) { + .holdemBtn:hover, .holdemBtn:active:not(:disabled), .linkBtn:active:not(:disabled), .seatMenuBtn:active:not(:disabled), .seatSheetClose:active:not(:disabled), + .cardSlot:hover, .cardSlot:active, + .pickerCard:hover:not(:disabled), .pickerCard:active:not(:disabled), .dealerChip:active, .raiseQuickBtn:active:not(:disabled), - .actionUndo:active:not(:disabled) { + .actionBtn:active, + .actionUndo:active:not(:disabled), + .toastAction:active, + .toastClose:active, + .potWizardChoice:active { transform: none; } + + /* The seat sheet's disclosure arrows keep their two positions and lose the swing + between them. Not in the list above — nothing here is under a pointer, and the + end state is meaningful: an arrow that pointed the same way open and shut would + have stopped saying anything. + + The scrim loses its fade with it, and the delay goes with the fade: with nothing + to cover, a quarter second of visibility held open is a scrim still painted over + a sheet that has already gone. `none` flips it at once in both directions, which + is what the sheet's own reduced-motion rule concluded for the same reason. */ + .seatSubPanel > summary::before, + #seatSheetScrim { + transition: none; + } } /* ---------- Border-box, everywhere ---------- @@ -366,16 +422,10 @@ button, } } -.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; +/* The word beside the arrow, which a phone drops. Below 700px the link is the arrow + and a 44px box around it; the name a screen reader gets is on the either way. */ +.backLinkWord { + white-space: nowrap; } #holdemHeaderActions { @@ -689,9 +739,23 @@ button, } } +/* The disc is deeper than it was. Its bright stop left the label sitting on a mid green + that measured Lc 38 and — the reading that settles it — 3.79:1, which is a plain AA + failure the audit had never reported because it reads background-color and this is a + gradient over the felt. + Deepening the disc rather than bleaching the label is what keeps the accent: gold on + a dark green is the pairing this whole page is built on, and gold on a mid green is + the one place it wasn't. The pot is also the number read from furthest away, so a + disc that separates harder from the felt is the right answer twice over. + + Both stops, not just the far one. Deepening only the outer stop moved the label by + two points of Lc and no more, because the highlight is at 32%/28% and that is exactly + where the label sits — the reading was being taken on the brightest part of the disc. + The highlight is still there, it is just a lift within a dark disc now rather than a + wash of white over a mid green. */ #potBadge { position: relative; - background: radial-gradient(circle at 32% 28%, rgba(255, 255, 255, 0.08), rgba(0, 0, 0, 0.55)); + background: radial-gradient(circle at 32% 28%, rgba(0, 0, 0, 0.34), rgba(0, 0, 0, 0.78)); border: 1px solid rgba(255, 255, 255, 0.14); border-radius: var(--r-pill); padding: var(--sp-3) var(--sp-5); @@ -702,11 +766,16 @@ button, box-shadow: inset 0 2px 6px rgba(0, 0, 0, 0.6), 0 4px 14px rgba(0, 0, 0, 0.45); } +/* --gold is tuned to sit on the near-black of the panels and the bar. The pot badge is + the one place it lands on green instead, and green is the worst possible ground for + it: measured Lc 38 there against Lc 70-odd everywhere else the same token is used. + The brighter step of the same accent clears it without the badge losing the accent — + white would read as a second amount next to the one underneath it. */ #potBadgeLabel { font-size: var(--fs-xs); letter-spacing: 0.14em; text-transform: uppercase; - color: var(--gold); + color: var(--gold-bright); } #potBadgeAmount { @@ -913,14 +982,29 @@ button, text-decoration-color: rgba(224, 123, 123, 0.75); } +/* An empty seat used to be a 2%-white wash at 0.7 opacity, which is another way of + saying it had no background of its own and whatever the felt was doing behind it + decided how readable its label was. The felt is a gradient with a brown rim, so that + varied by chair: the same #aeaeae chair number measured Lc 28 on the rim, Lc 30 on + the dark green and Lc 33 further in. Text whose contrast depends on where the seat + happens to sit cannot be reasoned about, and the fade was making every part of it + worse at once. + + A ground of its own fixes both. The dashed border and the EMPTY label are what say + "open" — they always were — so the fade was not carrying the meaning, only costing + contrast. It stays louder than seatInactive, which is what the old comment here was + protecting and is still true. + + 0.62 and not the 0.82 this was first written at. Contrast turns out to be almost flat + across that range — Lc 69.6 against the rim at 0.62 versus 71.7 at 0.82, because the + label is light and every ground in that range is dark — so the heavier wash bought + about two points of Lc and cost four dark rectangles sitting on the felt like holes. + Taking the lighter one is free. */ .seat.seatEmpty { - background: rgba(255, 255, 255, 0.02); + background: rgba(18, 18, 18, 0.62); border-style: dashed; - border-color: rgba(255, 255, 255, 0.13); + border-color: rgba(255, 255, 255, 0.22); box-shadow: none; - /* Deliberately louder than seatInactive: an open seat is information a - dealer wants, not something to squint at. */ - opacity: 0.7; } .seatEmptyLabel { @@ -935,16 +1019,17 @@ button, box-shadow: 0 0 0 2px rgba(9, 255, 0, 0.4), 0 4px 14px rgba(0, 0, 0, 0.5); } -/* The chair number is 9px, and on an empty seat it is 9px behind a 0.7 fade: 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. */ +/* The chair number answers "which seat is seat 6?", and it is the smallest text on the + felt. It was set to a hand-picked #aeaeae chosen for being a hair over 4.5:1 — see + the token block for why that bar was the wrong one, and the rule above for why the + fade behind it made the reading meaningless anyway. On the token now, so it moves + with the rest of the page instead of being pinned to a number from one measurement. */ .seatChairNumber { position: absolute; top: 2px; left: 6px; font-size: var(--fs-2xs); - color: #aeaeae; + color: var(--text-muted); } /* Two lines, not an ellipsis. @@ -1296,9 +1381,13 @@ button, color: #eaf3ff; } +/* Lightened a step. Dark-on-gold was the right call and stays; the gold underneath it + was just too deep to carry 9px bold at its bottom stop, which measured Lc 46 while + the small blind's blue was already at Lc 84. Raising the gold is what closes the gap, + and it costs nothing — the badge reads as the same gold chip, only brighter. */ .bbBadge { left: 2px; - background: linear-gradient(180deg, #e6ad4d, #c98a2a); + background: linear-gradient(180deg, #f0c069, #e2a845); color: #241a05; } @@ -1358,6 +1447,17 @@ button, font-size: var(--fs-2xs); } +/* Not wrapping is only half an answer: a line that cannot wrap and still does not fit + paints over the seat next door instead. So on the tightest pucks the mark gives up + most of its letter-spacing, which is 3.6px of the 36 it wanted. + + The spacing is there to stop "ALL IN" reading as money at a glance, and it keeps the + two signals that actually do that job — the warm colour and the bold weight. What it + gives up is the third one, on the only pucks too narrow to afford it. */ +.seatsTight .seatPuckStack.seatPuckAllIn { + letter-spacing: 0.01em; +} + /* The other end of the same idea. A puck past the ordinary 96px ceiling has room the type was never given: the felt grows with the window and the text did not, so a dealer on a 2560px monitor read their stacks at the same 10px as one on a 360px @@ -1414,10 +1514,11 @@ button, 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. */ +/* Grip, Done button and scrim are the sheet's furniture — no reason for any of them + on a desktop sidebar, where the panel is just a panel. */ .seatSheetGrip, -.seatSheetClose { +.seatSheetClose, +#seatSheetScrim { display: none; } @@ -1436,6 +1537,66 @@ button, gap: var(--sp-2); } +/* A label and a field, and the field takes what is left. + `.fieldRow input` caps at 140px, which is the right width in a 320px sidebar of + settings and the wrong one in a sheet that has the whole phone: "Name" sat against + the left edge with 203px of nothing between it and a field a third of the row wide. + The row's height does not change — this is the same 44px control, given the width + the sheet already had. */ +.seatEditorForm .fieldRow input:not([type="checkbox"]) { + flex: 1; + min-width: 0; + max-width: none; +} + +/* The three folded sections. `.subPanel` already draws the rule above and the muted + summary; what these add is the room to hit one with a thumb and the fact on the + right that says whether opening it is worth the tap. */ +.seatSubPanel > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-2); + padding: var(--sp-1) 0; + list-style: none; +} + +/* A flex loses its disclosure triangle in Safari and Chrome alike, so the + marker is drawn here instead — and it is the one piece of this sheet that says + "there is more underneath" before anything is tapped. */ +.seatSubPanel > summary::before { + content: "▸"; + flex: none; + font-size: var(--fs-xs); + color: var(--text-dim); + transition: transform 0.15s ease; +} + +.seatSubPanel[open] > summary::before { + transform: rotate(90deg); +} + +.seatSubPanel > summary::-webkit-details-marker { + display: none; +} + +.seatSubPanelName { + flex: 1; + min-width: 0; +} + +/* The answer, on the shut row. Sized and coloured like the sidebar's panel glances, + because it is the same idea one level down: the one fact worth opening for. */ +.seatGlance { + flex: none; + max-width: 55%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + .seatCardToggles { display: flex; gap: var(--sp-2); @@ -2142,15 +2303,21 @@ button, line-height: 1; } +/* The rule above it is the disclosure's own now, so this no longer draws one of its + own — two lines a few pixels apart was the look of a section that didn't know where + it started. */ .seatContribution { - border-top: 1px dashed var(--line); - padding-top: var(--sp-2); margin-bottom: var(--sp-1); } +/* A grid rather than a wrapping flex row. Five chip colours in a flex row came out + four-and-one, with the fifth stranded on a line of its own and the four above it + ending wherever their widths happened to land — a ragged block for a set of things + that are all the same shape. Columns are as wide as they need to be for a value and + a count, and every cell is the same. */ .chipCounts { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); gap: var(--sp-1); margin-bottom: var(--sp-1); } @@ -2159,17 +2326,32 @@ button, display: flex; align-items: center; gap: var(--sp-1); + min-width: 0; } .chipSwatch { width: 12px; height: 12px; + flex: none; border-radius: 50%; border: 1px solid rgba(255, 255, 255, 0.3); display: inline-block; } +/* What the colour is worth, in the row. Fixed-width and tabular so five of these + stack into a column the eye can run down, rather than five labels each starting + somewhere else. */ +.chipCountValue { + flex: none; + min-width: 2.75rem; + color: var(--text-muted); + font-size: var(--fs-sm); + font-variant-numeric: tabular-nums; +} + .chipCountInput { + flex: 1; + min-width: 0; width: 34px; background: var(--surface-1); border: 1px solid var(--line); @@ -2898,7 +3080,7 @@ body.hasActionBar #holdemApp { border-radius: var(--r-pill); border: 1px solid #414141; background: #262626; - color: #bdbdbd; + color: var(--text-muted); font-family: inherit; font-size: var(--fs-sm); cursor: pointer; @@ -2915,6 +3097,16 @@ body.hasActionBar #holdemApp { } } +/* Which hand this is, at the head of the row that says what hand you are in. Quieter + than the street beside it — the street is read every deal and this is read when + somebody asks — and tabular, because it is a number that ticks over while the dealer + is looking at it. */ +.actionHand { + color: var(--text-dim); + font-variant-numeric: tabular-nums; + font-size: var(--fs-xs); +} + .actionStreet { color: var(--gold); font-weight: 600; @@ -2929,7 +3121,7 @@ body.hasActionBar #holdemApp { } .actionPlayers { - color: #9c9c9c; + color: var(--text-muted); font-variant-numeric: tabular-nums; } @@ -2941,7 +3133,7 @@ body.hasActionBar #holdemApp { .actionWho { margin-left: auto; - color: #9c9c9c; + color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -3253,7 +3445,7 @@ body.hasActionBar #holdemApp { padding: 0 var(--sp-1); border-radius: var(--r-sm); border: 1px solid currentColor; - opacity: 0.75; + opacity: 0.9; font-family: inherit; font-size: var(--fs-2xs); line-height: 1.4; @@ -3264,7 +3456,12 @@ body.hasActionBar #holdemApp { /* 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. */ + read under the threshold. They're still quieter than the label they sit beside. + + Raised again from 0.75 to 0.9 for the same reason the text tokens moved: clearing + 4.5:1 was never the same thing as being readable on this ground, and a fade is the + most expensive way to say "quieter" when the ground is already near black. The + keycap's border and its size still say it, and both are free. */ .actionKey { position: absolute; top: 5px; @@ -3272,7 +3469,7 @@ body.hasActionBar #holdemApp { padding: 0 var(--sp-1); border-radius: var(--r-sm); border: 1px solid currentColor; - opacity: 0.75; + opacity: 0.9; font-family: inherit; font-size: var(--fs-2xs); line-height: 1.4; @@ -3312,10 +3509,29 @@ body.hasActionBar #holdemApp { color: var(--gold-bright); } +/* Deal Flop, Deal Turn, Deal River, "Showdown — resolve pot", "Award pot to Dee". + Every one of them is the only thing the bar is offering at the moment it appears, and + every one of them was the quietest control on the screen. + + Measured over a whole hand, seventeen steps: the accent green was on two controls at + thirteen of them — the bar's Call and the header's Resolve Pot, painted the identical + gradient at opposite ends of a phone — and at the other four it was on the header + alone while the bar held the only button there was to press. At showdown that is as + wrong as it gets: "Showdown — resolve pot" is the most consequential tap in the hand + and it was blue-grey, while a shortcut in the corner that had looked exactly the same + for the previous five minutes was green. + + So the accent goes where the next step is. The same green as Call, deliberately, + because the rule worth learning is "green is the thing to do next" and not "green is + a player's action" — and the two can never be confused, because they never appear + together: renderStreetDone and renderHandOver each build their own row and neither + ever renders alongside the fold/call/raise triad. Checked over a full hand, and + checked again by the audit on every viewport. The fold red and the raise gold are + untouched, and the labels and the shortcut glyph still tell them apart. */ .actionAdvance { - background: linear-gradient(180deg, #2b3f57, #1b2a3a); - border-color: #3d5f83; - color: #dceaf8; + background: linear-gradient(180deg, #16783a, #0d5225); + border-color: #1e8449; + color: #ecffef; } /* ---------- Raise pad ---------- */ @@ -3420,11 +3636,38 @@ body.hasActionBar #holdemApp { } } +/* One line, always — the same rule every design system gives a badge, and the one + thing on this puck that was never told it. + + A stack figure has no spaces in it, so it could never wrap; it overflows instead, and + the puckAmountLong/Huge sizes below exist to stop it. "ALL IN" has a space, and that + space cost the two smallest phones their seat layout. + + Measured at 360x640 with twelve players all-in: "ALL IN" wanted 36px and had 34. It + broke onto a second line, an 11px line became 22, and the puck went from 55px tall to + 66. Height is exactly what is scarce at the ends of the ring — the seats there are + separated vertically, so a puck 11px taller is a puck 11px further into its + neighbour. The layout search then walked its candidate widths, found none that + cleared, and settled for the narrowest, which is also the one that wraps hardest. + + Two pixels, and every seat on the felt paid for them. */ +/* The stack is not secondary text, and it had been styled as some for as long as this + file has existed: muted grey under a name set in --text at a heavier weight. Read + back the other way, that says a dealer needs the name more than the number — and + during a hand the opposite is true. The name is looked up once, to find the chair; + the stack is read on every single action, because it is what decides whether a player + can cover the bet. It measured Lc 44 on the puck, the dimmest thing on the felt that + anyone has to read. + + Set in --text now. The name is still the louder of the two — it is bigger and it is + 600 — so the hierarchy is intact; it is just carried by size and weight instead of by + fading the number underneath it. */ .seatPuckStack { font-size: var(--fs-2xs); - color: var(--text-muted); + color: var(--text); font-variant-numeric: tabular-nums; margin-top: var(--sp-0); + white-space: nowrap; } /* "ALL IN" takes the stack line's place, so it has to not read as a stack. Wider @@ -3533,6 +3776,33 @@ body.hasActionBar #toastHost { transform: translateY(0); } +/* Reduced motion keeps the fade and loses the travel. + + This file already honoured the setting in three places, and all three were the wrong + size of thing: a 2px press-down on a button, a checkbox's tick, and the acting seat's + pulse. What it never covered was the motion that actually moves something across the + screen — and for anyone who gets motion sick, a 12px slide is the part that matters, + not a button dipping under a thumb. + + Deleting the animation outright is the wrong answer too. A toast that simply appears, + already in place, reads as a rendering glitch rather than as news, and the same + guidance that says to cut the movement says to keep something: fade it instead, so + the interface still feels like it responded. */ +@media (prefers-reduced-motion: reduce) { + #toastHost { + transition: none; + } + + .toast { + transform: none; + transition: opacity 0.18s ease; + } + + .toast.toastVisible { + transform: none; + } +} + .toastText { flex: 1; min-width: 0; @@ -4138,26 +4408,92 @@ body.hasActionBar #toastHost { padding: var(--sp-4) var(--sp-3) calc(var(--actionBarHeight, 0px) + 5rem + env(safe-area-inset-bottom, 0px)); } + /* ---- One row, the way a phone on its side already had it ---- + + The header stood 120px tall on a 390px phone and only 46 of that was a control. + The other 74 were a 22px title with a gold rule under it, a back link and a gold + "Hand #1" pill — read once, at the top of a screen whose subject is the green + table underneath. Sixteen percent of the window, before the felt started. + + A phone held sideways has solved this since the landscape block went in: one row, + title clipped, arrow and buttons. Upright never got the same answer, and upright + is how this tool is held. It is 44px now, and the felt is 76px taller — 496 to + 572, about fifteen percent more table on the same screen. + + Nothing left the page. The title is still the document's

, clipped rather + than removed, so it is still the heading a screen reader lands on and still what + the browser tab and the installed app are called. The hand number moved into the + action bar's status row, where the street, the pot and the player count already + say what hand you are in. And the arrow keeps its 44px box; only the word "Casino" + is gone, which is what every phone does with a back link. */ #holdemHeader { + flex-wrap: nowrap; + align-items: center; margin-bottom: var(--sp-4); gap: var(--sp-2); } + /* So the arrow is a flex item of the header itself rather than a block inside a + block — there is nothing else left in this group once the title is clipped. */ + #holdemHeaderLeft { + display: contents; + } + + /* Clipped, not removed: `display: none` would take the page's only

off the + accessibility tree, and a page whose heading disappears below 700px is a page + with no heading on the device it is mostly read on. */ #holdemHeader h1 { - font-size: var(--fs-xl); + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; } + .backLinkWord { + display: none; + } + + /* 44 square, not 32. The word used to carry the width and the padding only had to + make up the height; with the word gone the arrow is the whole target, and the + layout sweep caught it at 31x43 on every touch viewport there is. It costs + nothing — the arrow and the three buttons come to 323px of the 336 a 360px phone + has, which is the narrowest screen in the sweep. */ + .holdemBackLink { + flex: none; + justify-content: center; + min-width: 44px; + /* The negative margins that gave the hit area back to a two-row header would + now pull the arrow out of a row it is the first item of. */ + margin: 0; + font-size: var(--fs-md); + } + + /* Takes the row rather than shrink-wrapping to the right of it. Sized to their + labels the three buttons left 78px of nothing where the title used to be, and + that gap buys nothing — spent on the buttons instead it is about 26px of extra + target each, on the controls a dealer presses once a hand with a thumb. */ #holdemHeaderActions { - width: 100%; - gap: var(--sp-2); + flex: 1 1 auto; + flex-wrap: nowrap; + min-width: 0; + gap: var(--sp-1); } + /* Grow into that spare width, never shrink below the label. `flex: 1 1 0` would + divide the row into equal thirds and squeeze "Next Hand ⟳" — 105px of text — into + 99, and these are `white-space: nowrap`, so what gives is the text. An `auto` + basis makes the label the floor and the spare width the bonus. */ #holdemHeaderActions .holdemBtn { flex: 1 1 auto; justify-content: center; min-height: 46px; - padding: var(--sp-2) var(--sp-3); - font-size: var(--fs-base); + padding: var(--sp-2) var(--sp-2); + font-size: var(--fs-sm); + white-space: nowrap; } /* Keeps every puck inside the viewport: the ring is drawn within this box, @@ -4198,6 +4534,50 @@ body.hasActionBar #toastHost { /* ---- Seat editor as a bottom sheet ---- */ + /* What the sheet is in front of. + Lighter than the 0.72 the other six overlays use, on purpose: those replace the + table, and this one is a form *about* something on it. The chair being edited + should still read through the dim, and at 0.55 it does — the felt stays a felt + and the header behind it stops looking like somewhere to put a thumb. */ + /* Put away with visibility, not with opacity — the same lesson the sheet above it + already learned about transforms, and it was caught the same way. A fixed, + full-window box at `opacity: 0` is invisible and still entirely there: the layout + sweep read it as furniture standing 640px tall at the bottom of a 640px phone, on + every screen and in every state, and asked scroll-padding to reserve the whole + window for it. Hidden is hidden — off the accessibility tree, out of hit testing, + and out of that measurement. + + The delay is what keeps the fade-out on screen for its own quarter second: + visibility flips at the end of the 0.25s going out, and immediately coming in. */ + #seatSheetScrim { + display: block; + position: fixed; + inset: 0; + z-index: 59; + background: rgba(0, 0, 0, 0.55); + opacity: 0; + visibility: hidden; + transition: opacity 0.25s ease, visibility 0s linear 0.25s; + } + + body.seatSheetOpen #seatSheetScrim { + opacity: 1; + visibility: visible; + transition: opacity 0.25s ease, visibility 0s; + } + + /* And the toast steps over the sheet, the way it already steps over the action + bar. Both are fixed to the bottom edge, and the sheet is the taller of the two + while it is open — a toast left at the bar's height lands in the middle of the + seat form, with a live Undo and a live × sitting on top of the fields. Written + after the `body.hasActionBar` rule it overrides: equal specificity, later wins. + + The height is measured and published by Render.watchSeatSheetHeight, so it + follows the sheet when a section is unfolded rather than assuming a size. */ + body.seatSheetOpen #toastHost { + bottom: calc(var(--seatSheetHeight, 0px) + 0.75rem + env(safe-area-inset-bottom, 0px)); + } + #seatEditorPanel { position: fixed; left: 0; @@ -4282,6 +4662,20 @@ body.hasActionBar #toastHost { text-align: center; } + /* A section's name is a control, and a thumb has to be able to land on it without + aiming. Same 44px floor as every field below it, and the same 15px the toggles + and the total are set in — a summary at 12px reads as a caption for the row + above rather than as the thing you press. */ + .seatEditorForm .seatSubPanel > summary { + min-height: 44px; + font-size: var(--fs-md); + color: var(--text); + } + + .seatEditorForm .seatGlance { + font-size: var(--fs-base); + } + .seatEditorForm .chipCounts { gap: var(--sp-2); } @@ -4431,6 +4825,30 @@ body.hasActionBar #toastHost { } } +/* The seat sheet, for anyone who asked not to be moved. + + This is the largest movement the tool makes: on a phone the seat editor is a bottom + sheet that travels the full height of itself, up from off-screen, every time a dealer + taps a chair — which on a busy table is most of the taps they make all night. + + Placed after the 700px block rather than inside it because the rule it overrides is an + ID selector: at equal specificity the later one wins, and a copy sitting earlier in + the file would have looked right and done nothing. + + The visibility delay has to go with the transform, and that is the part worth being + careful about. It exists so the slide-out stays on screen for its own 0.25s; with the + slide gone, the same delay leaves a sheet that has already jumped away still painted + for a quarter of a second. So it flips at once here, in both directions. */ +@media (max-width: 700px) and (prefers-reduced-motion: reduce) { + #seatEditorPanel { + transition: visibility 0s linear 0s; + } + + body.seatSheetOpen #seatEditorPanel { + transition: visibility 0s; + } +} + /* ---------- 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% @@ -4494,8 +4912,12 @@ body.hasActionBar #toastHost { 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. */ + reader and for the browser's own outline. + + The upright block above now does all of this too, and the hand pill it used to + make room for has gone to the action bar's status row for both of them. What is + left here is what upright does not need: this query reaches up to 900px wide, + where the 700px block does not, so a phone held sideways still needs to be told. */ #holdemHeader { margin-bottom: var(--sp-2); gap: var(--sp-1); @@ -4503,6 +4925,10 @@ body.hasActionBar #toastHost { align-items: center; } + #holdemHeaderLeft { + display: contents; + } + #holdemHeader h1 { position: absolute; width: 1px; @@ -4514,17 +4940,28 @@ body.hasActionBar #toastHost { white-space: nowrap; } - .handCounterBadge { - margin-top: 0; + .backLinkWord { + display: none; + } + + .holdemBackLink { + flex: none; + justify-content: center; + min-width: 44px; + margin: 0; + font-size: var(--fs-md); } /* Tightened across, never down: the buttons keep the 44px a thumb needs. */ #holdemHeaderActions { flex-wrap: nowrap; + margin-left: auto; + min-width: 0; gap: var(--sp-1); } #holdemHeaderActions .holdemBtn { + flex: 0 1 auto; padding-left: var(--sp-2); padding-right: var(--sp-2); font-size: var(--fs-sm); @@ -4845,6 +5282,14 @@ body.hasActionBar #toastHost { right: var(--actionRailW); border-top-right-radius: var(--r-lg); } + + /* The scrim stops where the sheet stops. Fold, Call and Raise live in the rail + down the right-hand side when a phone is on its side, and a scrim that reached + across it would be dimming — and swallowing the taps on — the one part of the + screen the sheet was deliberately kept clear of. */ + #seatSheetScrim { + right: var(--actionRailW); + } } /* ---------- Focus is brought to rest where it can be seen ---------- diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js index 9d9a7b8..46fa16d 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/actionBar.js @@ -277,10 +277,29 @@ const ActionBar = { return `${name} to act, ${cost}. Pot ${Money.format(Betting.potSoFar(state))}.`; }, + // Pure, so the wording can be checked without a page to hang it on — the same reason + // Render.panelGlances is. A table that has never dealt is on hand one, not hand + // zero and not hand undefined, and that fallback is the whole of what there is to + // get wrong here. + handLabel(state) { + return `#${(state && state.handNumber) || 1}`; + }, + renderStatus() { const row = document.createElement("div"); row.className = "actionStatus"; + // Which hand this is. It used to be a gold pill beside the page title, and it + // was the wrong fact in the wrong place: a counter that changes once a hand, + // read after the fact, holding a line of a 390px phone's header all night. It + // belongs with the street, the pot and the player count — the other three + // things that say what hand you are in — and here it costs nothing, because + // this row was already drawn. + const hand = document.createElement("span"); + hand.className = "actionHand"; + hand.textContent = this.handLabel(this.state); + row.appendChild(hand); + const street = document.createElement("span"); street.className = "actionStreet"; street.textContent = Betting.streetLabel(this.state.street); @@ -308,8 +327,12 @@ const ActionBar = { if (Betting.isRunningOut(this.state) && !Betting.isHandOver(this.state)) { const runout = document.createElement("span"); runout.className = "actionRunout"; - const stillIn = Betting.liveSeats(this.state).length; - runout.textContent = `All-in — running out the board (${stillIn} players)`; + // It used to end "(12 players)", which is the number the chip immediately to + // its left already says, in the same row, at the same moment. Saying it + // twice in one breath reads like two different figures being quoted, and on + // a 360px phone it cost the sentence a second line — which the felt paid + // for, on the one table shape that has twelve seats to fit. + runout.textContent = "All-in — running out the board"; row.appendChild(runout); } @@ -609,12 +632,17 @@ const ActionBar = { // Four is what the bar can hold without taking the felt with it — see // buildPotBreakdown for the measurements, and uiAudit's fixedFurniture check // for the ceiling this is keeping the bar under. + // + // Two, once the run-out has started, for the reason spelled out over there: the + // list is decision support, and during a run-out there is no decision left to + // support. The felt gets the difference, on the one table shape that has the + // most seats to fit into it. return Render.buildPotBreakdown( PotCalculator.layers(contributions), PotCalculator.uncalledReturns(contributions), id => nameById.get(id), null, - { maxLayers: 4 } + { maxLayers: 4, summarise: Betting.isRunningOut(this.state) } ); }, @@ -1009,7 +1037,7 @@ const ActionBar = { if (!field) return; const panel = field.closest("details"); if (panel) panel.open = true; - field.scrollIntoView({ block: "center", behavior: "smooth" }); + Render.bringIntoView(field, { block: "center" }); field.focus(); field.select(); })); @@ -1140,6 +1168,11 @@ const ActionBar = { // happens, too — say the straddle, then deal. this.straddleButtons(preview).forEach(button => row.appendChild(button)); row.appendChild(this.button("Post blinds & deal", "actionDeal", () => { + // The first hand is the moment this stops being a setup screen and starts + // being a night somebody could lose. It is also a tap, which is what every + // browser wants the "keep my data" request to come from — see + // requestDurable() in state.js. Harmless and instant after the first time. + if (this.hooks.keepStorage) this.hooks.keepStorage(); this.commit("dealing the hand", () => Betting.beginHand(this.state)); })); wrap.appendChild(row); @@ -1169,6 +1202,20 @@ const ActionBar = { return line; }, + // The cards come over before anybody is paid. + // + // Same shape as the street button above: when the dealer has asked to be asked, the + // moment they press this is the moment they are holding the hands, so the sheet + // opens here rather than leaving them to find the seat editor for each player with + // the pot already on the table. Off by default, and unchanged when it's off. + toShowdown() { + if (this.state.trackBoard && typeof Render !== "undefined") { + Render.openShowdownPicker(() => this.hooks.resolvePot()); + return; + } + this.hooks.resolvePot(); + }, + renderStreetDone() { const wrap = document.createElement("div"); @@ -1184,7 +1231,7 @@ const ActionBar = { row.className = "actionButtons"; row.appendChild(this.button(label, "actionAdvance", () => { if (isRiver) { - this.hooks.resolvePot(); + this.toShowdown(); return; } this.commit(`dealing the ${Betting.streetLabel(Betting.STREETS[Betting.STREETS.indexOf(this.state.street) + 1]).toLowerCase()}`, () => Betting.closeStreet(this.state)); @@ -1216,7 +1263,7 @@ const ActionBar = { row.appendChild(this.button( winner ? `Award pot to ${winner}` : "Resolve pot", "actionAdvance", - () => this.hooks.resolvePot() + () => this.toShowdown() )); wrap.appendChild(row); return wrap; diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js index 16c9ad4..4831f7b 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/cardPicker.js @@ -68,8 +68,20 @@ const CardPicker = { this.state.seats[this.seatIndex].cards[index] = card; }, - close() { + // Why the sheet is closing, because one caller needs to tell the difference. + // + // "filled" is the sheet finishing its own job — the last empty slot it was offering + // got a card. "dismissed" is the dealer leaving: Done, Escape, or a tap on the scrim. + // A single seat's cards don't care which. A showdown does: it walks the players one + // at a time, and the two answers are "next player" and "stop asking". + // + // The hooks are dropped before the callback runs, so a caller that opens the sheet + // again from inside `onDone` installs its own and this close can't fire twice. + close(reason = "dismissed") { document.getElementById("cardPickerOverlay").hidden = true; + const done = this.hooks && this.hooks.onDone; + this.hooks = null; + if (done) done(reason); }, // Every card already dealt somewhere — in front of a player or face up in the @@ -110,7 +122,7 @@ const CardPicker = { this.render(); return; } - this.close(); + this.close("filled"); }, clearSlot() { diff --git a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js index 9623219..cdba3f3 100644 --- a/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js +++ b/JoshHeaps.Net/wwwroot/js/casino/holdem/main.js @@ -5,6 +5,32 @@ document.addEventListener("DOMContentLoaded", () => { // `hidden` attribute they all already toggle. Modal.mount(); + // ---- A scroll is not an edit ---- + // + // A focused `` is a spinbutton, and a browser lets the mouse + // wheel drive it. Every other way of driving one is deliberate: the arrow keys, the + // little stepper arrows, typing. Scrolling is the one actuation nobody means — the + // dealer is reaching for another panel and the cursor happens to be over a field + // they touched a moment ago. + // + // Measured on the running page, at 1280x900 with the sidebar open. Focus the small + // blind, scroll: $5 becomes $5.50. Focus a seat's stack, scroll: $500 becomes $501. + // Both are written straight through to the saved table, because these fields save on + // `input` like everything else here. Nothing is announced, nothing looks wrong, and + // the tool has invented a chip. + // + // Blur rather than preventDefault. Cancelling the event would stop the value moving + // and stop the page moving with it, which turns a silent bug into a sidebar that + // mysteriously will not scroll. Dropping focus lets the scroll do what the dealer + // asked for and leaves the number alone — and losing focus on a field you have just + // scrolled away from is honest rather than surprising. + // + // Captured at the document, so it covers the fields the seat editor and the chip + // legend build on the fly as well as the ones in the markup. + document.addEventListener("wheel", event => { + if (Money.wheelWouldEdit(event.target, document.activeElement)) event.target.blur(); + }, { passive: true }); + // Undo works by swapping the whole table back to a snapshot. `state` is handed out // by reference to every module at mount time, so it has to be refilled in place — // reassigning the variable would leave the renderer and the betting bar pointing at @@ -364,6 +390,7 @@ document.addEventListener("DOMContentLoaded", () => { save: hooks.save, rerender: hooks.rerender, resolvePot: openPotWizard, + keepStorage: () => askToKeepStorage(), // The first thing a dealer does on a table nobody is sitting at. Opens the // seat editor on the first free chair — the same panel tapping the seat gives @@ -372,7 +399,7 @@ document.addEventListener("DOMContentLoaded", () => { const index = state.seats.findIndex(seat => !HoldemState.seatInPlay(seat)); Render.toggleSeatEditor(index < 0 ? 0 : index); const panel = document.getElementById("seatEditorPanel"); - if (panel) panel.scrollIntoView({ block: "nearest", behavior: "smooth" }); + Render.bringIntoView(panel, { block: "nearest" }); }, beginAction: label => { @@ -769,6 +796,27 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("closeSeatEditorBtn").addEventListener("click", () => Render.closeSeatEditor()); + // The two ways out of the sheet that every other overlay in the tool already had. + // + // Modal.mount() covers the six whose ids end in "Overlay"; this one cannot join + // them, because above 700px it is not a sheet at all — it is the sidebar panel the + // seat's fields live in, and a dialog is the wrong thing for Modal to make of it. + // So the two behaviours it was missing are wired here against the same state the + // scrim is drawn from. + document.getElementById("seatSheetScrim") + .addEventListener("click", () => Render.closeSeatEditor()); + + // Not in capture, and behind Modal's own check: a card picker opened *from* the + // sheet is on top of it, and Escape there means "stop picking a card", not "throw + // the seat away". Modal stops that event before this listener ever sees it; the + // guard is for the case where it hasn't, and costs nothing. + document.addEventListener("keydown", event => { + if (event.key !== "Escape") return; + if (Modal.open.length > 0) return; + if (!document.body.classList.contains("seatSheetOpen")) return; + Render.closeSeatEditor(); + }); + document.getElementById("addChipsConfirmBtn").addEventListener("click", () => AddChips.confirm()); document.getElementById("addChipsCancelBtn").addEventListener("click", () => AddChips.close()); document.getElementById("addChipsOverlay").addEventListener("click", e => { @@ -851,7 +899,7 @@ document.addEventListener("DOMContentLoaded", () => { // costs a screen reader the sentence. if (section.dataset.short) jump.setAttribute("aria-label", heading.textContent); jump.addEventListener("click", () => { - section.scrollIntoView({ block: "start", behavior: "smooth" }); + Render.bringIntoView(section, { block: "start" }); // The heading is what the dealer is now reading, so that is where the // keyboard and a screen reader should be too. heading.setAttribute("tabindex", "-1"); @@ -1001,6 +1049,74 @@ document.addEventListener("DOMContentLoaded", () => { { duration: 12000 }); } + // ---- Keeping the screen on ---- + // + // The lock itself is taken and dropped in renderAll, because that is the one place + // every start and end of a hand already passes through. All that lives here is the + // dealer's say in it: the toggle, and the way out offered by the toast the first + // time it takes hold. + WakeLock.hooks = { + turnOff: () => { + state.keepScreenAwake = false; + hooks.save(state); + Render.renderAll(); + Toast.show("The screen will sleep as usual. Table Setup has it if you want it back."); + }, + }; + WakeLock.start(() => state); + + document.getElementById("keepScreenAwakeToggle").addEventListener("change", e => { + state.keepScreenAwake = e.target.checked; + hooks.save(state); + // renderAll does the acquiring and releasing, so turning this off mid-hand + // drops the lock in the same tick rather than at the end of the hand. + Render.renderAll(); + }); + + // ---- Asking the browser to keep the night ---- + // + // Reported rather than announced. A dealer opening Save & reset is already thinking + // about losing the table, and that is the only moment this is worth a sentence. + const showStorageDurability = async () => { + const hint = document.getElementById("storageDurabilityHint"); + const button = document.getElementById("keepStorageBtn"); + if (!hint || !button) return; + + if (!HoldemState.supportsDurableStorage()) { + // Nothing to say and nothing to offer. A browser this old still keeps the + // table exactly as well as it did yesterday. + hint.hidden = true; + button.hidden = true; + return; + } + + const durable = await HoldemState.isDurable(); + hint.hidden = false; + hint.textContent = durable + ? "This browser has promised to keep it: the table survives the phone running short on space." + : "This browser may clear the table on its own if the phone runs short on space."; + button.hidden = durable; + }; + + // Fires on the first deal of every session, which costs nothing after the first + // time it is granted — persisted() is checked before persist() is asked for. + const askToKeepStorage = async () => { + await HoldemState.requestDurable(); + showStorageDurability(); + }; + + document.getElementById("keepStorageBtn").addEventListener("click", async () => { + const granted = await HoldemState.requestDurable(); + showStorageDurability(); + // Said either way, because a button that sometimes does nothing visible is a + // button a dealer presses four more times. + Toast.show(granted + ? "This browser will keep the table now." + : "This browser wouldn't promise. Save the table to a file if tonight matters."); + }); + + showStorageDurability(); + // ---- Working with no signal ---- // // The night already survives anything — it lives in localStorage — but the page @@ -1014,33 +1130,78 @@ document.addEventListener("DOMContentLoaded", () => { // cannot drift from the markup, which is the way a hand-kept one always fails: it // stays right until somebody adds a script tag, and then the tool is quietly // offline-broken until a dealer finds out the hard way. - const registerWorker = () => { + const MANIFEST_URL = "/casino/holdem.webmanifest"; + + // The icons are the one part of the shell the document doesn't mention: the browser + // reads them out of the manifest itself, so no or