How the chat shell meets the on-screen keyboard — the nine-round war of 2026-07-17
(v469 → v477), its three separate root causes, the final architecture, and the traps that cost a round
each — plus two sequels: the URL-bar sequel (v486 → v488), the composer riding Chrome Android's address
bar on a plain swipe, and the emoji-search sequel (v657), where the keyboard's own stand-in asked for a
keyboard. Every mechanism lives in lib/room-ui.html → fitShellToKeyboard() and the
shell CSS, plus one viewport-meta key. Read this before touching anything keyboard-, composer-, scroll-shell-,
or --app-h-related.
| bug | what the user saw | the actual cause | the fix |
|---|---|---|---|
| Android covered |
Bubbles hidden behind the keyboard; second open worse than the first. | Chrome 108 changed the default — the keyboard stopped resizing the layout viewport
(resizes-visual), so the flex shell no longer shrank; our JS band-aids raced it.
The re-pin also trusted the pinned flag, which a resize-driven scroll event flips. |
interactive-widget=resizes-content in the viewport meta → native again (v471).
Stickiness measured from live geometry, never a flag (v470). |
| iOS gap |
A dead strip between the composer and the keys. | The composer's env(safe-area-inset-bottom) clears the home indicator — but the
keyboard covers that strip and iOS keeps reporting the inset (~34px), floating the composer
that far above the keys. (Not offsetTop — that fix was a no-op.) |
body.kbd-up drops the inset padding while the keyboard is up; it returns when the
keys go (v470). Set with the pre-shrink, same paint. |
| iOS shove |
The whole page shoves up — topbar off-screen — then bubbles catch up, then the topbar snaps back. | Safari reveals a focused field by PANNING the page (it never resizes). Countering after the
fact always loses: the pan is compositor-level and, for a native tap, the decision is bound
to the gesture itself — before focusin even fires. |
Make the room before the keyboard exists: pre-shrink by the cached height + a forced layout
flush at focusin, and route direct composer taps through programmatic focus
(v473 → v477, see the timeline). |
| piece | where | what it does |
|---|---|---|
| the meta key | <meta name="viewport"> |
interactive-widget=resizes-content — Android/Firefox shrink the layout viewport natively.
WebKit ignores it (harmless). |
| setShell(h) | fitShellToKeyboard() |
The one writer of --app-h. Same-task bottom re-pin (reads live chat geometry
before the reflow, writes scrollTop in the same task → composer + bubbles commit
in ONE paint), lastVal dedupe, and a zero-height guard (a hidden boot reports
clientHeight 0; writing it freezes the shell — skip, the CSS falls back to
100dvh). |
| write() | 〃 | The measurer: kbdUp = typing && (layoutH − vv.height − vv.offsetTop) > 120. With the
keyboard up the shell height is vv.height + vv.offsetTop (the keyboard's top edge — never
vv.height alone). Toggles body.kbd-up; on iOS it also measures and caches
the true keyboard height per orientation (localStorage mad-kbh) for the next pre-shrink. |
| clearWhenNative | 〃 | v653 — the native path owes the reader a re-pin too. Owner: 「on phones, click on the text area
of composer doesn't shift the bubbles up when we are at the last message」. The re-pin lives in
setShell, and on Android this branch returns before ever reaching it —
kbdUp is false here by construction, because the layout viewport shrinks WITH the
keys. So CSS sized the shell correctly and the composer seated itself above the keyboard, while the
conversation never moved and the last message stayed behind the keys. Now re-pins via
scrollSoft() on this path, behind two guards that keep it off the「sticky scroll」regression:
a drop of >120px (the same threshold kbdUp uses — Android's URL bar is ~56px and
must not qualify) and only while a field is focused. ⚠ It asks pinned, not a fresh
measurement: a shrinking scrollport raises max-scrollTop rather than clamping it, so
no scroll event fires and the flag still holds the pre-shrink answer — measuring after the fact
would read ~one keyboard-height from the bottom and always decline. ⚠ Wrapped in try//catch
for the dead zone below. |
| preShrink(field) | 〃 | iOS-only. Shrinks by the cached height, sets kbd-up, then forces a layout flush
(getBoundingClientRect) so Safari's reveal decision sees the new geometry — a style write
alone leaves layout dirty and Safari measures the OLD one. A 1.2s guard restores the shell if no
keyboard materialises (hardware keyboards). First-ever keyboard has no cache → measured once via the
old path. |
| the tap router | 〃 (touchend, capture) |
iOS-only, unfocused #ta only. A native tap-focus lets Safari decide its pan from
the gesture — earlier than any event we get. So: capture the caret target from the tap point on the
OLD layout (a DOM Range survives the relayout), preventDefault() the touchend (kills the
native click→focus and with it the early reveal), preShrink, then
focus({preventScroll}) + place the caret. Once focused, native taps move the caret as
usual; a moved touch (scroll) passes through. |
| instant dismiss | 〃 (focusout) |
Restore the full shell the instant the field blurs (same-task) — the layout viewport never changed,
so the shell just paints in behind the collapsing keys. Skipped when focus is merely moving
to another editable (relatedTarget) so the keyboard doesn't yo-yo; a 250ms re-fit is the
belt. |
| counterPan | 〃 (vv scroll + window scroll) |
Belt-and-suspenders: any residual reveal-pan is countered the frame it happens
(scrollTo(0,0)). With the pre-shrink working it should never fire for the composer. |
| the debounce | 〃 (vv resize) |
Small vv fluctuations (a hardware keyboard's predictive bar wiggles on every keystroke) settle for 160ms; a >120px jump (keyboard open/close) is tracked in the same frame — debouncing the jump was a visible late snap. |
| round | change | device verdict | |
|---|---|---|---|
| v469 | rAF re-pin + vv.height + offsetTop + desktop keeps focus after send |
Keyboard stopped covering bubbles on iPhone, but the transition flashed (composer first, bubbles a frame later); iOS gap unmoved (offsetTop was 0). | partial |
| v470 | Same-task re-pin (no rAF) · big-jump skips the debounce · kbd-up drops the safe-area inset · live-geometry stickiness |
iOS gap fixed (user-confirmed). Flash reduced, not gone. Android regressed differently. | partial |
| v471 | interactive-widget=resizes-content — Android goes native (+ the zero-height boot guard) |
Android fully fixed (user-confirmed). iOS shove remains. | android ✓ |
| v472 | Counter the pan the frame it happens (vv/window scroll listeners) | Shove persisted — reacting loses to the compositor. | dead end |
| v473 | Pre-shrink at focusin by the cached keyboard height |
Shove persisted — the style write left layout dirty; Safari measured the old geometry. | dead end |
| v474 | Pre-shrink at pointerdown + forced layout flush |
Open fixed for taps landing beside the textbox — but a direct textbox tap broke: the relayout yanked the target from under the finger, iOS cancelled the tap (no caret, no keyboard, ghost shove, slow 1.2s restore). | partial |
| v475 | Instant dismiss at focusout (the close had started reading as slow by contrast) |
Dismiss lightning again. | close ✓ |
| v476 | Drop the pointerdown trigger (it cancelled its own tap); focusin+flush only | Textbox taps worked again but the flash returned — for a native tap-focus, Safari decides before focusin. The user's two-zone isolation (beside-the-box clean, on-the-box flashing) was the decisive diagnostic. | dead end |
| v477 | Route the unfocused composer's tap through programmatic focus (range from the tap point → preventDefault → preShrink+flush → focus) | Problem solved (user-confirmed). Both directions at-the-touch. | solved |
fitShellToKeyboard() runs its first
write() at boot, before later let/const declarations
(chat, pinned, FINE_POINTER) exist — and in the temporal dead zone
even typeof throws, killing the whole app script (blank chat list, ME undefined).
Read late-declared bindings only through try/catch, or keep a local copy.touchend+preventDefault or after the tap completes.--app-h without a forced flush
(getBoundingClientRect) means it measures the old geometry. The v473 lesson.env(safe-area-inset-bottom) still being reported under the keyboard; an earlier "strip" in the
Me-sheet saga was a box-shadow. Visual artifacts need paint-level reasoning — see the
visual-bug-verification memory.Months of keyboard peace, then a cousin surfaced on Android with no keyboard involved: a plain swipe
up the chat made Chrome's URL bar hide, dragging the whole shell (composer + topbar) up — which then
"settled" back once layout caught up; a swipe down squeezed the composer off-screen and it reappeared. Same
subsystem (the shell, --app-h), a different culprit — not the keyboard, but the scroll shell's own
geometry.
overscroll-behavior in the
abstract cost two rounds; comparing the two scrollers cracked it in one.| round | change | verdict | |
|---|---|---|---|
| v486 | Hand the shell to 100dvh when nothing holds the keyboard's space (stop writing a debounced JS height) |
The resting white gap under the composer went away — but dvh only recomputes at the
end of Chrome's URL-bar animation, so the composer now visibly rode the transition and snapped. |
traded |
| v487 | Lock the root: .chat{overscroll-behavior:contain} + html,body{overscroll-behavior:none} |
Real hygiene (no pull-to-refresh, no scroll-chaining) — but not the fix. The stable list scroller
carries no overscroll-behavior at all, which proved the cause lay elsewhere. |
symptom |
| v488 | SEAL the fixed shell: .mainpane{overflow:hidden} |
The fix. Gives the chat pane the list's "nothing escapes the viewport" property, so the spill (below) can never reach the URL-bar zone — whatever the measurement does. | sealed |
.chat's
box spills past it — a stale --composer-h over-pulling the negative margin — into the strip Chrome
hides its bar to reveal. The seal, .mainpane{overflow:hidden}, clips it back.What the split test exposed. The list's scroller (.drawer-body) is plain flex — no negative
margins — so it fits its fixed parent exactly: its bottom is the viewport edge. The chat's scroller
(.chat) bleeds under the frosted bars via
margin-bottom:calc(-1 * var(--composer-h)), and --composer-h — set from
comp.offsetHeight by a ResizeObserver — had drifted stale: 87px while the composer actually
rendered 76 (and it varied load to load: 78, 87…). That surplus over-pulls the bottom margin, so
.chat hangs a few px below the viewport (measured: chat.bottom 823 vs
innerH 812), and .mainpane was overflow:visible, so it spilled. Content in
the URL-bar strip is exactly what Chrome Android reveals by retracting the bar. Correcting --composer-h
by hand snapped chat.bottom 823 → 812 and the spill 11 → 0 (the proof) — but the durable fix is the
seal, which doesn't ride on a flaky measurement.
offsetHeight is only ever as right as the last measurement — and it drifts. Seal the
container (overflow:hidden on the fixed shell) so the drift can't matter, instead of trying to
keep the number perfect.100dvh tracks the URL bar — but only after the animation. It
kills the resting gap (better than a debounced JS height), yet it recomputes at the end of the URL-bar
transition, so it can't smooth the movement during it. The real cure is to stop the bar toggling at
all.overscroll-behavior looked right — but the stable list had none, a ten-second observation
that would have skipped a round. When your theory contradicts a working case, the theory is wrong.The emoji panel (gap 12) is built as the keyboard's stand-in: the measured keyboard height, the keyboard's place, the keyboard's reserved space — which is exactly what makes the ⌨↔😀 swap move nothing. Then the panel grew a search field of its own, and a stand-in that asks for the real thing has a problem: tap 「Search emoji」 and the keys land squarely on the box you just tapped. Owner-caught on Android, with WhatsApp's arrangement as the brief — the whole screen shifts up; the field and its results stay above the keys.
occupy(px, lift) carries the answer.Math.max(200, …) floor quietly refused to give back the difference. A bottom-anchored panel with
nowhere to go pushes its own top off the screen, and its top is the search field: the report showed a row
of emoji and the tab strip, and no field at all. The fix is structural, not a better estimate: a passenger
is clamped to floorH − RIDE_MIN and told what it got via --ride-h, which is
its CSS height — so the two numbers cannot disagree whatever the keyboard turns out to be.--kb-cover) and it becomes a strip — the field over a single horizontally-scrolling row,
~100px, at the height the shell granted it (--ride-h).| piece | what changed |
|---|---|
| floorH | One expression for「the bottom edge of the space still ours」: the keys' top edge when
kbdUp (iOS), layoutH when a field is focused and the platform already shrank it
(Android), fullH otherwise. The occupant is then subtracted from that — previously it
was subtracted only from the keyboard-free height, so a keyboard and a panel at once ignored the panel. |
| --kb-cover | Published by write() (and by preShrink, in the same frame as the shrink): the px
the keys hide at the bottom of the layout viewport. It is the riding panel's bottom, and
it is non-zero on iOS only — Android's layout viewport already ends at the keys. |
| occupy(px, lift) | The passenger flag (see lesson ① above). release() clears it. |
| --ride-h | What the passenger was actually GRANTED: clamp(60, px, floorH − RIDE_MIN), recomputed on
every write and set as the panel's CSS height. Lesson ② in one variable — the panel asks for its natural
size (a field + one row) and is drawn at whatever the live space allowed. |
| the strip | .emoji-panel.riding — no section head, no tab strip, .ep-body becomes a
display:flex row with overflow-x:auto, and paint() renders recents
only (or results only). Search replaces the tabs; a row that must fit between a composer and a keyboard
cannot spend a line on a label. |
| the pick | preventDefault() on a cell's pointerdown while riding — a tap on a button
blurs the field and the keys leave with it, which cost a keyboard per emoji. Suppresses the focus change
only; touch panning answers to touch-action, so the strip still scrolls. |
| the restore | Deferred one beat (90ms) on focusout, and a pick re-focuses the field: tapping a result can
blur it for an instant, and a full-height panel in between slams the keys shut and straight back open. |
Math.max(200, floorH − lend) is right; Math.max(200, floorH) is not — on a landscape
phone the keys' top edge is genuinely under 200px, and flooring it there pushes the composer back under the
keyboard. Clamp what you computed, never what you measured.#kbdebug in the
URL — the on-screen probe prints main-thread blocks, vv event counts, and the live viewport numbers while you
type. Then split the symptom: gap above the keys → is body.kbd-up present while typing, and
does the composer's computed padding-bottom drop? page shoves → is the pre-shrink firing
(does --app-h shrink before the keyboard finishes), is the kb-height cache warm
(localStorage mad-kbh — the first-ever keyboard on a device still measures via the old path), and
did the tap land on the composer (the router covers #ta only — a new bottom-anchored editable
needs adding)? bubbles covered on Android → is the viewport meta intact and is the browser Chrome 108+ /
Firefox 132+? app boots blank → suspect a TDZ throw in the boot write() (trap ①).
composer rides the URL bar on a plain swipe (no keyboard) → compare
document.querySelector('.chat').getBoundingClientRect().bottom to innerHeight: it must
not exceed it. If it does, --composer-h has drifted (the negative-margin bleed over-pulls) — confirm
.mainpane computes overflow:hidden (the v488 seal), and sanity-check against the Chats
list, whose .drawer-body should always end exactly at the viewport.
the emoji search box is under (or above) the screen edge → with the field focused, the panel's
getBoundingClientRect() must satisfy top ≥ 0 and
bottom == clientHeight − kbCover, and --app-h + --ride-h must equal the space above the
keys. Read --kb-cover (the keyboard height on iOS, 0px on Android) and
--ride-h; if --ride-h is missing the lift flag never reached
occupy(), and if the panel is taller than --ride-h something is still writing an inline
height over the .riding rule. And remember the SW
ritual: no fix is on a device until BUILD + CACHE were bumped and the shell actually swapped (check the build
badge).Companion pages: Rendering & speed · the composer's layout history lives with the STT/composer memory. The nine-round keyboard war shipped 2026-07-17 (v477, author-tested on iPhone / Android Chrome / desktop); the URL-bar sequel shipped 2026-07-18 (v488, on the box — on-device Android confirmation of the seal owed). The emoji-search sequel landed 2026-07-30 (v657, corrected the same day by v658 after device reports from both platforms): probe-verified at 375×812 and again at a simulated post-keyboard 375×392, where the strip sits at 311–392 with the shell ending at exactly 311 and the field on screen — the geometry that failed before. On-device re-confirmation is owed; a desktop probe can raise no keys.