From ae8d0bf3fb82c88627fc7d525d042fcc96be5b30 Mon Sep 17 00:00:00 2001 From: rodboev Date: Tue, 7 Jul 2026 05:05:11 +0000 Subject: [PATCH] fix(#5701): keep the composer above iPad keyboards On touch-primary devices (iPad Safari) the on-screen keyboard shrinks the visual viewport but not the layout viewport, so the bottom-pinned composer sits behind the keyboard. Compute a --keyboard-bottom-inset from visualViewport and pad the composer up by it; clear it when the keyboard closes / on non-touch / no-visualViewport. Reuses the existing visualViewport + pageshow hooks; no-op on desktop/fine-pointer. Co-authored-by: nesquena-hermes --- static/boot.js | 23 +++++++++++++++ static/style.css | 6 +++- tests/test_mobile_layout.py | 56 ++++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 2 deletions(-) diff --git a/static/boot.js b/static/boot.js index ed99427d..07891e07 100644 --- a/static/boot.js +++ b/static/boot.js @@ -149,6 +149,26 @@ function _isPhoneWidthViewport(){ return window.matchMedia('(max-width: 640px)').matches; } +function _isTouchKeyboardViewport(){ + try{return matchMedia('(hover:none) and (pointer:coarse)').matches&&!_hasFinePointerCoexisting();}catch(_){return false;} +} + +function _syncKeyboardBottomInset(){ + const root=document.documentElement; + if(!root) return; + if(!window.visualViewport||!_isTouchKeyboardViewport()){ + root.style.removeProperty('--keyboard-bottom-inset'); + return; + } + const vv=window.visualViewport; + const inset=Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop))); + if(inset>0){ + root.style.setProperty('--keyboard-bottom-inset',`${inset}px`); + }else{ + root.style.removeProperty('--keyboard-bottom-inset'); + } +} + // Mobile PWA viewport reflow guard. When the on-screen keyboard / browser // chrome shows or hides, visualViewport (or a plain resize on browsers without // it) changes height without a layout invalidation, leaving the phone layout @@ -156,6 +176,7 @@ function _isPhoneWidthViewport(){ // (which applies a cheap GPU-promotion transform under the @media(max-width:640px) // rule) forces a repaint, then we resync the workspace panel + sidebar aria. function _forceMobileViewportReflow(){ + _syncKeyboardBottomInset(); if(!_isPhoneWidthViewport()) return; const layout=document.querySelector('.layout'); if(!layout) return; @@ -2183,6 +2204,7 @@ window.addEventListener('resize',()=>{ // URL-bar collapse fire visualViewport resize/scroll rather than window resize. // Debounce a reflow so the phone layout repaints against the new geometry. if(window.visualViewport){ + _syncKeyboardBottomInset(); let _mobileViewportReflowTimer=0; const _scheduleMobileViewportReflow=()=>{ if(_mobileViewportReflowTimer) clearTimeout(_mobileViewportReflowTimer); @@ -3391,6 +3413,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer; // chrome aren't left in the stale bfcache snapshot. window.addEventListener('pageshow', async (event) => { if (!event.persisted) return; // fresh loads are handled by the IIFE above + _syncKeyboardBottomInset(); const _srch = document.getElementById('sessionSearch'); if (_srch) _srch.value = ''; if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear(); diff --git a/static/style.css b/static/style.css index c5d0e6e3..b3890ebc 100644 --- a/static/style.css +++ b/static/style.css @@ -3062,7 +3062,7 @@ .messages-inner{padding:12px 10px 20px;max-width:100%;overflow-x:clip;word-break:break-word;min-width:0;padding-left:max(10px,env(safe-area-inset-left,0));padding-right:max(10px,env(safe-area-inset-right,0));} .msg-body{padding-left:0;max-width:100%;} .msg-role{font-size:12px;} - .composer-wrap{padding:8px 10px 12px!important;padding-left:max(10px,env(safe-area-inset-left,0))!important;padding-right:max(10px,env(safe-area-inset-right,0))!important;} + .composer-wrap{padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important;padding-left:max(10px,env(safe-area-inset-left,0))!important;padding-right:max(10px,env(safe-area-inset-right,0))!important;} .composer-box{border-radius:12px;} .composer-box textarea{min-height:40px;} .composer-footer{padding:6px 8px 8px!important;gap:6px;flex-wrap:nowrap;align-items:center;} @@ -3432,6 +3432,10 @@ body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;} .composer-wrap{padding:10px 20px 14px;position:relative;z-index:10;} .composer-wrap::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(to bottom,transparent,var(--bg));pointer-events:none;} +@media (hover:none) and (pointer:coarse){ + .composer-wrap{padding-bottom:calc(14px + var(--keyboard-bottom-inset, 0px));} +} + /* Cron status badges: pill shape refinement */ .cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;} diff --git a/tests/test_mobile_layout.py b/tests/test_mobile_layout.py index d07a64f9..b6ad6afb 100644 --- a/tests/test_mobile_layout.py +++ b/tests/test_mobile_layout.py @@ -1048,7 +1048,7 @@ def test_safe_area_variables_available_for_pwa_shell(): assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS, ( "CSS must expose env(safe-area-inset-top) through --app-titlebar-safe-top" ) - assert "padding:8px 10px 12px!important" in CSS, ( + assert "padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important" in CSS, ( "Phone composer should keep the proven pre-cover-mode padding contract" ) @@ -1572,6 +1572,60 @@ def test_touch_device_inputs_meet_zoom_threshold(): ) +def test_touch_keyboard_inset_uses_touch_primary_media_query(): + """The keyboard inset path must key off touch-primary media queries.""" + boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + assert "function _isTouchKeyboardViewport()" in boot_js, \ + "boot.js must define a touch-keyboard viewport predicate" + assert "matchMedia('(hover:none) and (pointer:coarse)')" in boot_js or \ + 'matchMedia("(hover:none) and (pointer:coarse)")' in boot_js, \ + "touch keyboard inset eligibility must use the hover:none + pointer:coarse media query" + assert "any-pointer:fine" in boot_js, \ + "touch keyboard inset eligibility must exclude co-existing fine-pointer setups" + assert "!_hasFinePointerCoexisting()" in boot_js, \ + "touch keyboard inset eligibility must skip hardware-keyboard/trackpad devices" + + +def test_touch_keyboard_inset_writes_and_clears_css_variable(): + """visualViewport geometry must write and clear the keyboard inset variable.""" + boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + assert "setProperty('--keyboard-bottom-inset'" in boot_js, \ + "boot.js must write --keyboard-bottom-inset on touch keyboard viewport changes" + assert "removeProperty('--keyboard-bottom-inset')" in boot_js, \ + "boot.js must clear --keyboard-bottom-inset when the inset is zero or ineligible" + assert "Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)))" in boot_js, \ + "boot.js must compute the bottom inset from innerHeight and visualViewport geometry" + + +def test_touch_keyboard_inset_primes_during_visual_viewport_setup(): + """The existing visualViewport setup path must prime the inset immediately.""" + boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + setup_start = boot_js.index("if(window.visualViewport){") + setup_end = boot_js.index("window.visualViewport.addEventListener('resize'", setup_start) + setup_block = boot_js[setup_start:setup_end] + assert "_syncKeyboardBottomInset();" in setup_block, \ + "boot.js must sync the keyboard inset once during visualViewport setup" + + +def test_touch_keyboard_inset_primes_on_pageshow_restore(): + """BFCache restore must resync the inset before restore work continues.""" + boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8") + pageshow_start = boot_js.index("window.addEventListener('pageshow'") + pageshow_end = boot_js.index("const _srch = document.getElementById('sessionSearch');", pageshow_start) + pageshow_block = boot_js[pageshow_start:pageshow_end] + assert "_syncKeyboardBottomInset();" in pageshow_block, \ + "boot.js must sync the keyboard inset during pageshow restore" + + +def test_touch_keyboard_inset_applies_to_composer_padding(): + """Touch/coarse composer padding must consume the keyboard inset variable.""" + css_ns = re.sub(r'\s+', '', CSS) + assert "@media(hover:none)and(pointer:coarse)" in css_ns, \ + "style.css must scope the keyboard inset padding to touch-primary viewports" + assert ".composer-wrap{padding-bottom:calc(14px+var(--keyboard-bottom-inset,0px));}" in css_ns, \ + "style.css must add the keyboard inset to composer bottom padding" + + # ── Sidebar tabs on mobile ───────────────────────────────────────────────────