From 6a797fe0830b6cb3ce829c4a283909d7e8494132 Mon Sep 17 00:00:00 2001
From: Badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sat, 14 Nov 2026 14:12:55 -0500
Subject: [PATCH] fix(web): sheets and drawers no longer collapse when the page
reads as off screen
On an iPhone Duo, going from the open screen to the folded one could
leave the thread list and the right panel squeezed to no height: their
fixed-size rows stacked with nothing behind them, until the panel was
closed and opened again.
Sheets, drawers and dialogs pad themselves above an on-screen keyboard
by comparing the visual viewport with the window. Feeding a visual
viewport of about no height reproduces the reported state exactly: the
padding takes the whole window. The best explanation, not measured on
the phone, is that iOS reports that for a moment while the page moves
between the two screens and sends no resize event when it recovers.
- A visual viewport under a tenth of the window's height is not read
as a keyboard.
- The keyboard readings are taken again on the window's own resize,
not only on the visual viewport's events.
Also swaps three calc() offsets for a plain offset plus a margin (image
viewer arrows, both find bars): same geometry, fewer startup CSS bytes,
since main sits about 150 B under the startup size budget.
---
.../components/chat/ExpandedImageDialog.tsx | 4 +-
.../src/components/chat/chatFind/ChatFind.tsx | 2 +-
.../components/terminal/TerminalFindBar.tsx | 2 +-
apps/web/src/components/ui/dialog.browser.tsx | 31 ++++++++++
.../web/src/hooks/useOnScreenKeyboardInset.ts | 17 +++--
apps/web/src/lib/onScreenKeyboard.browser.tsx | 21 +++++++
apps/web/src/lib/onScreenKeyboard.ts | 62 +++++++++++++++----
7 files changed, 114 insertions(+), 25 deletions(-)
diff --git a/apps/web/src/components/chat/ExpandedImageDialog.tsx b/apps/web/src/components/chat/ExpandedImageDialog.tsx
index 80dabe9a5..51e4ae6ce 100644
--- a/apps/web/src/components/chat/ExpandedImageDialog.tsx
+++ b/apps/web/src/components/chat/ExpandedImageDialog.tsx
@@ -137,7 +137,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="ghost"
- className="absolute left-[calc(--spacing(2)+var(--safe-area-left))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-[calc(--spacing(6)+var(--safe-area-left))]"
+ className="absolute left-2 top-1/2 z-20 ml-(--safe-area-left) -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:left-6"
aria-label="Previous image"
onClick={() => navigateImage(-1)}
>
@@ -212,7 +212,7 @@ export const ExpandedImageDialog = memo(function ExpandedImageDialog({
type="button"
size="icon"
variant="ghost"
- className="absolute right-[calc(--spacing(2)+var(--safe-area-right))] top-1/2 z-20 -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-[calc(--spacing(6)+var(--safe-area-right))]"
+ className="absolute right-2 top-1/2 z-20 mr-(--safe-area-right) -translate-y-1/2 text-white/90 hover:bg-white/10 hover:text-white sm:right-6"
aria-label="Next image"
onClick={() => navigateImage(1)}
>
diff --git a/apps/web/src/components/chat/chatFind/ChatFind.tsx b/apps/web/src/components/chat/chatFind/ChatFind.tsx
index e5d96b48d..b685c95fc 100644
--- a/apps/web/src/components/chat/chatFind/ChatFind.tsx
+++ b/apps/web/src/components/chat/chatFind/ChatFind.tsx
@@ -429,7 +429,7 @@ function ChatFindSession(props: ChatFindProps) {
return (
);
diff --git a/apps/web/src/components/ui/dialog.browser.tsx b/apps/web/src/components/ui/dialog.browser.tsx
index 52505f872..d95af58bb 100644
--- a/apps/web/src/components/ui/dialog.browser.tsx
+++ b/apps/web/src/components/ui/dialog.browser.tsx
@@ -62,6 +62,37 @@ describe("Dialog", () => {
}
});
+ it("keeps a phone's bottom sheet whole while the page reads as off screen for a moment", async () => {
+ await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height);
+ const mounted = await render(
+ ,
+ );
+
+ try {
+ await vi.waitFor(() => {
+ expect(popupRect()?.bottom).toBeCloseTo(window.innerHeight, 0);
+ });
+ const height = popupRect()!.height;
+
+ // A folding phone moving the page between its screens: none of it
+ // shows, and read as a keyboard that squeezed the sheet to nothing.
+ showOnScreenKeyboard({ height: window.visualViewport!.height, pageScrolledUpBy: 0 });
+ await nextFrames(5);
+
+ expect(popupRect()?.height).toBeCloseTo(height, 0);
+ expect(popupRect()?.bottom).toBeCloseTo(window.innerHeight, 0);
+ } finally {
+ await mounted.unmount();
+ }
+ });
+
it("keeps a tall sheet inside what's visible when Safari scrolls the page up for the keyboard", async () => {
await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height);
const mounted = await render(
diff --git a/apps/web/src/hooks/useOnScreenKeyboardInset.ts b/apps/web/src/hooks/useOnScreenKeyboardInset.ts
index ec46ce2c8..13f5bfa8d 100644
--- a/apps/web/src/hooks/useOnScreenKeyboardInset.ts
+++ b/apps/web/src/hooks/useOnScreenKeyboardInset.ts
@@ -1,6 +1,10 @@
import { type CSSProperties, useCallback, useSyncExternalStore } from "react";
-import { readVisualViewportInset, type VisualViewportEdge } from "~/lib/onScreenKeyboard";
+import {
+ readVisualViewportInset,
+ type VisualViewportEdge,
+ watchOnScreenKeyboard,
+} from "~/lib/onScreenKeyboard";
function getServerSnapshot(): number {
return 0;
@@ -9,15 +13,8 @@ function getServerSnapshot(): number {
function useVisualViewportInset(edge: VisualViewportEdge, enabled: boolean): number {
const subscribe = useCallback(
(callback: () => void) => {
- if (!enabled || typeof window === "undefined") return () => {};
- const viewport = window.visualViewport;
- if (!viewport) return () => {};
- viewport.addEventListener("resize", callback);
- viewport.addEventListener("scroll", callback);
- return () => {
- viewport.removeEventListener("resize", callback);
- viewport.removeEventListener("scroll", callback);
- };
+ if (!enabled) return () => {};
+ return watchOnScreenKeyboard(callback);
},
[enabled],
);
diff --git a/apps/web/src/lib/onScreenKeyboard.browser.tsx b/apps/web/src/lib/onScreenKeyboard.browser.tsx
index e2b0f8766..f699d81ea 100644
--- a/apps/web/src/lib/onScreenKeyboard.browser.tsx
+++ b/apps/web/src/lib/onScreenKeyboard.browser.tsx
@@ -24,4 +24,25 @@ describe("installOnScreenKeyboardFlag", () => {
window.visualViewport?.dispatchEvent(new Event("resize"));
expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
});
+
+ it("does not take a page that is off screen for a moment for a keyboard", () => {
+ const root = document.documentElement;
+ uninstall = installOnScreenKeyboardFlag();
+
+ // A folding phone moving the page from one of its screens to the other:
+ // for a moment none of the page shows.
+ showOnScreenKeyboard({ height: window.visualViewport!.height, pageScrolledUpBy: 0 });
+ expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
+ });
+
+ it("looks again when the window resizes with no word from the visual viewport", () => {
+ const root = document.documentElement;
+ uninstall = installOnScreenKeyboardFlag();
+ const viewport = window.visualViewport!;
+
+ vi.spyOn(viewport, "height", "get").mockReturnValue(viewport.height - 260);
+ expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(false);
+ window.dispatchEvent(new Event("resize"));
+ expect(root.hasAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE)).toBe(true);
+ });
});
diff --git a/apps/web/src/lib/onScreenKeyboard.ts b/apps/web/src/lib/onScreenKeyboard.ts
index af3564bd6..f7c0980dc 100644
--- a/apps/web/src/lib/onScreenKeyboard.ts
+++ b/apps/web/src/lib/onScreenKeyboard.ts
@@ -1,25 +1,66 @@
export type VisualViewportEdge = "top" | "bottom";
-/** Pinch zoom also shrinks the visual viewport; only a 1:1 scale reliably means
- * the height lost is the on-screen keyboard's. */
-function unzoomedVisualViewport(): VisualViewport | null {
+/**
+ * The least of the window a visual viewport can show and still be read as a
+ * page with a keyboard over it. No on-screen keyboard takes nine tenths of
+ * its window (a small phone on its side keeps about a third). A reading
+ * under this is taken to mean the page is off screen for a moment. That is
+ * the best explanation, not a measured one, for an iPhone Duo's sheets and
+ * drawers opening squeezed to nothing after a fold: read as a keyboard, a
+ * visual viewport of about no height pads them by the whole window, and
+ * the reading can outlast the fold with no resize event to correct it.
+ */
+const VISIBLE_PAGE_MIN_SHARE_OF_WINDOW = 0.1;
+
+/**
+ * The visual viewport, when what it has lost can be an on-screen keyboard's
+ * doing. Pinch zoom shrinks it too, so only a 1:1 scale counts, and so does
+ * only a viewport that still shows some of the page.
+ */
+function keyboardVisualViewport(): VisualViewport | null {
const viewport = typeof window === "undefined" ? null : window.visualViewport;
- if (!viewport || Math.abs(viewport.scale - 1) > 0.01) {
+ if (
+ !viewport ||
+ Math.abs(viewport.scale - 1) > 0.01 ||
+ viewport.height < window.innerHeight * VISIBLE_PAGE_MIN_SHARE_OF_WINDOW
+ ) {
return null;
}
return viewport;
}
+/**
+ * Calls back when an on-screen keyboard may have come, gone or moved the
+ * page. The window's own resize counts too: the readings compare the visual
+ * viewport with the window, and the window can change size (a folding phone,
+ * a turned one) with no event on the visual viewport. Returns the way to stop.
+ */
+export function watchOnScreenKeyboard(onChange: () => void): () => void {
+ const viewport = typeof window === "undefined" ? undefined : window.visualViewport;
+ if (!viewport) {
+ return () => {};
+ }
+ viewport.addEventListener("resize", onChange);
+ viewport.addEventListener("scroll", onChange);
+ window.addEventListener("resize", onChange);
+ return () => {
+ viewport.removeEventListener("resize", onChange);
+ viewport.removeEventListener("scroll", onChange);
+ window.removeEventListener("resize", onChange);
+ };
+}
+
/**
* How much of the layout viewport an on-screen keyboard leaves out of view at
* one edge, in px: at the bottom, what the keyboard covers; at the top, how far
* iOS Safari scrolled the page up to show the focused field above it. Only
* browsers whose keyboard slides over the page (iOS Safari) report anything;
* one that resizes the page instead (Android Chrome, which honors
- * `interactive-widget=resizes-content`) reports 0, as does a pinch-zoomed page.
+ * `interactive-widget=resizes-content`) reports 0, as does a pinch-zoomed page
+ * and one that is off screen for a moment.
*/
export function readVisualViewportInset(edge: VisualViewportEdge): number {
- const viewport = unzoomedVisualViewport();
+ const viewport = keyboardVisualViewport();
if (!viewport) {
return 0;
}
@@ -31,7 +72,7 @@ export function readVisualViewportInset(edge: VisualViewportEdge): number {
/** The visual viewport's whole shrink, whichever edge Safari scrolled it from. */
function readOnScreenKeyboardHeight(): number {
- const viewport = unzoomedVisualViewport();
+ const viewport = keyboardVisualViewport();
return viewport ? Math.max(0, Math.round(window.innerHeight - viewport.height)) : 0;
}
@@ -60,8 +101,7 @@ export const ON_SCREEN_KEYBOARD_ATTRIBUTE = "data-on-screen-keyboard";
* Returns the way to undo it.
*/
export function installOnScreenKeyboardFlag(): () => void {
- const viewport = typeof window === "undefined" ? undefined : window.visualViewport;
- if (!viewport) {
+ if (typeof window === "undefined" || !window.visualViewport) {
return () => {};
}
const root = document.documentElement;
@@ -72,9 +112,9 @@ export function installOnScreenKeyboardFlag(): () => void {
);
};
sync();
- viewport.addEventListener("resize", sync);
+ const stopWatching = watchOnScreenKeyboard(sync);
return () => {
- viewport.removeEventListener("resize", sync);
+ stopWatching();
root.removeAttribute(ON_SCREEN_KEYBOARD_ATTRIBUTE);
};
}