diff --git a/src/app/elements.ts b/src/app/elements.ts index c41eab3..5af9a45 100644 --- a/src/app/elements.ts +++ b/src/app/elements.ts @@ -129,6 +129,14 @@ export function getAppElements(): AppElements { export function syncAppHeight() { const height = window.visualViewport?.height || window.innerHeight; document.documentElement.style.setProperty("--app-height", `${height}px`); + // iOS Safari can scroll the document body when focusing an input near the + // bottom of the viewport (keyboard appearance). With our fixed-height, + // overflow:hidden layout that just leaves part of the UI — e.g. the + // composer footer or send button — visually clipped at the edges of the + // visual viewport. Snap any stray scroll back to the top. + if (window.scrollY !== 0 || window.scrollX !== 0) { + window.scrollTo(0, 0); + } } export function initAppHeightSync() { @@ -136,4 +144,15 @@ export function initAppHeightSync() { 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 }); } diff --git a/src/styles/base.css b/src/styles/base.css index 8912e71..facf749 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -14,12 +14,27 @@ --app-height: 100vh; } +@supports (height: 100dvh) { + :root { --app-height: 100dvh; } +} + * { box-sizing: border-box; } html, body { width: 100%; max-width: 100%; height: var(--app-height); overflow: hidden; + /* iOS Safari sometimes scrolls the html element when an input is focused + even with body { overflow: hidden }. Lock scroll position explicitly. */ + overscroll-behavior: none; +} +/* `overflow: clip` is stricter than `overflow: hidden` — it disables + programmatic scrolling entirely, which prevents iOS Safari from horizontally + scrolling the document while tracking the caret of a focused textarea. + Supported in Safari 16+, Chrome 90+, Firefox 81+. We keep `overflow: hidden` + above as a fallback for older engines. */ +@supports (overflow: clip) { + html, body { overflow: clip; } } body { margin: 0; diff --git a/src/styles/responsive.css b/src/styles/responsive.css index 338c555..b3c98bd 100644 --- a/src/styles/responsive.css +++ b/src/styles/responsive.css @@ -29,14 +29,58 @@ height: var(--app-height); min-height: 0; padding: 10px 0; + padding-top: max(10px, env(safe-area-inset-top)); padding-bottom: max(10px, env(safe-area-inset-bottom)); } + /* iOS Safari auto-zooms the page when the user focuses an input/textarea + whose font-size is < 16px. That zoom shifts the layout sideways, clips + the composer’s send button at the right edge, makes the session-bar + tabs overflow, and is what users experience as “the UI is a little + zoomed in”. Force 16px on every text input on mobile so iOS leaves the + page at the natural zoom level. */ + textarea, + input[type="text"], + input[type="search"], + input[type="email"], + input[type="url"], + input[type="tel"], + input[type="password"], + input:not([type]) { + font-size: 16px; + } .composer { margin: 0 10px; margin-bottom: max(0px, env(safe-area-inset-bottom)); + /* Defensive: never let the composer (or its footer) overflow the + viewport horizontally on iOS, where keyboard / focus-scroll quirks + can otherwise clip the send button at the right edge. */ + max-width: calc(100vw - 20px); + min-width: 0; + } + .composerFooter { + min-width: 0; + max-width: 100%; + } + /* Expanded composer needs to respect safe-area insets on iOS too. */ + .composer.expanded { + inset: max(10px, env(safe-area-inset-top)) + max(10px, env(safe-area-inset-right)) + max(10px, env(safe-area-inset-bottom)) + max(10px, env(safe-area-inset-left)); } .message.user { margin: 12px 10px 12px 0; } + /* On narrow phone screens the cwd is too long and pushes the right-side + status-bar buttons (bucket, settings) off-screen. Hide it on mobile; + the working directory is still surfaced via the empty-state chooser + and the session list. */ + .statusPath { + display: none; + } + .statusTitle { + max-width: min(60vw, 240px); + } + .contextMeterLabel { top: -13px; right: 0; diff --git a/src/styles/statusBar.css b/src/styles/statusBar.css index c215e54..b92cdcf 100644 --- a/src/styles/statusBar.css +++ b/src/styles/statusBar.css @@ -7,6 +7,9 @@ padding: 0 8px; height: 24px; flex-shrink: 0; + /* Prevent right-side buttons (settings, bucket, …) from being pushed + off-screen on narrow viewports if a child refuses to shrink. */ + overflow: hidden; } .statusBar .iconButton { flex: 0 0 auto;