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