← Design notes

The virtual keyboard

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.htmlfitShellToKeyboard() and the shell CSS, plus one viewport-meta key. Read this before touching anything keyboard-, composer-, scroll-shell-, or --app-h-related.

The mental model in one line: Android resizes, iOS pans. On Android one meta key makes the browser shrink the layout viewport natively — our JS just follows. On iOS the viewport never changes; Safari pans the page to reveal a focused field — so we make the room before the keyboard exists, and route composer taps through programmatic focus so Safari never has a reason to pan.

The two platforms

Android · Chrome 108+ interactive-widget=resizes-content layout viewport shrinks NATIVELY, in step with the keyboard animation composer sits here keyboard JS = a follower (re-pin only) iOS · WebKit (ignores the meta) visualViewport JS is the driver shell pre-shrunk at focus, by the CACHED keyboard height + a flush composer already clear keyboard nothing to reveal → no pan the layout viewport NEVER changes on iOS — Safari pans unless the caret is already clear
fig 1 · one meta key on Android; measured pre-shrink on iOS. Both end with the composer seated on the keyboard's top edge.

The three root causes (they shared one symptom)

bugwhat the user sawthe actual causethe 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).

The final architecture (v477)

piecewherewhat 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 itkbdUp 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.

The nine rounds — what each one taught

roundchangedevice verdict
v469rAF 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
v470Same-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
v471interactive-widget=resizes-content — Android goes native (+ the zero-height boot guard) Android fully fixed (user-confirmed). iOS shove remains. android ✓
v472Counter the pan the frame it happens (vv/window scroll listeners) Shove persisted — reacting loses to the compositor. dead end
v473Pre-shrink at focusin by the cached keyboard height Shove persisted — the style write left layout dirty; Safari measured the old geometry. dead end
v474Pre-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
v475Instant dismiss at focusout (the close had started reading as slow by contrast) Dismiss lightning again. close ✓
v476Drop 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
v477Route 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

Traps — each one cost a round

The sequel — the composer riding the URL bar (v486 → v488)

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.

The lesson in one line: a stable sibling view is your best oracle. The Chats list never moved the URL bar; the chat pane did — same browser, same swipe. The difference between the two is the bug. Reasoning about viewport units and overscroll-behavior in the abstract cost two rounds; comparing the two scrollers cracked it in one.
roundchangeverdict
v486Hand 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
v487Lock 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
v488SEAL 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
Chats list · stable top bar .drawer-body plain flex — no negative margins bottom = viewport ✓ fits exactly · URL bar stays put Chat pane · rode the bar top bar .chat margin-bottom: -var(--composer-h) stale 87 vs real 76 the fold hangs 11px past → URL-bar zone
fig 2 · the same fixed viewport. Left: the list scroller ends at the fold. Right: .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.

The second sequel — two bottom surfaces at once (v657)

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.

Two lessons. ① The occupant has to say which kind it is. "Panel up and keys up" is two states with identical geometry — the panel replacing the keyboard (it reserves its space one statement before the composer blurs) and the panel riding above it. Subtracting in the first case writes a doubly-shrunk shell; not subtracting in the second buries the field. No measurement can tell them apart, so occupy(px, lift) carries the answer.
② The height a surface is DRAWN at and the height the shell RESERVES must be one number — and it must come from the space that exists now, never from a remembered measurement of the thing you are making room beside. That is the whole of the v657 → v658 bust below.
the v657 bust · Android The first cut sized the riding panel from the cached keyboard height. On a phone whose real keys were ~430px against a 300px cache, the reservation (≈290) plus the keyboard (430) came to more than the viewport — and the shell's own 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.
before · the stand-in is stood on top bar emoji panel 300 keyboard 420 the search field is under the keys after v657 · a passenger top bar chat + composer shell ≥ 190 search one scrolling row --ride-h keyboard 420 bottom = --kb-cover · height = --ride-h
fig 3 · one 812px screen, a 420px keyboard. Left: the panel keeps the keyboard's height and the keyboard's place, so the keys cover it whole. Right: the panel's bottom moves to the keys' top edge (--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).
piecewhat 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.

If it regresses

debug recipe Open the app with #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.