From 190664c1a516ab69aa4bc7a14426555bb78df8a8 Mon Sep 17 00:00:00 2001 From: Martin Dvorak Date: Wed, 7 Oct 2026 13:00:06 +0000 Subject: [PATCH] Lock the key gate while the on-screen keyboard is up On phones the gate card (447px) is taller than the keyboard-reduced viewport, so the browser panned the page to reveal the field and the user could keep scrolling/dragging while typing the API key. - Pin the gate to the visual viewport (height + offsetTop tracked from visualViewport resize/scroll), so the browser never needs to pan the page to reveal the focused field. - When the keyboard is detected (visual viewport >120px shorter than innerHeight), lock the card: overflow hidden + touch-action none, and keep the field and Continue button in view by scrolling the wrapper programmatically. Re-aligns on resize and when the card grows (error message / busy state). - Corner controls are now absolute within the pinned gate so they stay in the visual viewport. - Don't auto-focus the field on touch devices (no forced keyboard on load); desktop still focuses. Verified by shrinking the viewport to 330px: card locked (wheel/swipe does nothing), field 182-230 and Continue 242-290 both visible, doc scrollHeight == viewport; error state keeps Continue at 270-318. key-gate 49/49, install sheet 64/64, final-ux 12/12, zoom 6/6. --- components/ApiKeyGate.tsx | 84 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/components/ApiKeyGate.tsx b/components/ApiKeyGate.tsx index 39af5f4..53679c3 100644 --- a/components/ApiKeyGate.tsx +++ b/components/ApiKeyGate.tsx @@ -1,7 +1,7 @@ "use client"; import { AnimatePresence, motion, useReducedMotion } from "framer-motion"; -import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; import { useTheme } from "@/components/ThemeToggle"; import { @@ -84,12 +84,74 @@ function GateScreen() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [helpOpen, setHelpOpen] = useState(false); + const [keyboardOpen, setKeyboardOpen] = useState(false); const inputRef = useRef(null); + const rootRef = useRef(null); + const wrapRef = useRef(null); + const submitRef = useRef(null); useEffect(() => { - inputRef.current?.focus(); + // Don't force the on-screen keyboard open on touch devices — let the user + // tap the field when they're ready. + if (window.matchMedia("(pointer: fine)").matches) inputRef.current?.focus(); }, []); + // Keep the field and the Continue button in view while the on-screen keyboard + // is up (the card itself is locked, so this is done by scrolling the wrapper + // programmatically rather than by the user). + const alignKeyboard = useCallback(() => { + const wrap = wrapRef.current; + const submit = submitRef.current; + if (!wrap || !submit) return; + const wrapBox = wrap.getBoundingClientRect(); + const btnBox = submit.getBoundingClientRect(); + const below = btnBox.bottom + 12 - wrapBox.bottom; + const above = wrapBox.top + 12 - btnBox.top; + if (below > 0) wrap.scrollTop += below; + else if (above > 0) wrap.scrollTop -= above; + }, []); + + // Pin the gate to the *visual* viewport so the browser never has to pan the + // page to reveal the focused field. While the on-screen keyboard is up the + // card is locked (not user-scrollable). + useEffect(() => { + const vv = window.visualViewport; + const root = rootRef.current; + if (!vv || !root) return; + + const baseline = window.innerHeight; + let frame = 0; + + const sync = () => { + if (frame) cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => { + root.style.height = `${vv.height}px`; + root.style.top = `${vv.offsetTop}px`; + + const open = baseline - vv.height > 120; + setKeyboardOpen(open); + if (open) alignKeyboard(); + }); + }; + + sync(); + vv.addEventListener("resize", sync); + vv.addEventListener("scroll", sync); + return () => { + if (frame) cancelAnimationFrame(frame); + vv.removeEventListener("resize", sync); + vv.removeEventListener("scroll", sync); + }; + }, [alignKeyboard]); + + // Re-align when the card grows/shrinks (e.g. an error message appears) while + // the keyboard is open. + useEffect(() => { + if (!keyboardOpen) return; + const frame = requestAnimationFrame(alignKeyboard); + return () => cancelAnimationFrame(frame); + }, [keyboardOpen, error, busy, alignKeyboard]); + useEffect(() => { if (!helpOpen) return; const onKey = (event: KeyboardEvent) => { @@ -118,10 +180,13 @@ function GateScreen() { }; return ( -
+
{/* corner controls: theme + help ---------------------------------- */}
{/* card ----------------------------------------------------------- */} -
+