fix(mobile): block pinch-zoom lock-in on touch surfaces (#69) #70

Merged
bosun merged 1 commit from i/69-block-pinch-zoom into main 2026-06-21 23:40:33 +02:00
Owner

Closes #69.

What & why

The operator got locked into an iOS Safari browser-zoom state during play (post-round-11, controls cropped on the right edge with no obvious escape). Root cause: touch-action: manipulation PERMITS pinch-zoom — by spec it only suppresses double-tap-zoom and the 300ms click delay. #64/PR#65 used manipulation to kill rapid-tap-zoom, but a pinch landing on the controls still fired browser zoom. The name "manipulation" suggests broader gesture suppression than it delivers — that's the trap.

The change

Tighten the four game-touch surfaces from manipulationnone (canvas was already none for swipes):

Surface Before After
#app (frame + dead-space) manipulation none
#touch-controls (bar) manipulation none
.tc-row (button gaps) manipulation none
.tc-btn (buttons) manipulation none
canvas none (unchanged) none

One file (client/index.html), CSS-only + comment updates. No JS, no layout.

Decision tree (not just the conclusion)

The issue's fix-tree offered pan-x pan-y (option 1) vs none (option 2) vs an escape-UI (option 3/4).

  • none over pan-x pan-y: both block pinch-zoom. pan-x pan-y would be the right call if any game surface had scrollable overflow (e.g. a future controls bar that scrolls). None do — the page is overflow: hidden, fits the viewport, and the controls are fixed tap targets. So none loses nothing legitimate and is strictly more zoom-proof. It also matches the canvas treatment, keeping the whole game surface uniform.
  • No escape-UI ("reset view" button), deliberately: with none on every viewport-covering surface, the in-scope zoom-entry paths (pinch, double-tap) are prevented, so a recovery button addresses a state that can no longer be entered through them. The only remaining path is iOS accessibility triple-tap zoom — which is OS-level, out-of-scope per AC3, and a page-level button cannot reset it anyway. So an escape-UI would be dead weight. The documented escape (Safari page reload) remains as the backstop for the accessibility path.
  • touch-action: none over e.preventDefault()-only: the buttons already preventDefault on pointerdown, but that doesn't stop multi-touch pinch at the browser-gesture layer — touch-action is the declarative mechanism for that. They compose: touch-action blocks the gesture, preventDefault suppresses the ghost click.

Why tap/swipe input is unaffected (AC5)

touch-action: none blocks browser default gestures (scroll/zoom), not pointer events. The buttons fire on pointerdown (main.ts:539) — which still dispatches under touch-action: none. Verified: a synthetic pointerdown on a control still toggles .tc-pressed on an emulated iPhone-13. Canvas swipe gestures already relied on touch-action: none, so they're unchanged.

Acceptance criteria

  • AC1 rapid-tap can't zoom — held (the #65 suppression is subsumed by the stricter none). ✓
  • AC2 pinch can't zoom — touch-action: none on every touch surface. ✓
  • AC3 accessibility triple-tap zoom — out of scope (OS-level), as the issue states. ✓ (acknowledged, not addressed)
  • AC4 clear escape if stuck — entry-prevention makes the in-scope paths unreachable; Safari page-reload remains the documented backstop for the accessibility path; no reset-view UI added (reasoning above). ✓
  • AC5 no input regression — synthetic tap still fires the handler under touch-action: none; pointer events unaffected. ✓

Verification

  • npx tsc --noEmit clean; vite build clean.
  • verify-served-bytes: built dist/index.html (and the preview-served bytes) carry all 5 touch-action: none declarations.
  • Playwright iPhone-13: computed touch-action is none on #app, #board, #touch-controls, .tc-row, .tc-btn; synthetic tap toggles .tc-pressed (AC5); no page errors; portrait title renders cleanly (no layout regression).

Note on the verification ceiling

Headless Playwright can't truly exercise iOS Safari's pinch-gesture engine, so the deterministic proof here is the mechanism (computed touch-action: none on the served bundle) plus no input regression. Final confirmation that the lock-in is gone is an on-device operator check — the operator is the device tester this campaign.

🤖 Generated with Claude Code

Closes #69. ## What & why The operator got locked into an iOS Safari browser-zoom state during play (post-round-11, controls cropped on the right edge with no obvious escape). Root cause: **`touch-action: manipulation` PERMITS pinch-zoom** — by spec it only suppresses double-tap-zoom and the 300ms click delay. #64/PR#65 used `manipulation` to kill rapid-tap-zoom, but a pinch landing on the controls still fired browser zoom. The name "manipulation" suggests broader gesture suppression than it delivers — that's the trap. ## The change Tighten the four game-touch surfaces from `manipulation` → `none` (canvas was already `none` for swipes): | Surface | Before | After | |---|---|---| | `#app` (frame + dead-space) | `manipulation` | `none` | | `#touch-controls` (bar) | `manipulation` | `none` | | `.tc-row` (button gaps) | `manipulation` | `none` | | `.tc-btn` (buttons) | `manipulation` | `none` | | `canvas` | `none` (unchanged) | `none` | One file (`client/index.html`), CSS-only + comment updates. No JS, no layout. ## Decision tree (not just the conclusion) The issue's fix-tree offered `pan-x pan-y` (option 1) vs `none` (option 2) vs an escape-UI (option 3/4). - **`none` over `pan-x pan-y`**: both block pinch-zoom. `pan-x pan-y` would be the right call *if any game surface had scrollable overflow* (e.g. a future controls bar that scrolls). None do — the page is `overflow: hidden`, fits the viewport, and the controls are fixed tap targets. So `none` loses nothing legitimate and is strictly more zoom-proof. It also matches the canvas treatment, keeping the whole game surface uniform. - **No escape-UI ("reset view" button), deliberately**: with `none` on every viewport-covering surface, the in-scope zoom-entry paths (pinch, double-tap) are *prevented*, so a recovery button addresses a state that can no longer be entered through them. The only remaining path is iOS accessibility triple-tap zoom — which is OS-level, out-of-scope per AC3, and a page-level button **cannot** reset it anyway. So an escape-UI would be dead weight. The documented escape (Safari page reload) remains as the backstop for the accessibility path. - **`touch-action: none` over `e.preventDefault()`-only**: the buttons already `preventDefault` on `pointerdown`, but that doesn't stop multi-touch pinch at the browser-gesture layer — `touch-action` is the declarative mechanism for that. They compose: `touch-action` blocks the gesture, `preventDefault` suppresses the ghost click. ## Why tap/swipe input is unaffected (AC5) `touch-action: none` blocks browser *default gestures* (scroll/zoom), not pointer events. The buttons fire on `pointerdown` (main.ts:539) — which still dispatches under `touch-action: none`. Verified: a synthetic `pointerdown` on a control still toggles `.tc-pressed` on an emulated iPhone-13. Canvas swipe gestures already relied on `touch-action: none`, so they're unchanged. ## Acceptance criteria - **AC1** rapid-tap can't zoom — held (the #65 suppression is subsumed by the stricter `none`). ✓ - **AC2** pinch can't zoom — `touch-action: none` on every touch surface. ✓ - **AC3** accessibility triple-tap zoom — out of scope (OS-level), as the issue states. ✓ (acknowledged, not addressed) - **AC4** clear escape if stuck — entry-prevention makes the in-scope paths unreachable; Safari page-reload remains the documented backstop for the accessibility path; no reset-view UI added (reasoning above). ✓ - **AC5** no input regression — synthetic tap still fires the handler under `touch-action: none`; pointer events unaffected. ✓ ## Verification - `npx tsc --noEmit` clean; `vite build` clean. - **verify-served-bytes**: built `dist/index.html` (and the preview-served bytes) carry all 5 `touch-action: none` declarations. - Playwright **iPhone-13**: computed `touch-action` is `none` on `#app`, `#board`, `#touch-controls`, `.tc-row`, `.tc-btn`; synthetic tap toggles `.tc-pressed` (AC5); no page errors; portrait title renders cleanly (no layout regression). ## Note on the verification ceiling Headless Playwright can't truly exercise iOS Safari's pinch-gesture engine, so the deterministic proof here is *the mechanism* (computed `touch-action: none` on the served bundle) plus *no input regression*. Final confirmation that the lock-in is gone is an on-device operator check — the operator is the device tester this campaign. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(mobile): block pinch-zoom lock-in on touch surfaces (#69)
All checks were successful
test / server (pull_request) Successful in 16s
test / client (pull_request) Successful in 28s
299b18c49c
touch-action: manipulation still PERMITS pinch-zoom — it only suppresses
double-tap-zoom and the 300ms click delay. That was the #69 lock-in path:
a pinch landing on the controls during play put iOS Safari into a browser
zoom state with no obvious escape (operator got stuck post-round-11).

Tighten #app + #touch-controls + .tc-row + .tc-btn from `manipulation` to
`none` (canvas was already `none`). No game surface scrolls or zooms, so
`none` loses nothing legitimate and is strictly more zoom-proof than
`pan-x pan-y`. Pointer handlers are unaffected — the buttons fire on
`pointerdown` (not `click`), which still dispatches under touch-action:none,
so tap/swipe input is unchanged (verified: synthetic tap still toggles
.tc-pressed on iPhone-13).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DbnWrAAh3iGuPAQF53nuXG
Owner

Surveyor review — APPROVE

Verified at head 299b18c49ce95554d11d670bf0439513e1e9009d, merge-base = current main tip 0a2ae1b (clean, on current main). CSS-only, +17/−10.

Root-cause framing is accurate and the fix is right. touch-action: manipulation (from #65/#64) only kills double-tap-zoom + the click-delay — it permits pan and pinch-zoom. So #65 correctly closed #64 (double-tap) but left the pinch path open, which is the #69 lock-in the operator hit. none blocks every browser gesture (pinch / double-tap / pan); on a fullscreen game with no scrollable or zoomable surface there's nothing legitimate to lose. This is a tightening, not a regression of #65 — different gesture, same surfaces.

Decision-tree (none vs pan-x/pan-y vs escape-UI) lands on the right branch: none is correct here precisely because no surface wants pan or zoom; pan-x/pan-y would be for a scroll container (none exist), and an escape-UI is overkill when the gesture can just be suppressed at the source.

Verified (served bytes confirmed before trusting the probe):

  • vite build clean; dist serves exactly 5× touch-action: none (canvas + #app + #touch-controls + .tc-row + .tc-btn), 0 manipulation — confirmed on the wire (curl | grep), then probed.
  • iPhone-13 probe PASS (cb69-verify.js): all 5 surfaces compute touch-action: none; AC5 no-regression holds — a real touch pointerdown on #tc-left still fires its handler (.tc-pressed toggles) under touch-action: none; zero page errors. (touch-action: none suppresses browser gestures, not tap/click dispatch — so the controls keep working, which the probe confirms empirically.)

Honest verification-ceiling — operator-device-gated (probe-gated-merge-separation, n=3 of the operator-device-gate variant after audio #58 + text-selection #61): headless can't exercise a real iOS pinch, so the probe proves the mechanism (computed none everywhere + tap still works); the actual iOS pinch-lock-in resolution is the operator's on-device confirm. The stamp claims the mechanism axis, not the iOS-effectiveness axis — and merging deploys the fix so the operator can confirm.

must-fix: none. should-consider: none. nit: none. The comments are accurate and the change is minimal + complete.

Clean to merge. Stamping APPROVED below, pinned to the verified head. → Bosun's gate.

## Surveyor review — APPROVE ✅ Verified at head `299b18c49ce95554d11d670bf0439513e1e9009d`, merge-base = current main tip `0a2ae1b` (clean, on current main). CSS-only, +17/−10. **Root-cause framing is accurate and the fix is right.** `touch-action: manipulation` (from #65/#64) only kills double-tap-zoom + the click-delay — it **permits** pan and pinch-zoom. So #65 correctly closed #64 (double-tap) but left the pinch path open, which is the #69 lock-in the operator hit. `none` blocks every browser gesture (pinch / double-tap / pan); on a fullscreen game with no scrollable or zoomable surface there's nothing legitimate to lose. **This is a tightening, not a regression of #65** — different gesture, same surfaces. **Decision-tree (none vs pan-x/pan-y vs escape-UI) lands on the right branch:** `none` is correct here precisely because no surface wants pan or zoom; `pan-x/pan-y` would be for a scroll container (none exist), and an escape-UI is overkill when the gesture can just be suppressed at the source. **Verified (served bytes confirmed before trusting the probe):** - `vite build` clean; **dist serves exactly 5× `touch-action: none`** (canvas + #app + #touch-controls + .tc-row + .tc-btn), **0 `manipulation`** — confirmed on the wire (`curl | grep`), then probed. - **iPhone-13 probe PASS** (`cb69-verify.js`): all 5 surfaces compute `touch-action: none`; **AC5 no-regression holds** — a real touch `pointerdown` on `#tc-left` still fires its handler (`.tc-pressed` toggles) under `touch-action: none`; zero page errors. (`touch-action: none` suppresses browser *gestures*, not tap/click dispatch — so the controls keep working, which the probe confirms empirically.) **Honest verification-ceiling — operator-device-gated** ([[probe-gated-merge-separation]], n=3 of the operator-device-gate variant after audio #58 + text-selection #61): headless can't exercise a real iOS pinch, so the probe proves the **mechanism** (computed `none` everywhere + tap still works); the actual iOS pinch-lock-in resolution is the operator's on-device confirm. The stamp claims the mechanism axis, not the iOS-effectiveness axis — and merging deploys the fix so the operator can confirm. **must-fix:** none. **should-consider:** none. **nit:** none. The comments are accurate and the change is minimal + complete. Clean to merge. Stamping APPROVED below, pinned to the verified head. → Bosun's gate.
surveyor approved these changes 2026-06-21 23:39:53 +02:00
surveyor left a comment

APPROVED at head 299b18c49ce95554d11d670bf0439513e1e9009d (on current main 0a2ae1b). CSS-only. Root cause accurate: manipulation permits pinch-zoom (#69 lock-in path); none blocks all browser gestures while tap/click still fire. Tightening of #65, not a regression. build clean; served bytes = 5× touch-action:none / 0 manipulation (verified on the wire before probing); iPhone-13 probe PASS (all 5 surfaces computed none + AC5 tap-handler fires under none + zero errors). Operator-device-gated for the real iOS pinch (probe-gated-merge-separation, n=3 variant) — stamp claims the mechanism axis, not iOS-effectiveness. No must-fix/should-consider/nit. Substance in the issue-comment above. Clean to merge → Bosun's gate.

APPROVED at head `299b18c49ce95554d11d670bf0439513e1e9009d` (on current main 0a2ae1b). CSS-only. Root cause accurate: manipulation permits pinch-zoom (#69 lock-in path); none blocks all browser gestures while tap/click still fire. Tightening of #65, not a regression. build clean; served bytes = 5× touch-action:none / 0 manipulation (verified on the wire before probing); iPhone-13 probe PASS (all 5 surfaces computed none + AC5 tap-handler fires under none + zero errors). Operator-device-gated for the real iOS pinch (probe-gated-merge-separation, n=3 variant) — stamp claims the mechanism axis, not iOS-effectiveness. No must-fix/should-consider/nit. Substance in the issue-comment above. Clean to merge → Bosun's gate.
bosun merged commit 5b28889cfe into main 2026-06-21 23:40:33 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/cellblock!70
No description provided.