From 9bffaa90ece73e61ccc699c4461d6047b1d970c8 Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 5 Oct 2026 14:54:04 +0100 Subject: [PATCH 01/39] Keep a confirm open through the bounce of the tap that opened it A double tap's second click landed on the freshly rendered dialog layer: on the backdrop or Cancel it dismissed the confirm, on Confirm it confirmed it unasked. DialogHost, Modal and the court console's two hand-rolled confirms now swallow pointer clicks for TAP_BOUNCE_MS after they open (tap_guard.jsx); keyboard activation is never swallowed. Closes bc-cfbd Co-Authored-By: Claude Opus 5.5 --- .../confirm_dialog_double_tap.render.test.jsx | 110 ++++++++++++++++++ web-mobile/js/admin_shiaijo.jsx | 12 +- web-mobile/js/ui.jsx | 14 ++- 3 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx diff --git a/web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx b/web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx new file mode 100644 index 000000000..c3a564752 --- /dev/null +++ b/web-mobile/js/__tests__/render/confirm_dialog_double_tap.render.test.jsx @@ -0,0 +1,110 @@ +// bc-cfbd: a double tap on a button that opens a confirm leaves the confirm +// open. +// +// The trigger's click opens the dialog, Preact renders it on the next +// microtask, and the second tap of the bounce (16-44ms later) lands on the new +// dialog layer: on the backdrop or Cancel it dismissed the confirm, and on +// Confirm it confirmed it without being asked. tap_guard.jsx's swallowBounce +// now covers every pointer click in the layer for TAP_BOUNCE_MS after it +// opens. Keyboard activation (detail 0) is never swallowed. +// +// Pointer taps pass detail: 1 (helpers/tap_events.js), or the guard exempts +// them and these tests could never go red. + +import React from 'react'; +import { render, act, cleanup, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { DialogHost, confirmDialog, Modal } from '../../ui.jsx'; +import { TAP_BOUNCE_MS } from '../../tap_guard.jsx'; +import { pointerTap, keyboardClick } from '../helpers/tap_events.js'; + +// One holder per test: a dialog a test leaves open is resolved (false) by the +// next test's dialog, and must not write into that test's result. +let outcome; + +function Harness({ sink }) { + return ( +
+ + +
+ ); +} + +const wait = (ms) => act(async () => { vi.advanceTimersByTime(ms); }); +const backdrop = (container) => container.querySelector('.modal-backdrop'); + +beforeEach(() => { outcome = { value: 'unresolved' }; vi.useFakeTimers(); }); +afterEach(() => { cleanup(); vi.useRealTimers(); }); + +describe('a confirm opened by a tap', () => { + it('survives the bounce landing on the backdrop', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + expect(backdrop(container)).not.toBeNull(); + await wait(30); + await pointerTap(backdrop(container)); + expect(backdrop(container)).not.toBeNull(); + expect(outcome.value).toBe('unresolved'); + }); + + it('survives the bounce landing on Cancel', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(30); + await pointerTap(screen.getByText('Cancel')); + expect(backdrop(container)).not.toBeNull(); + expect(outcome.value).toBe('unresolved'); + }); + + it('is not confirmed by the bounce landing on Confirm', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(30); + await pointerTap(screen.getByText('Go')); + expect(backdrop(container)).not.toBeNull(); + expect(outcome.value).toBe('unresolved'); + }); + + it('is dismissed by a deliberate tap on the backdrop after the window', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop(container)); + expect(backdrop(container)).toBeNull(); + expect(outcome.value).toBe(false); + }); + + it('is confirmed by a deliberate tap on Confirm after the window', async () => { + const { container } = render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(screen.getByText('Go')); + expect(backdrop(container)).toBeNull(); + expect(outcome.value).toBe(true); + }); + + it('is confirmed at once from the keyboard (detail 0 is never a bounce)', async () => { + render(); + await pointerTap(screen.getByTestId('trigger')); + await wait(30); + await keyboardClick(screen.getByText('Go')); + expect(outcome.value).toBe(true); + }); +}); + +describe('a Modal opened by a tap', () => { + it('survives the bounce on its backdrop and closes on a deliberate tap', async () => { + const onClose = vi.fn(); + const { container } = render(

body

); + await wait(30); + await pointerTap(backdrop(container)); + expect(onClose).not.toHaveBeenCalled(); + await wait(TAP_BOUNCE_MS + 50); + await pointerTap(backdrop(container)); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web-mobile/js/admin_shiaijo.jsx b/web-mobile/js/admin_shiaijo.jsx index 236ea5f7b..0e5dc31d5 100644 --- a/web-mobile/js/admin_shiaijo.jsx +++ b/web-mobile/js/admin_shiaijo.jsx @@ -15,6 +15,7 @@ import { createTimerPool } from './timer_pool.jsx'; import { applyPatch, keepNewerCompetitions } from './patch.jsx'; import { SideCell } from './side_cell.jsx'; +import { stampTap, swallowBounce } from './tap_guard.jsx'; // Imported DIRECTLY from the leaf rather than read off `window`. Two of the // call sites below sit inside a `try { } catch (_e) { }` that swallows, so a // missing global there would degrade into exactly the silent not-saved failure @@ -813,6 +814,11 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, // "Send back to queue" on a running bout. Cleared on confirm or cancel. // Shape: {compId, matchId, label}. const [pendingRevert, setPendingRevert] = useStateSh(null); + // bc-cfbd: the two confirms below open from a tap; the bounce of that tap + // must not land on the fresh backdrop or its buttons. One ref serves both + // (only one is ever open). + const confirmOpenedTapRef = useRefSh(null); + const stampConfirmOpened = useCallbackSh((node) => { if (node) stampTap(confirmOpenedTapRef); }, []); const [reverting, setReverting] = useStateSh(false); // Selected competition for filtering the queue. Default: running match's comp, // else first comp with scheduled matches here, else any comp with matches here. @@ -1874,7 +1880,8 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, {pendingMove && ( -
!movingCourt && setPendingMove(null)}> +
!movingCourt && setPendingMove(null)}>
e.stopPropagation()}>

@@ -1897,7 +1904,8 @@ function AdminShiaijoPage({ tournament, court: routeCourt, onBack, onEditScore, )} {pendingRevert && ( -
!reverting && setPendingRevert(null)}> +
!reverting && setPendingRevert(null)}>
e.stopPropagation()}>

diff --git a/web-mobile/js/ui.jsx b/web-mobile/js/ui.jsx index 3cf6bd830..a7738bdfd 100644 --- a/web-mobile/js/ui.jsx +++ b/web-mobile/js/ui.jsx @@ -1,5 +1,7 @@ // Shared UI primitives used by both admin and viewer modules. +import { stampTap, swallowBounce } from './tap_guard.jsx'; + // Formats that run their matches through the pool pipeline but are not pools. // See the label note inside StatusBadge. const POOLS_PHASE_FORMAT_LABEL = { league: "League", swiss: "Swiss" }; @@ -318,6 +320,9 @@ function DialogHost() { const inputRef = React.useRef(null); const triggerRef = React.useRef(null); const trapRef = React.useRef(null); + // bc-cfbd: when the dialog opened, so the bounce of the tap that opened it + // lands on nothing (see swallowBounce on the backdrop below). + const openedTapRef = React.useRef(null); React.useEffect(() => { const fn = (r) => { setReq(r); if (r && r.kind === "prompt") setValue(r.defaultValue || ""); }; @@ -338,6 +343,7 @@ function DialogHost() { // aria-modal carry the background-isolation contract instead. const dialogRefCb = React.useCallback((node) => { if (node) { + stampTap(openedTapRef); triggerRef.current = document.activeElement; // Save the baseline inline overflow so close restores EXACTLY it, rather // than blindly clearing to "" (which would clobber a pre-existing inline @@ -404,7 +410,7 @@ function DialogHost() { }; return ( -
+
e.stopPropagation()}>
{req.title}
@@ -588,8 +594,12 @@ function ShareLinkModal({ title, url, onClose, onCopy, children }) { function Modal({ title, onClose, children, footer, size, dismissable = true, className, style, ariaLabel }) { useEscapeToClose(dismissable ? onClose : undefined); + // bc-cfbd: the bounce of the tap that opened this modal must not reach its + // backdrop or its buttons. + const openedTapRef = React.useRef(null); + const stampOnOpen = React.useCallback((node) => { if (node) stampTap(openedTapRef); }, []); return ( -
+
e.stopPropagation()} From 610c19071076d2bc4cf3325375b8ea74279d447b Mon Sep 17 00:00:00 2001 From: Ricardo Oliveira Date: Mon, 5 Oct 2026 14:54:04 +0100 Subject: [PATCH 02/39] Floor the operator's small score-editor and lineup controls at 44px Under a coarse pointer the encho label and steppers, the team sheet's name box, the lineup panel's Rename link and the kachinuki collapse caret now reach the 44px tap floor through --tap-floor in classes. The Rename row is a div, so a tap on the position name no longer activates Rename, and only the caret glyph rotates. Closes bc-tp44 Co-Authored-By: Claude Opus 5.5 --- web-mobile/css/styles.css | 52 +++++++++-- ...tch_lineup_side_editor_member_ids.test.jsx | 23 +++++ .../js/__tests__/operator_tap_floor.test.jsx | 87 +++++++++++++++++++ .../team_editor_config_matrix.render.test.jsx | 8 +- web-mobile/js/admin_schedule_lineup.jsx | 10 ++- web-mobile/js/admin_scoring_team.jsx | 8 +- 6 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 web-mobile/js/__tests__/operator_tap_floor.test.jsx diff --git a/web-mobile/css/styles.css b/web-mobile/css/styles.css index 29994010b..e22879390 100644 --- a/web-mobile/css/styles.css +++ b/web-mobile/css/styles.css @@ -6241,9 +6241,18 @@ button.vsched-item:not([data-clickable]) { margin-bottom: 0; } .lineup-name__bar { - min-height: 36px; - padding: 4px 6px; + min-height: max(36px, var(--tap-floor)); + padding: 0 6px; flex-wrap: nowrap; + align-items: stretch; +} +/* The input fills the bar, so the whole bar is the typeable target (a tap on + the bar's own padding used to focus nothing); the clear button stays centred. */ +.lineup-name__bar .pmf__input { + align-self: stretch; +} +.lineup-name__bar .lineup-name__clear { + align-self: center; } /* An unfilled lineup slot is a "fill this" caution, not an error, and the two sides are already Aka (red) / Shiro per the colour system, so a red @@ -6268,6 +6277,21 @@ button.vsched-item:not([data-clickable]) { .lineup-name__clear:hover { color: var(--ink-1); } +/* The at-court lineup panel's Rename link: compact under a fine pointer, and + floored under a coarse one. Two classes, so it outranks both .btn floors + (an inline style used to, which left it 47x14 on an iPad). */ +.btn.lineup-rename-btn { + padding: 0 2px; + font-size: 11px; + height: auto; + min-height: 0; +} +@media (pointer: coarse) { + .btn.lineup-rename-btn { + min-height: var(--tap-floor); + min-width: var(--tap-floor); + } +} .lineup-name__dropdown { z-index: 60; min-width: 200px; @@ -7570,13 +7594,15 @@ abbr.msb-lab { text-decoration: none; cursor: help; } gap: 3px 6px; } @media (pointer: coarse) { + /* The box bleeds into the row padding and the gap rather than widening the + number gutter, which the team header restates as a margin. */ .tsm-caret-btn { - min-height: 44px; - min-width: 36px; + min-height: var(--tap-floor); + min-width: var(--tap-floor); display: inline-flex; align-items: center; justify-content: center; - margin: -10px 0; + margin: -10px -5px; } } @@ -7914,6 +7940,18 @@ abbr.msb-lab { text-decoration: none; cursor: help; } align-items: center; gap: 6px; font-weight: 600; + min-height: var(--tap-floor); + cursor: pointer; +} +/* The label, not the checkbox, is the tap target: the native box stays small + on a fine pointer and grows with the label under a coarse one. */ +@media (pointer: coarse) { + .encho-row__label { padding-inline: 6px; } + .encho-row__label input[type="checkbox"] { + width: 20px; + height: 20px; + accent-color: var(--accent); + } } .encho-row__stepper { display: flex; @@ -7927,8 +7965,8 @@ abbr.msb-lab { text-decoration: none; cursor: help; } font-weight: 700; } .encho-row__btn { - min-width: 28px; - min-height: 28px; + min-width: max(28px, var(--tap-floor)); + min-height: max(28px, var(--tap-floor)); } /* --- Compact mode: chrome ------------------------------------------------ */ diff --git a/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx b/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx index 828ac03af..7c9cfd6ae 100644 --- a/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx +++ b/web-mobile/js/__tests__/match_lineup_side_editor_member_ids.test.jsx @@ -403,6 +403,29 @@ describe('MatchLineupSideEditor resolves names to squad member ids (bc-pnum gap // same rename the Lineups page offers; typing into the picker over a named // member stays a substitution. A blank pick gets no Rename: it is named by // typing into the box. + // bc-tp44: the Rename link is sized by a class (an inline style outranked the + // coarse .btn floors and left it 47x14 on an iPad), and its row is a div: a + // label with no `for` activates its first labelable descendant, which on a + // named row is this button, so tapping the position name opened Rename. + it('Rename carries a class not an inline style, and its row host is not a label', async () => { + global.window.API.fetchSquads = vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD_7 }); + + let tree = await mount(); + const pickers = findComponents(tree, 'LineupNameInput'); + pickers[0].props.onSelect('Fighter 1', SQUAD_7[0]); + tree = runtime.currentTree(); + + const rename = findHosts(tree, 'button').find(b => b.props?.['aria-label'] === 'Rename 1 player'); + expect(rename).toBeTruthy(); + expect(rename.props.style).toBeUndefined(); + expect(rename.props.className).toContain('lineup-rename-btn'); + + const rowHosts = (type) => findHosts(tree, type) + .filter(n => /^match-lineup-pos-/.test(n.props?.['data-testid'] || '')); + expect(rowHosts('label')).toEqual([]); + expect(rowHosts('div').length).toBeGreaterThan(0); + }); + it('Rename under a named pick renames that member and the next save writes the new name with the same id', async () => { global.window.API.fetchSquads = vi.fn().mockResolvedValue({ 'uuid-grouped': SQUAD_7 }); global.window.API.renameTeamMember = vi.fn().mockResolvedValue({ id: 'mem-1', index: 1, name: 'Fighter One' }); diff --git a/web-mobile/js/__tests__/operator_tap_floor.test.jsx b/web-mobile/js/__tests__/operator_tap_floor.test.jsx new file mode 100644 index 000000000..d757e4c16 --- /dev/null +++ b/web-mobile/js/__tests__/operator_tap_floor.test.jsx @@ -0,0 +1,87 @@ +// bc-tp44: the operator's score-editor and lineup-panel controls reach the +// 44px floor under a coarse pointer, through --tap-floor in CLASSES (0px on a +// fine pointer, so a laptop keeps its density), never an inline style. +// +// jsdom lays nothing out, so this pins the rules in the stylesheet (the +// pattern of viewer_watchlist_panel.test.jsx); the acceptance is the browser +// measurement at 1180x820 with a coarse pointer. + +import { describe, it, expect } from 'vitest'; +import { cssBlock, readStylesheet } from './helpers/source.js'; + +const css = readStylesheet(); + +const block = (selector) => { + const b = cssBlock(css, selector); + expect(b, `rule ${selector} exists`).not.toBeNull(); + return b; +}; + +// Every `@media (pointer: coarse) { ... }` body, found by brace depth. +function coarseBlocks(source) { + const out = []; + const opener = '@media (pointer: coarse) {'; + let from = 0; + for (;;) { + const at = source.indexOf(opener, from); + if (at < 0) return out; + let depth = 1; + let i = at + opener.length; + while (i < source.length && depth > 0) { + if (source[i] === '{') depth += 1; + else if (source[i] === '}') depth -= 1; + i += 1; + } + out.push(source.slice(at + opener.length, i - 1)); + from = i; + } +} +const coarseRule = (selector) => { + const esc = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + for (const body of coarseBlocks(css)) { + const m = new RegExp(`(?:^|\\s)${esc}\\s*\\{([^}]*)\\}`).exec(body); + if (m) return m[1]; + } + return null; +}; + +describe('operator tap floors (bc-tp44)', () => { + it('floors the encho stepper buttons on both axes', () => { + const b = block('.encho-row__btn'); + expect(b).toMatch(/min-width: max\(28px, var\(--tap-floor\)\)/); + expect(b).toMatch(/min-height: max\(28px, var\(--tap-floor\)\)/); + }); + + it('floors the encho label, which is the checkbox\'s tap target', () => { + expect(block('.encho-row__label')).toMatch(/min-height: var\(--tap-floor\)/); + const box = coarseRule('.encho-row__label input[type="checkbox"]'); + expect(box, 'coarse checkbox rule').not.toBeNull(); + expect(box).toMatch(/width: 20px/); + expect(box).toMatch(/height: 20px/); + }); + + it('floors the lineup name bar and lets the input fill it', () => { + const bar = block('.lineup-name__bar'); + expect(bar).toMatch(/min-height: max\(36px, var\(--tap-floor\)\)/); + expect(bar).toMatch(/padding: 0 6px/); + expect(bar).toMatch(/align-items: stretch/); + expect(block('.lineup-name__bar .pmf__input')).toMatch(/align-self: stretch/); + expect(block('.lineup-name__bar .lineup-name__clear')).toMatch(/align-self: center/); + }); + + it('floors the lineup panel Rename link under a coarse pointer, above the .btn floors', () => { + expect(block('.btn.lineup-rename-btn')).toMatch(/min-height: 0/); + const coarse = coarseRule('.btn.lineup-rename-btn'); + expect(coarse, 'coarse rename rule').not.toBeNull(); + expect(coarse).toMatch(/min-height: var\(--tap-floor\)/); + expect(coarse).toMatch(/min-width: var\(--tap-floor\)/); + }); + + it('floors the collapse caret button on both axes without widening the gutter', () => { + const coarse = coarseRule('.tsm-caret-btn'); + expect(coarse, 'coarse caret rule').not.toBeNull(); + expect(coarse).toMatch(/min-height: var\(--tap-floor\)/); + expect(coarse).toMatch(/min-width: var\(--tap-floor\)/); + expect(coarse).toMatch(/margin: -10px -5px/); + }); +}); diff --git a/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx b/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx index 1703d2d63..cf6ca66cd 100644 --- a/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx +++ b/web-mobile/js/__tests__/render/team_editor_config_matrix.render.test.jsx @@ -369,7 +369,13 @@ describe('TeamScoreEditorModal kachinuki bout navigation', () => { expect(rows[1].querySelector('.team-sub-match__btns')).not.toBeNull(); // The collapse caret (rotated, aria-expanded=true) is the close control. const caret = screen.getByTestId('kachinuki-done-collapse-0'); - expect(caret.classList.contains('tsm-caret--open')).toBe(true); + // bc-tp44: only the GLYPH is rotated. Rotating the button itself made its + // 44x36 tap box hit-test as 36x44. + expect(caret.classList.contains('tsm-caret--open')).toBe(false); + expect(caret.classList.contains('tsm-caret-btn')).toBe(true); + const glyph = caret.querySelector('.tsm-caret.tsm-caret--open'); + expect(glyph).not.toBeNull(); + expect(glyph.getAttribute('aria-hidden')).toBe('true'); expect(caret.getAttribute('aria-expanded')).toBe('true'); }); diff --git a/web-mobile/js/admin_schedule_lineup.jsx b/web-mobile/js/admin_schedule_lineup.jsx index 9755dd9a9..69e24d269 100644 --- a/web-mobile/js/admin_schedule_lineup.jsx +++ b/web-mobile/js/admin_schedule_lineup.jsx @@ -548,7 +548,11 @@ export function MatchLineupSideEditor({ comp, team, match, allMatches, password,
{positions.map(p => { const pickedLabel = pickedLabelFor(p.key); return ( -