From 073ba9a2529a3e1ef3769505234f022012986b25 Mon Sep 17 00:00:00 2001 From: JoshHeaps Date: Tue, 1 Sep 2026 03:11:39 -0600 Subject: [PATCH] Make every tap in the dealer land instantly on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four measured problems, all of which only show up under a thumb. Every tap waited on the browser (53 of 53 controls) Not one interactive element set `touch-action`, so a mobile browser held every tap for ~300ms to see whether a second one was coming that would mean "zoom in". That is on the fold button, the call button, every seat, every card slot. Worse, this tool uses double-tap on a seat as the fold shortcut — which *is* the browser's zoom gesture. The dealer's fastest shortcut was fighting the page zooming under their thumb. A single seat tap cost ~300ms of browser hesitation before the app's own 220ms double-tap window even started. `touch-action: manipulation` everywhere keeps panning and pinch-zoom and drops only double-tap-to-zoom, which is precisely the one that was in the way. Press feedback was the OS grey box (1 `:active` rule in 2385 lines) Only `.actionBtn` had a pressed state. Everything else — seats, header buttons, card slots, picker cards, menu rows, toasts, wizard choices — relied on the translucent grey rectangle the browser paints on tap. So the tap highlight couldn't just be switched off; the controls needed real pressed states first. They press down 1px and brighten, in 0.04s, matching the action bar that already did. Then the grey box goes. Hover stuck after every tap (21 unguarded rules) `:hover` has no meaning on touch, but it still applies — and stays applied until you tap elsewhere. Tapping "Next hand" left it lifted and lightened as though still under a cursor, and it would have fought the new pressed states directly. All of them are behind `@media (hover: hover)` now. The one rule that shared a block with `:focus-visible` was split so keyboard focus survives. The notes field zoomed the page (iOS) `#tableNotes` was 13.3px. iOS zooms the viewport when a field under 16px takes focus, and the dealer is then pinching back out to see the table. Every other input here was already 16px; this was the straggler. Seats deliberately have no `:active` rule Worth writing down because it nearly went in. A seat's `transform` is what positions it around the felt, so a pressed state setting `transform` drops the `translate(-50%, -50%)` and throws every pressed seat ~38px off its spot. Validation caught it: seats already dip via `.seatPressing`, which `pointerdown` adds in render.js — earlier than `:active` resolves, and it restates the translate correctly. Validated with Playwright at 390x844: 53/53 controls off `touch-action: auto` and 0 tap-highlighted across the table, all four overlays and Quick Split; text fields still selectable and buttons not; 14/14 hover rules gated; presses measured live via mouse-down (fold reads translateY(1px)/brightness(1.3), a seat holds its translate with 2px of drift from the shrink alone). Hand played through the UI including a double-tap fold. No console errors. Unit tests 58/58. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015bnp7xKUPFFSpwkYNFaPgb --- JoshHeaps.Net/wwwroot/css/casino/holdem.css | 249 +++++++++++++++--- .../wwwroot/css/casino/quicksplit.css | 82 +++++- 2 files changed, 284 insertions(+), 47 deletions(-) diff --git a/JoshHeaps.Net/wwwroot/css/casino/holdem.css b/JoshHeaps.Net/wwwroot/css/casino/holdem.css index c4579b5..c01911a 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/holdem.css +++ b/JoshHeaps.Net/wwwroot/css/casino/holdem.css @@ -6,6 +6,148 @@ body { 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; + } +} + #holdemApp { max-width: 1400px; margin: 0 auto; @@ -85,10 +227,12 @@ body { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.05); } -.holdemBtn:hover { - background: linear-gradient(180deg, #383838, #2a2a2a); - border-color: #4a4a4a; - transform: translateY(-1px); +@media (hover: hover) { + .holdemBtn:hover { + background: linear-gradient(180deg, #383838, #2a2a2a); + border-color: #4a4a4a; + transform: translateY(-1px); + } } .holdemBtnAccent { @@ -97,8 +241,10 @@ body { color: #ecffef; } -.holdemBtnAccent:hover { - background: linear-gradient(180deg, #1a8a44, #0f602c); +@media (hover: hover) { + .holdemBtnAccent:hover { + background: linear-gradient(180deg, #1a8a44, #0f602c); + } } .holdemBtnDanger { @@ -107,8 +253,10 @@ body { color: #ffd6d6; } -.holdemBtnDanger:hover { - background: linear-gradient(180deg, #572525, #401818); +@media (hover: hover) { + .holdemBtnDanger:hover { + background: linear-gradient(180deg, #572525, #401818); + } } .holdemBtnGhost { @@ -430,11 +578,16 @@ body { transition: opacity 0.15s ease; } -.seat:hover .dealerChip, .dealerChip:focus-visible { opacity: 1; } +@media (hover: hover) { + .seat:hover .dealerChip { + opacity: 1; + } +} + .dealerChip.active { background: radial-gradient(circle at 35% 30%, #ffe9a8, #f5d547 55%, #cba428 100%); border-color: #a9782f; @@ -567,9 +720,11 @@ body { transition: transform 0.12s ease, box-shadow 0.12s ease; } -.cardSlot:hover { - transform: translateY(-1px); - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); +@media (hover: hover) { + .cardSlot:hover { + transform: translateY(-1px); + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.4); + } } .cardSlot.cardRed { @@ -743,8 +898,10 @@ body { cursor: pointer; } -.linkBtn:hover { - color: #d8b46a; +@media (hover: hover) { + .linkBtn:hover { + color: #d8b46a; + } } #rulingsOverlay { @@ -909,9 +1066,11 @@ body { transition: all 0.12s ease; } -.seatMenuBtn:hover { - border-color: #4a4a4a; - background: linear-gradient(180deg, #383838, #2a2a2a); +@media (hover: hover) { + .seatMenuBtn:hover { + border-color: #4a4a4a; + background: linear-gradient(180deg, #383838, #2a2a2a); + } } .seatMenuBtn.menuDanger { @@ -1037,8 +1196,10 @@ body { transition: transform 0.1s ease; } -.pickerCard:hover:not(:disabled) { - transform: translateY(-1px); +@media (hover: hover) { + .pickerCard:hover:not(:disabled) { + transform: translateY(-1px); + } } .pickerCard.cardRed { @@ -1217,8 +1378,10 @@ body { font-size: 0.78rem; } -.subPanel summary:hover { - color: #c5c5c5; +@media (hover: hover) { + .subPanel summary:hover { + color: #c5c5c5; + } } .subPanelBody { @@ -1395,6 +1558,10 @@ body { padding: 0.5rem; 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 ---------- */ @@ -1456,10 +1623,12 @@ body.hasActionBar #holdemSidebar { white-space: nowrap; } -.actionUndo:hover { - background: #333; - border-color: #4f4f4f; - color: #eee; +@media (hover: hover) { + .actionUndo:hover { + background: #333; + border-color: #4f4f4f; + color: #eee; + } } .actionStreet { @@ -1600,9 +1769,11 @@ body.hasActionBar #holdemSidebar { transition: all 0.12s ease; } -.actionBtn:hover { - border-color: #4a4a4a; - background: linear-gradient(180deg, #383838, #2a2a2a); +@media (hover: hover) { + .actionBtn:hover { + border-color: #4a4a4a; + background: linear-gradient(180deg, #383838, #2a2a2a); + } } .actionBtn:active { @@ -1831,8 +2002,10 @@ body.hasActionBar #toastHost { cursor: pointer; } -.toastAction:hover { - background: rgba(216, 180, 106, 0.22); +@media (hover: hover) { + .toastAction:hover { + background: rgba(216, 180, 106, 0.22); + } } .toastClose { @@ -1848,9 +2021,11 @@ body.hasActionBar #toastHost { cursor: pointer; } -.toastClose:hover { - background: #333; - color: #ddd; +@media (hover: hover) { + .toastClose:hover { + background: #333; + color: #ddd; + } } /* ---------- Confirmation dialog ---------- */ @@ -1985,9 +2160,11 @@ body.hasActionBar #toastHost { transition: border-color 0.12s ease, background 0.12s ease; } -.potWizardChoice:hover { - border-color: #555; - background: #191919; +@media (hover: hover) { + .potWizardChoice:hover { + border-color: #555; + background: #191919; + } } .potWizardChoice input { diff --git a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css index 157f9c9..2cc5a6b 100644 --- a/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css +++ b/JoshHeaps.Net/wwwroot/css/casino/quicksplit.css @@ -9,6 +9,58 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +/* Same touch handling as the dealer table — see the note there. This page is the one + a dealer opens mid-hand to chop a pot, so the ~300ms the browser spends deciding + whether a tap was a double-tap-to-zoom is the last thing it needs. */ +button, +[role="button"], +input, +select, +textarea, +a { + touch-action: manipulation; +} + +button, +[role="button"], +input, +select, +textarea, +a { + -webkit-tap-highlight-color: transparent; +} + +/* Not on the fields — a dealer typing a pot amount still needs to select it. */ +button, +[role="button"] { + -webkit-user-select: none; + user-select: none; +} + +.clearPotBtn:active:not(:disabled), +.choiceChip:active:not(:disabled), +.stepBtn:active:not(:disabled), +.splitBtn:active:not(:disabled), +.splitBtnGhost:active:not(:disabled), +.unevenFoldBtn:active:not(:disabled), +.unevenRemoveBtn:active:not(:disabled) { + transform: translateY(1px); + filter: brightness(1.25); + transition-duration: 0.04s; +} + +@media (prefers-reduced-motion: reduce) { + .clearPotBtn:active:not(:disabled), + .choiceChip:active:not(:disabled), + .stepBtn:active:not(:disabled), + .splitBtn:active:not(:disabled), + .splitBtnGhost:active:not(:disabled), + .unevenFoldBtn:active:not(:disabled), + .unevenRemoveBtn:active:not(:disabled) { + transform: none; + } +} + #splitApp { max-width: 480px; margin: 0 auto; @@ -336,9 +388,11 @@ body { cursor: pointer; } -.clearPotBtn:hover { - background: #333; - color: #ddd; +@media (hover: hover) { + .clearPotBtn:hover { + background: #333; + color: #ddd; + } } /* ---------- Tap targets ---------- */ @@ -364,9 +418,11 @@ body { transition: all 0.15s ease; } -.choiceChip:hover { - border-color: #4a4a4a; - background: linear-gradient(180deg, #383838, #2a2a2a); +@media (hover: hover) { + .choiceChip:hover { + border-color: #4a4a4a; + background: linear-gradient(180deg, #383838, #2a2a2a); + } } .choiceChip.selected { @@ -401,8 +457,10 @@ body { cursor: pointer; } -.stepBtn:hover { - background: #333; +@media (hover: hover) { + .stepBtn:hover { + background: #333; + } } .waysReadout { @@ -439,9 +497,11 @@ body { transition: all 0.15s ease; } -.splitBtn:hover { - border-color: #4a4a4a; - background: linear-gradient(180deg, #383838, #2a2a2a); +@media (hover: hover) { + .splitBtn:hover { + border-color: #4a4a4a; + background: linear-gradient(180deg, #383838, #2a2a2a); + } } .splitBtnGhost { -- 2.43.0