Skip to content

iOS: composer send button still clipped on real iPhones after PR #19 #23

@goyamegh

Description

@goyamegh

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

  1. Open pi-web on iPhone Safari.
  2. Tap the composer to focus it (keyboard slides up).
  3. 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.).
  4. 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:

iOS typing papercut — composer send button clipped

(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:

  1. .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.
  2. 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

  • npm run typecheck — clean
  • npm run build — succeeds
  • npm run test:unit — 55/55 pass
  • Manual repro on iPhone 16 Pro Max / iOS 17 Safari — needs a tester with a real device. The headless playwright tests do not catch this regression because Chromium honours overflow: hidden strictly.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions