Dialogue · Design notes · The virtual keyboard
← 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 its URL-bar sequel (v486 → v488): the composer riding Chrome Android's address bar on a plain swipe. 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.
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.

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. 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).