diff --git a/web/src/lib/components/layout/BottomNav.svelte b/web/src/lib/components/layout/BottomNav.svelte
index bf2067c0..04c927f0 100644
--- a/web/src/lib/components/layout/BottomNav.svelte
+++ b/web/src/lib/components/layout/BottomNav.svelte
@@ -71,11 +71,12 @@
captureOpen = next;
}
- // Drive the .main-content reflow only while shown (mobile). app.css owns
- // the media-gated padding rule.
+ // Drive the .main-content reflow only while the bar is actually shown —
+ // mobile AND no keyboard up. app.css owns the media-gated padding rule;
+ // dropping the class while the keyboard hides the bar avoids a dead gap.
$effect(() => {
if (typeof document === 'undefined') return;
- document.body.classList.toggle('has-bottom-nav', uiStore.isMobile);
+ document.body.classList.toggle('has-bottom-nav', uiStore.isMobile && !uiStore.keyboardVisible);
});
onDestroy(() => {
if (typeof document !== 'undefined') document.body.classList.remove('has-bottom-nav');
@@ -83,6 +84,13 @@
{#if uiStore.isMobile && wsPrefix}
+
+ {#if !uiStore.keyboardVisible}
+ {/if}
(workspaceOpen = false)} />
(youOpen = false)} />
diff --git a/web/src/lib/stores/ui.svelte.ts b/web/src/lib/stores/ui.svelte.ts
index 16c03216..bca68c2b 100644
--- a/web/src/lib/stores/ui.svelte.ts
+++ b/web/src/lib/stores/ui.svelte.ts
@@ -5,6 +5,14 @@ let topbarOpen = $state(browser ? localStorage.getItem('pad-topbar') !== 'closed
let searchOpen = $state(false);
let isMobile = $state(browser ? window.innerWidth <= 768 : false);
let isTouch = $state(browser ? 'ontouchstart' in window : false);
+// True while the on-screen keyboard is up. Detected from the geometry of the
+// keyboard itself — visualViewport.height shrinking below the tallest height
+// we've seen (the keyboard-closed baseline). This works on iOS Safari AND
+// Android Chrome; a naive `innerHeight - visualViewport.height` does NOT,
+// because those browsers shrink window.innerHeight in lockstep with the visual
+// viewport, leaving the delta ~0. Consumers (e.g. BottomNav) hide fixed bottom
+// chrome so it doesn't sit stranded above the keyboard. PLAN-1694.
+let keyboardVisible = $state(false);
let detailPanelOpen = $state(browser ? localStorage.getItem('pad-detail-panel') !== 'closed' && window.innerWidth > 768 : false);
let createWorkspaceOpen = $state(false);
let quickAddRequested = $state(false);
@@ -30,6 +38,30 @@ if (browser) {
}
}
});
+
+ // Track on-screen keyboard visibility from the visual viewport. Only touch
+ // devices raise a soft keyboard, so gate on isTouch — a narrow desktop
+ // window never has one and must not hide the nav.
+ if (isTouch && window.visualViewport) {
+ const vv = window.visualViewport;
+ // The tallest viewport height we've seen == keyboard closed. It grows as
+ // the URL bar collapses on scroll and resets on rotation, so the keyboard
+ // shrink is always measured against the true full-height reference.
+ let baseline = vv.height;
+ const KEYBOARD_MIN_PX = 150; // smaller shrinks are browser chrome, not a keyboard
+ const measure = () => {
+ if (vv.height > baseline) baseline = vv.height;
+ keyboardVisible = baseline - vv.height > KEYBOARD_MIN_PX;
+ };
+ vv.addEventListener('resize', measure);
+ vv.addEventListener('scroll', measure);
+ // Re-capture the baseline after an orientation change so a shorter
+ // landscape viewport isn't mistaken for an open keyboard.
+ window.addEventListener('orientationchange', () => {
+ baseline = 0;
+ setTimeout(measure, 300);
+ });
+ }
}
export const uiStore = {
@@ -38,6 +70,7 @@ export const uiStore = {
get searchOpen() { return searchOpen; },
get isMobile() { return isMobile; },
get isTouch() { return isTouch; },
+ get keyboardVisible() { return keyboardVisible; },
get detailPanelOpen() { return detailPanelOpen; },
get createWorkspaceOpen() { return createWorkspaceOpen; },