fix(compose): the keyboard covered the window on an iPad and left a gap on an iPhone

Two reports, one misunderstanding. While the on-screen keyboard is open, iOS
goes on telling a page that the screen is its full height — only the *visual*
viewport shrinks — and a `fixed` element is placed against the layout one. So
anything anchored to the bottom of the screen sits behind the keyboard and
believes it does not. Android shrinks the layout viewport too
(`interactive-widget=resizes-content`), which is the whole reason the same
window looked right there.

The phone window already answered this: below md it sizes itself from
`visualViewport`, so the keyboard ends up below it. Above md nothing ran at
all — `_applyMobile` called `_unwatchViewport()` on that branch — so the dock
card kept its `bottom: 1rem` from an edge that was now keyboard, with the
action bar and Send underneath it and no gesture that brought them back.

The controller now tracks the viewport at every width and publishes
`--keyboard-inset` on <html>; the dock wrapper and the card's height cap read
it from app.css. The card moves up AND caps against the room left, because
lifting a 600px card on a screen with a keyboard on it otherwise pushes its
title bar off the top.

The iPhone gap was the other half of the same thing.
`env(safe-area-inset-bottom)` also describes the layout viewport, so it went on
reserving the home indicator's 34px against a window whose bottom edge had
become the top of the keyboard: clearance turned into a dead band of window
between the action bar and the keys. It is dropped while the keyboard is up.

Three things an adversarial review of the first draft found, all of them real:

  • Pinch zoom read as a keyboard. `innerHeight` ignores the scale and
    `visualViewport.height` does not, so zooming to 2x reported half the screen
    as keyboard with none on it — and `scroll` is subscribed as well as
    `resize`, so panning resized the card continuously under the user's finger.
    Zoom is deliberately available here (WCAG 1.4.4), so this was reachable. No
    arithmetic separates keyboard from zoom; at any scale but 1 the correction
    is withheld.

  • `--keyboard-inset` is one property written by every compose window, and
    above md a dock window and an inline reply are open at once by design. The
    first draft deleted it on any disconnect and argued in a comment that the
    survivor would re-assert it — which only holds when a blur caused the
    teardown. `_settleSend()` empties the inline frame from a timer eight
    seconds after a send, keyboard untouched, and would have dropped a dock
    window back under it mid-sentence. Cleared only by the last one out.

  • The height cap had no floor and the lift no clamp. An iPhone in landscape
    is 844x390 — wider than md, so it gets the dock card — and `90vh` less a
    200px keyboard computes to 151px against ~210px of header and action bar. A
    box shorter than its `shrink-0` children does not shrink them: it spills
    them out of its bottom edge while the scroller between them, which carries
    From, To, Subject and the message, collapses to nothing. Nothing fits a
    composer and a keyboard into 390px, and this does not pretend otherwise —
    it stops the fix from breaking the case it cannot fix.

tests/e2e/support/keyboard.ts stages what a keyboard leaves behind, since
Chromium's visual viewport never shrinks: the real VisualViewport with its
height, scale and offsetTop overridden, and a resize to announce it. Each of
the four faults above has a spec that goes red without its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-28 09:53:59 +02:00
1 parent 9290345204
commit dfe27b7219
7 files changed
+662 -32

No files matched your search

+12 -2
View File
@@ -170,7 +170,10 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
reason.
90vh still caps it on a short screen, and the height is under that on
anything taller than about 670px. #}
anything taller than about 670px. With a keyboard up the cap becomes
90vh minus the keyboard — see `.compose-dock .compose-window` in
app.css, which is what keeps a card lifted clear of an iPad's keyboard
from running off the top of the screen instead. #}
h-[600px] max-h-[90vh] w-[520px] rounded-pane shadow-float
pane-flat border border-line mb-4
{% endif %}
@@ -181,7 +184,14 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
takes it over from visualViewport, so the virtual keyboard shrinks
the window instead of covering its bottom row. The safe-area padding
sits here rather than on body: a fixed element is outside body's
box. #}
box.
The bottom half of that padding is dropped by the controller while the
keyboard is up. `env(safe-area-inset-bottom)` describes the layout
viewport, which the keyboard does not touch, so it keeps reporting the
home indicator's 34px — and against a window sized to the VISUAL
viewport those 34px are no longer clearance, just a dead strip of
window between the action bar and the keyboard. #}
max-md:fixed max-md:inset-x-0 max-md:top-0 max-md:z-50
max-md:w-full max-md:h-[100dvh] max-md:max-h-none
max-md:m-0 max-md:rounded-none max-md:border-0 max-md:shadow-none