fix(mobile): suppress iOS long-press text-selection over the game surface (#60) #61

Merged
bosun merged 1 commit from i/60-canvas-text-selection into main 2026-06-21 19:49:11 +02:00
Owner

What

Fixes #60. On iPhone Safari, a touch-and-hold on the canvas / game area triggers the iOS text-selection gesture — a selection rectangle + loupe appear over the play field mid-game (operator playtest, 2026-06-21). This scopes selection/callout/tap-flash suppression to the game surface.

Approach — why #app, not enumerated elements

The tracker suggested canvas, .game-container, body. I scoped to the game root #app instead, because the suppression properties (user-select, -webkit-touch-callout, -webkit-tap-highlight-color) inherit, so a single root rule cascades to canvas, #game-wrap, and #touch-controls without enumerating them.

The AC3 "keep legitimate text selectable" constraint is satisfied for free by this scope:

  • The only legitimately-selectable DOM text is the ?debug=audio overlay — and it's appended to document.body outside #app, so it never inherits the suppression (verified user-select:auto).
  • The leaderboard names + initials named in AC3 are canvas-rendered pixels, not real DOM text — they are unselectable regardless of any CSS. So there is no real selectable-text regression surface inside #app.

When the enumerated form would be right instead: if #app ever gains a real DOM text region meant to stay selectable (e.g. a future HTML leaderboard or about-panel), the root-scope would over-suppress it — at that point switch to enumerating the game-surface elements (canvas + #game-wrap + #touch-controls) and leave the text region out. Today, root-scope is both simpler and correct.

Diff

12 lines added to client/index.html — one inherited rule block on #app:

-webkit-user-select: none;
user-select: none;
-webkit-touch-callout: none;       /* disables the long-press callout menu */
-webkit-tap-highlight-color: transparent;

(touch-action: none was already on the canvas, so double-tap-zoom / scroll-on-swipe were already handled.)

Verification

Playwright iPhone-13 (/tmp/cb60-verify.js, ephemeral):

  • user-select:none + transparent tap-highlight computed on #app, canvas (inherited), #touch-controls (inherited).
  • ?debug=audio overlay computed user-select:auto and confirmed outside #app (closest('#app') null).
  • No JS errors; desktop mouse/keyboard path unaffected (no behavioral CSS change for pointer-fine devices).

Honest boundary: -webkit-touch-callout: none is a Safari-only property — Chromium's getComputedStyle never reports it, so it cannot be asserted in the headless probe. It is grep-confirmed present in the built bundle (dist/index.html), and its behavioral effect (no long-press callout / loupe) is part of the iOS operator device loop — same instrument-vs-confirmation split as #58. The Chromium-observable suppression is what the probe proves; the iOS-only behavior is the operator's to confirm on-device.

Scope

First of the mobile-session batch (#60#57#59). Composes cleanly with #57's upcoming layout work — this adds selection-suppression properties; #57 changes sizing/layout rules, no line overlap.

🤖 Generated with Claude Code

## What Fixes #60. On iPhone Safari, a touch-and-hold on the canvas / game area triggers the iOS text-selection gesture — a selection rectangle + loupe appear over the play field mid-game (operator playtest, 2026-06-21). This scopes selection/callout/tap-flash suppression to the game surface. ## Approach — why `#app`, not enumerated elements The tracker suggested `canvas, .game-container, body`. I scoped to the game root **`#app`** instead, because the suppression properties (`user-select`, `-webkit-touch-callout`, `-webkit-tap-highlight-color`) **inherit**, so a single root rule cascades to `canvas`, `#game-wrap`, and `#touch-controls` without enumerating them. The AC3 "keep legitimate text selectable" constraint is satisfied **for free** by this scope: - The only legitimately-selectable **DOM** text is the `?debug=audio` overlay — and it's appended to `document.body` **outside** `#app`, so it never inherits the suppression (verified `user-select:auto`). - The leaderboard names + initials named in AC3 are **canvas-rendered pixels**, not real DOM text — they are unselectable regardless of any CSS. So there is no real selectable-text regression surface inside `#app`. **When the enumerated form would be right instead:** if `#app` ever gains a real DOM text region meant to stay selectable (e.g. a future HTML leaderboard or about-panel), the root-scope would over-suppress it — at that point switch to enumerating the game-surface elements (canvas + `#game-wrap` + `#touch-controls`) and leave the text region out. Today, root-scope is both simpler and correct. ## Diff 12 lines added to `client/index.html` — one inherited rule block on `#app`: ```css -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; /* disables the long-press callout menu */ -webkit-tap-highlight-color: transparent; ``` (`touch-action: none` was already on the canvas, so double-tap-zoom / scroll-on-swipe were already handled.) ## Verification Playwright iPhone-13 (`/tmp/cb60-verify.js`, ephemeral): - `user-select:none` + transparent tap-highlight **computed** on `#app`, `canvas` (inherited), `#touch-controls` (inherited). ✅ - `?debug=audio` overlay computed `user-select:auto` **and** confirmed outside `#app` (`closest('#app')` null). ✅ - No JS errors; desktop mouse/keyboard path unaffected (no behavioral CSS change for pointer-fine devices). ✅ **Honest boundary:** `-webkit-touch-callout: none` is a **Safari-only** property — Chromium's `getComputedStyle` never reports it, so it cannot be asserted in the headless probe. It is **grep-confirmed present in the built bundle** (`dist/index.html`), and its behavioral effect (no long-press callout / loupe) is part of the **iOS operator device loop** — same instrument-vs-confirmation split as #58. The Chromium-observable suppression is what the probe proves; the iOS-only behavior is the operator's to confirm on-device. ## Scope First of the mobile-session batch (#60 → #57 → #59). Composes cleanly with #57's upcoming layout work — this adds selection-suppression *properties*; #57 changes *sizing/layout* rules, no line overlap. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(mobile): suppress iOS long-press text-selection over the game surface (#60)
All checks were successful
test / server (pull_request) Successful in 18s
test / client (pull_request) Successful in 25s
533fc39f4c
iOS Safari treats long-press on the canvas / game container as a
text-selection gesture, popping a selection rectangle + loupe over the
play field mid-game. Scope user-select/touch-callout/tap-highlight
suppression to the game root (#app) — it inherits to canvas, #game-wrap,
and #touch-controls. The only legitimately-selectable DOM text (the
?debug=audio overlay) is appended to document.body OUTSIDE #app so it
stays selectable; canvas-rendered text (leaderboard, initials) is not
real DOM text and is unselectable regardless.

Verified (Playwright iPhone-13): user-select:none + transparent
tap-highlight computed on #app/canvas/#touch-controls; ?debug=audio
overlay stays user-select:auto and outside #app; no JS errors.
-webkit-touch-callout is Safari-only (Chromium can't report it) —
grep-confirmed in the built bundle, behavior confirmed via the iOS
operator device loop.

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 533fc39f4cfbaf3d0a8273a49c816ad838f71973, merge-base = current main tip a9fe11fd (clean, on current main). CSS-only, 12 lines on #app.

The scope call is not just defensible — it's better than the tracker's enumeration. The tracker enumerated canvas, .game-container, body; scoping to #app + inheritance is superior precisely because it does not touch body — so the ?debug=audio overlay (appended to document.body, outside #app — verified in #58) keeps user-select: auto and stays selectable. The enumerated …body form would have suppressed it. AC3 ("keep legit text selectable") is satisfied because of the narrower scope, not in spite of it. (implementer-extends-recommendation — a defensibly-better category call than the tracker's.)

AC3 skeptic's pass (the load-bearing surface) — done independently: I enumerated every node inside #app:

  • canvas#board — game pixels, no DOM text
  • #audio-hudbtn-mute (button label), two type="range" sliders (no text content; user-select is a no-op on a range thumb), a glyph span
  • #touch-controls — all <button> labels

There is no type="text" input, no <textarea>, no contenteditable anywhere in the client (grep-confirmed) — name/initials entry is canvas-drawn (keydown→canvas), so "canvas pixels, not DOM text" holds. Nothing legitimately-selectable or editable lives inside #app. The suppression breaks nothing.

Verified (desktop-reachable axes):

  • vite build clean. The inline <style> is kept verbatim in dist/index.html; -webkit-touch-callout: none confirmed present in the built bundle at the #app rule (the Safari-only property the probe structurally can't assert — grep-verified as the PR states).
  • iPhone-13 probe PASS (/tmp/cb60-verify.js, Playwright devices['iPhone 13']): #app / canvas / #touch-controls all compute user-select: none (canvas + touch via inheritance ✓) and tap-highlight: rgba(0,0,0,0); the debug overlay exists: true, inApp: false, userSelect: auto — outside #app, stays selectable. Zero page errors. AC3-both-directions empirically confirmed (suppressed where it should be; selectable where it must be).

Honest boundary handled right (probe-gated-merge-separation, same split as #58): the Chromium-observable bits (user-select / tap-highlight computed + overlay scope) are proven on desktop; the Safari-only long-press loupe/callout behavior is operator-device-gated — that verdict stays with the iOS device loop, and my stamp doesn't claim it. The mechanism is wired + scoped correctly, which is what desktop can prove.

must-fix: none. should-consider: none beyond the now-familiar uncommitted-probe note (/tmp/cb60-verify.js) — same deferrable class as #58, where the operator device loop is the real gate and the probe is a desktop regression+scope check; consistent with the throwaway-probe convention you documented there. nit (precision): -webkit-tap-highlight-color: transparent already existed on a control rule (built line 160) pre-PR; #61 broadens it to the whole surface — worth knowing it's a broadening, not a net-new suppression.

Clean to merge. Stamping APPROVED below, pinned to the verified head. The iOS loupe/callout verdict stays with the operator's device loop.

## Surveyor review — APPROVE ✅ Verified at head `533fc39f4cfbaf3d0a8273a49c816ad838f71973`, merge-base = current main tip `a9fe11fd` (clean, on current main). CSS-only, 12 lines on `#app`. **The scope call is not just defensible — it's *better* than the tracker's enumeration.** The tracker enumerated `canvas, .game-container, body`; scoping to `#app` + inheritance is superior precisely because it does **not** touch `body` — so the `?debug=audio` overlay (appended to `document.body`, outside `#app` — verified in #58) keeps `user-select: auto` and stays selectable. The enumerated `…body` form would have suppressed it. AC3 ("keep legit text selectable") is satisfied *because of* the narrower scope, not in spite of it. ([[implementer-extends-recommendation]] — a defensibly-better category call than the tracker's.) **AC3 skeptic's pass (the load-bearing surface) — done independently:** I enumerated every node inside `#app`: - `canvas#board` — game pixels, no DOM text - `#audio-hud` — `btn-mute` (button label), **two `type="range"` sliders** (no text content; `user-select` is a no-op on a range thumb), a `♪` glyph span - `#touch-controls` — all `<button>` labels There is **no `type="text"` input, no `<textarea>`, no `contenteditable` anywhere in the client** (`grep`-confirmed) — name/initials entry is canvas-drawn (keydown→canvas), so "canvas pixels, not DOM text" holds. Nothing legitimately-selectable or editable lives inside `#app`. The suppression breaks nothing. **Verified (desktop-reachable axes):** - **`vite build` clean.** The inline `<style>` is kept verbatim in `dist/index.html`; **`-webkit-touch-callout: none` confirmed present in the built bundle** at the `#app` rule (the Safari-only property the probe structurally can't assert — grep-verified as the PR states). - **iPhone-13 probe PASS** (`/tmp/cb60-verify.js`, Playwright `devices['iPhone 13']`): `#app` / `canvas` / `#touch-controls` all compute `user-select: none` (canvas + touch via inheritance ✓) and `tap-highlight: rgba(0,0,0,0)`; the debug overlay `exists: true, inApp: false, userSelect: auto` — outside `#app`, stays selectable. Zero page errors. AC3-both-directions empirically confirmed (suppressed where it should be; selectable where it must be). **Honest boundary handled right** ([[probe-gated-merge-separation]], same split as #58): the Chromium-observable bits (user-select / tap-highlight computed + overlay scope) are proven on desktop; the **Safari-only long-press loupe/callout behavior is operator-device-gated** — that verdict stays with the iOS device loop, and my stamp doesn't claim it. The mechanism is wired + scoped correctly, which is what desktop can prove. **must-fix:** none. **should-consider:** none beyond the now-familiar uncommitted-probe note (`/tmp/cb60-verify.js`) — same deferrable class as #58, where the operator device loop is the real gate and the probe is a desktop regression+scope check; consistent with the throwaway-probe convention you documented there. **nit (precision):** `-webkit-tap-highlight-color: transparent` already existed on a control rule (built line 160) pre-PR; #61 broadens it to the whole surface — worth knowing it's a broadening, not a net-new suppression. Clean to merge. Stamping APPROVED below, pinned to the verified head. The iOS loupe/callout verdict stays with the operator's device loop.
surveyor approved these changes 2026-06-21 19:48:23 +02:00
surveyor left a comment

APPROVED at head 533fc39f4cfbaf3d0a8273a49c816ad838f71973 (on current main). CSS-only. AC3 skeptic's pass done independently — no text input / textarea / contenteditable anywhere in #app (only range sliders + button labels + canvas), so suppression breaks nothing; debug overlay is outside #app and stays selectable. The #app-scope is superior to the tracker's …body enumeration (preserves AC3 by not touching body). vite build clean + -webkit-touch-callout: none confirmed in the built bundle (the Safari-only prop the probe can't assert). iPhone-13 probe PASS (user-select:none on #app/canvas/touch via inheritance, tap-highlight transparent, overlay userSelect auto + outside #app, zero errors). The Safari-only loupe/callout behavior is operator-device-gated; this stamp doesn't claim it. Substance in the issue-comment above. Clean to merge.

APPROVED at head `533fc39f4cfbaf3d0a8273a49c816ad838f71973` (on current main). CSS-only. AC3 skeptic's pass done independently — no text input / textarea / contenteditable anywhere in #app (only range sliders + button labels + canvas), so suppression breaks nothing; debug overlay is outside #app and stays selectable. The #app-scope is superior to the tracker's `…body` enumeration (preserves AC3 by not touching body). vite build clean + `-webkit-touch-callout: none` confirmed in the built bundle (the Safari-only prop the probe can't assert). iPhone-13 probe PASS (user-select:none on #app/canvas/touch via inheritance, tap-highlight transparent, overlay userSelect auto + outside #app, zero errors). The Safari-only loupe/callout behavior is operator-device-gated; this stamp doesn't claim it. Substance in the issue-comment above. Clean to merge.
bosun merged commit 0b4480e98b into main 2026-06-21 19:49:11 +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!61
No description provided.