Symptom
On real iPhones (iOS 17 / Safari, observed on iPhone 16 Pro Max), focusing the composer textarea and typing a moderately long line causes the composer footer to be clipped at the right edge of the visual viewport. The send button (paper-airplane) is rendered with only its left half visible; the rounded right corner of the composer card extends past the screen edge.
This is not reproducible in desktop Chromium even with iPhone device emulation — it's specific to iOS Safari's caret-tracking behaviour when a textarea is focused near the bottom of the viewport with the soft keyboard open.
Repro (real iPhone, Safari, app server reachable via tunnel):
- Open pi-web on iPhone Safari.
- Tap the composer to focus it (keyboard slides up).
- Type any line that is wider than ~70% of the textarea (e.g.
I want to fix the ui issues with ios app and raise a pr for it.).
- Observe: the send button at the right of the composer footer is cut off; the composer card's right border is past the viewport edge.
Screenshot from the original report:

(See .pi/web/artifacts/ios-typing-papercut.png in the working copy that filed this issue, or attach inline when posting.)
Why PR #19 is not enough
PR #19 added two defences:
.composer { max-width: calc(100vw - 20px); } and .composerFooter { min-width: 0; max-width: 100%; } — so the composer/footer can't be intrinsically wider than the viewport.
- Inside
syncAppHeight(), window.scrollTo(0, 0) snaps stray scroll back to the top — but only on visualViewport.resize / visualViewport.scroll and window.resize.
That is sufficient for "the layout has overflow" and "the keyboard opening shifts the visual viewport", but it does not cover the third path:
- iOS Safari, on
focus and on each subsequent keystroke, calls an internal scrollIntoView-equivalent on the focused input to keep the caret on screen. With our fixed-height shell and html, body { overflow: hidden }, iOS still scrolls the document body — overflow: hidden does not block programmatic scroll on iOS Safari.
- That scroll fires
window's scroll event, not visualViewport's scroll event (visualViewport scroll is for visual-zoom panning, not layout scroll). So the existing snap-back never runs, the document stays offset by some pixels, and the right side of the composer renders past the visual viewport.
This is also why my Chromium playwright probe at iPhone 16 Pro Max viewport (430×932 with the keyboard simulated by shrinking height) reports composer.right = 419 and viewport.width = 430 — fully inside the viewport — yet the real device shows clipping.
Proposed fix (verified locally: typecheck + build + 55/55 unit tests pass)
Two small additions on top of PR #19:
1. Listen to window scroll, not just visualViewport scroll
src/app/elements.ts — extend initAppHeightSync:
export function initAppHeightSync() {
syncAppHeight();
window.addEventListener("resize", syncAppHeight);
window.visualViewport?.addEventListener("resize", syncAppHeight);
window.visualViewport?.addEventListener("scroll", syncAppHeight);
// iOS Safari ignores `html, body { overflow: hidden }` when the user types
// in a focused textarea: it scrolls the document horizontally to keep the
// caret in view, which clips the composer footer (send button) at the right
// edge. The visualViewport `scroll` event does NOT fire for document scroll,
// so we also need a window-level scroll listener to snap back.
window.addEventListener("scroll", syncAppHeight, { passive: true });
// After focusing an input, iOS may scroll once more on the next frame;
// schedule a follow-up snap-back so the UI never lands offset.
document.addEventListener("focusin", () => {
requestAnimationFrame(syncAppHeight);
}, { passive: true });
}
2. Use overflow: clip (Safari 16+) for stricter containment
src/styles/base.css — keep overflow: hidden as a fallback, opt into clip when supported:
@supports (overflow: clip) {
html, body { overflow: clip; }
}
overflow: clip differs from overflow: hidden in that it forms no scroll container at all — scrollIntoView and other programmatic scrolls become no-ops. This belt-and-suspenders pairs with the JS snap-back: even if iOS tries to scroll, there's nothing to scroll.
Diff
src/app/elements.ts | 11 +++++++++++
src/styles/base.css | 8 ++++++++
2 files changed, 19 insertions(+)
Verification
Suggested follow-up
We should add a Playwright test that explicitly fires window.scrollTo(...) after focusing the textarea (with a forced overflow probe) and asserts window.scrollX === 0 after a microtask, so this doesn't regress silently again. Tracked separately because it requires a small amount of plumbing to bypass the overflow: hidden guard during the test.
Related
- Refines #19 (
fix: keep iOS UI inside the visual viewport).
Symptom
On real iPhones (iOS 17 / Safari, observed on iPhone 16 Pro Max), focusing the composer textarea and typing a moderately long line causes the composer footer to be clipped at the right edge of the visual viewport. The send button (paper-airplane) is rendered with only its left half visible; the rounded right corner of the composer card extends past the screen edge.
This is not reproducible in desktop Chromium even with iPhone device emulation — it's specific to iOS Safari's caret-tracking behaviour when a
textareais focused near the bottom of the viewport with the soft keyboard open.Repro (real iPhone, Safari, app server reachable via tunnel):
I want to fix the ui issues with ios app and raise a pr for it.).Screenshot from the original report:
(See
.pi/web/artifacts/ios-typing-papercut.pngin the working copy that filed this issue, or attach inline when posting.)Why PR #19 is not enough
PR #19 added two defences:
.composer { max-width: calc(100vw - 20px); }and.composerFooter { min-width: 0; max-width: 100%; }— so the composer/footer can't be intrinsically wider than the viewport.syncAppHeight(),window.scrollTo(0, 0)snaps stray scroll back to the top — but only onvisualViewport.resize/visualViewport.scrollandwindow.resize.That is sufficient for "the layout has overflow" and "the keyboard opening shifts the visual viewport", but it does not cover the third path:
focusand on each subsequent keystroke, calls an internalscrollIntoView-equivalent on the focused input to keep the caret on screen. With our fixed-height shell andhtml, body { overflow: hidden }, iOS still scrolls the document body —overflow: hiddendoes not block programmatic scroll on iOS Safari.window'sscrollevent, notvisualViewport'sscrollevent (visualViewportscrollis for visual-zoom panning, not layout scroll). So the existing snap-back never runs, the document stays offset by some pixels, and the right side of the composer renders past the visual viewport.This is also why my Chromium playwright probe at iPhone 16 Pro Max viewport (430×932 with the keyboard simulated by shrinking height) reports
composer.right = 419andviewport.width = 430— fully inside the viewport — yet the real device shows clipping.Proposed fix (verified locally: typecheck + build + 55/55 unit tests pass)
Two small additions on top of PR #19:
1. Listen to
windowscroll, not justvisualViewportscrollsrc/app/elements.ts— extendinitAppHeightSync:2. Use
overflow: clip(Safari 16+) for stricter containmentsrc/styles/base.css— keepoverflow: hiddenas a fallback, opt intoclipwhen supported:overflow: clipdiffers fromoverflow: hiddenin that it forms no scroll container at all —scrollIntoViewand other programmatic scrolls become no-ops. This belt-and-suspenders pairs with the JS snap-back: even if iOS tries to scroll, there's nothing to scroll.Diff
Verification
npm run typecheck— cleannpm run build— succeedsnpm run test:unit— 55/55 passoverflow: hiddenstrictly.Suggested follow-up
We should add a Playwright test that explicitly fires
window.scrollTo(...)after focusing the textarea (with a forced overflow probe) and assertswindow.scrollX === 0after a microtask, so this doesn't regress silently again. Tracked separately because it requires a small amount of plumbing to bypass theoverflow: hiddenguard during the test.Related
fix: keep iOS UI inside the visual viewport).