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.
user-select:none) and rebuild the whole thing — highlight, handles, word-select, hit-testing — yourself. Half-measures lose to the platform every time.A real text selection on a phone drags OS chrome along with it — chrome the page has no handle on:
-webkit-touch-callout:none only half-suppresses it and kills selection with it.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.
user-select:none on the sheet. This single rule kills Android's handles, iOS's callout, and Chrome's search bar all at once, by construction — there is nothing for them to attach to.
Desktop keeps native selection (a mouse has none of these quirks): branch on FINE_POINTER, and on fine pointers re-enable user-select:text + a styled ::selection.textContent), so the selection is just two integer offsets {s,e}. Paint your own highlight as <div> boxes over range.getClientRects() (absolute inside the scroller, so they ride content scroll, behind the text). Draw your own handles. Read copy/save from text.slice(s,e).caretRangeFromPoint returns null for user-select:none text on WebKit. Chrome/Android ignores user-select and still returns a caret — which is exactly why Android worked and iOS went stone dead (no tap, long-press, or handle-drag responded: every hit-test returned null).
Fix: hit-test by measurement, not by caretRangeFromPoint. Binary-search the character offset whose glyph box sits at (x,y) using Range.getBoundingClientRect() — which is pure layout, independent of user-select, and works on every engine. O(log n) per hit.Intl.Segmenter(_, {granularity:"word"}) (handles CJK and latin; a non-whitespace run is the fallback). Drag a handle to adjust one boundary, the other stays fixed. A lone tap — on the text or the padding — deselects (deferred 350ms so a double-tap still wins the race).font-size at bubble size while every emulation computed it correctly (raw dev server, fresh shell — not cache, not minify). Only el.style.setProperty("font-size", …, "important") at open time landed on device. And derive it from the live element — the sheet reads 1.4 × getComputedStyle(the tapped bubble).fontSize — so it tracks the Text-size dial with no hardcoded px.| v | What that round learned |
|---|---|
| 490 | Built: double-tap → full-screen sheet, pre-selected, ✦ Save line · ⧉ Copy pill; Copy moved onto the action bar. |
| 492 | Device 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. |
| 493 | Native handles proved unreliable — drew our own violet ball-on-stem handles (still riding the native selection at this point). |
| 494–497 | The font wouldn't grow on device though every emulation was right → inline-!important stamp; then sized it as 1.4× the bubble, measured live. |
| 498 | The turn: Android showed our handles and the OS's; iOS popped the OS callout. Root cause = native selection. Went fully self-driven — user-select:none, own highlight + handles + gestures. |
| 499 | iOS then responded to nothing: caretRangeFromPoint is null under user-select:none on WebKit → switched to the measurement hit-test. Added tap-to-deselect. |
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.)
.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)