Dialogue · Design notes · Text selection on touch
← Design notes

Text selection on touch

The ten-round build (v490–v499, 2026-07-18) of the double-tap text sheet — WeChat's 双击 gesture: double-tap a chat bubble and its text opens full-screen, freely selectable, to ✦ Save line or ⧉ Copy. It should have been a weekend feature. It cost ten device-tested rounds because letting a user select text on a touch phone is one of the web's genuine dark corners — the platform's own selection UI can't be styled or suppressed, and the two mobile engines disagree on the one API you need. This is the map, so the next selectable surface skips the war.

The one sentence: if you want text-selection that looks like your app and not the OS's, you must give up native selection entirely (user-select:none) and rebuild the whole thing — highlight, handles, word-select, hit-testing — yourself. Half-measures lose to the platform every time.

Why native selection can't be tamed

A real text selection on a phone drags OS chrome along with it — chrome the page has no handle on:

We fought all three with escalating hacks — a non-editing contenteditable with inputmode="none", programmatic focus-then-select, drawing our own handles on top of the native ones — and lost every time, because the native handles are drawn by the OS the moment a native selection exists, full stop. On real devices the user saw two sets of handles on Android and the OS callout instead of ours on iOS. The only move that works is to remove the thing that summons them: have no native selection at all.

The architecture that finally worked

The ten rounds

vWhat that round learned
490Built: double-tap → full-screen sheet, pre-selected, ✦ Save line · ⧉ Copy pill; Copy moved onto the action bar.
492Device round 1: 82px top so text clears the ‹ back button; overscroll-behavior:contain so the scroll doesn't walk the URL bar (the keyboard lesson); a non-editing contenteditable to try for real handles + kill the search panel.
493Native handles proved unreliable — drew our own violet ball-on-stem handles (still riding the native selection at this point).
494–497The font wouldn't grow on device though every emulation was right → inline-!important stamp; then sized it as 1.4× the bubble, measured live.
498The turn: Android showed our handles and the OS's; iOS popped the OS callout. Root cause = native selection. Went fully self-drivenuser-select:none, own highlight + handles + gestures.
499iOS then responded to nothing: caretRangeFromPoint is null under user-select:none on WebKit → switched to the measurement hit-test. Added tap-to-deselect.

The sibling trap: the suppressed click

The same "touch lies" theme bit once more a few days later, in selection mode rather than the sheet (v500–v502). Symptom: after the entry long-press, tapping a second bubble did nothing — but the bubble visibly dipped, so the touch was landing. The cause: a mobile browser suppresses the click that follows a long-press gesture. pointerdown and pointerup both arrive; no click ever does — so a toggle that waits for a click silently never runs.

Fix: in select mode, touch/pen toggles on pointerup, not click — with a ~10px movement check so a scroll never selects, the pointer recorded only while already selecting (so the entry hold's own release can't toggle), a ~700ms window so a trailing click can't double-toggle, and mouse left on the click path. The emulator always delivers that click, so two earlier fixes tested green while the phone stayed broken. The rule: when a tap "does nothing" but the element visibly reacts, suspect a missing click — not your handler logic. (And check the live box build before believing a "still broken" report: one round was just a stale deploy — main had the fix, the box didn't.)

the pattern Every round that computed clean in the browser but failed on the phone was the same lesson wearing a new hat: the device is the only source of truth; the emulator lies about touch. Headless Chrome is a fine pointer with no native selection handles at all — so "native handles gone / iOS responds / callout suppressed" is unverifiable off-device by construction. Verify the logic in preview (hit-test returns sane offsets, word-bounds, range slicing, no console errors) and hand the feel to a real phone. Same theme as the virtual-keyboard war.
reuse The live implementation is .txt-sheet / the ts* functions in lib/room-ui.html; the Style Guide's Double-tap text sheet term is the component reference. The sheet's violet is seat-6 from the selection-wash colour study. Any future selectable surface — a note editor, a transcript view — should start from rule 1 and never reach for native selection on touch.

Post-mortem · written 2026-07-18 · deployed live at v499 · the live rule lives in lib/room-ui.html (.txt-sheet + tsCaretOffset)