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 <nesquena-hermes@users.noreply.github.com>
This commit is contained in:
rodboevandnesquena-hermes authored and t committed 2026-07-07 05:05:11 +00:00
1 parent 021bc1280f
commit ae8d0bf3fb
3 files changed
+83 -2

No files matched your search

+23
View File
@@ -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();
+5 -1
View File
@@ -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;}
+55 -1
View File
@@ -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 ───────────────────────────────────────────────────