diff --git a/web/index.html b/web/index.html
index c67fb83..8749601 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,7 @@
diff --git a/web/src/styles.css b/web/src/styles.css
index 73d2038..9c68219 100644
--- a/web/src/styles.css
+++ b/web/src/styles.css
@@ -211,8 +211,11 @@ body {
#app {
/* `--app-vh` tracks the *visual* viewport (set by installViewportSizing) so
the shell stays above the mobile soft keyboard; falls back to the layout
- viewport where `visualViewport` is unavailable. */
+ viewport where `visualViewport` is unavailable. `--app-top` follows the
+ layout viewport's scroll under the keyboard so the shell stays pinned to
+ the visible region instead of drifting to the top. */
height: var(--app-vh, 100%);
+ transform: translateY(var(--app-top, 0px));
display: flex;
flex-direction: column;
overflow: hidden;
diff --git a/web/src/viewport.ts b/web/src/viewport.ts
index b241b5d..f5f400b 100644
--- a/web/src/viewport.ts
+++ b/web/src/viewport.ts
@@ -1,5 +1,5 @@
/**
- * Bind the app shell's height to the *visual* viewport.
+ * Bind the app shell's height *and position* to the *visual* viewport.
*
* On mobile the layout viewport does not shrink when the on-screen keyboard
* opens — only the visual viewport does. A shell sized at `height: 100%` (or
@@ -8,6 +8,16 @@
* `visualViewport.height` onto a CSS custom property keeps the shell exactly as
* tall as the visible area, so the key bar lands just above the keyboard.
*
+ * Height alone isn't enough: when the keyboard opens the browser also scrolls
+ * the layout viewport to reveal the focused element, which drags the shell
+ * (anchored at layout-top) up and out of view — the key bar ends up at the top
+ * of the screen and the user has to scroll back down. `visualViewport.offsetTop`
+ * is how far the layout viewport has scrolled under the keyboard; translating
+ * the shell down by that amount re-pins it to the visible region. (Chrome's
+ * `interactive-widget=resizes-content`, set in the viewport meta, prevents the
+ * scroll entirely on Android; this covers iOS and older browsers that ignore
+ * it, where offsetTop stays non-zero.)
+ *
* No-ops where `visualViewport` is unavailable; the CSS falls back to `100%`.
*/
export function installViewportSizing(): void {
@@ -20,6 +30,7 @@ export function installViewportSizing(): void {
const sync = () => {
raf = 0;
root.style.setProperty("--app-vh", `${Math.round(vv.height)}px`);
+ root.style.setProperty("--app-top", `${Math.round(vv.offsetTop)}px`);
};
const schedule = () => {
if (raf) return;