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.
This commit is contained in:
Martin Dvořák 2026-10-07 13:00:06 +00:00
commit 190664c1a5

View file

@ -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<GateError>(null);
const [helpOpen, setHelpOpen] = useState(false);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const rootRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const submitRef = useRef<HTMLButtonElement>(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 (
<div className="gate-bg fixed inset-0 overflow-hidden overscroll-none">
<div
ref={rootRef}
className="gate-bg fixed inset-0 h-dvh overflow-hidden overscroll-none"
>
{/* corner controls: theme + help ---------------------------------- */}
<div
className="fixed z-20 flex items-center gap-2"
className="absolute z-20 flex items-center gap-2"
style={{
top: "max(env(safe-area-inset-top), 1.25rem)",
right: "max(env(safe-area-inset-right), 1.25rem)",
@ -153,7 +218,15 @@ function GateScreen() {
</div>
{/* card ----------------------------------------------------------- */}
<div className="relative z-10 flex h-full items-center justify-center overflow-y-auto overscroll-contain px-5 py-10">
<div
ref={wrapRef}
className={cn(
"relative z-10 flex h-full justify-center px-5",
keyboardOpen
? "items-start touch-none overflow-hidden py-3"
: "items-center overflow-y-auto overscroll-contain py-10",
)}
>
<motion.div
initial={reduceMotion ? false : { opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
@ -225,6 +298,7 @@ function GateScreen() {
<button
type="submit"
ref={submitRef}
disabled={!draft.trim() || busy}
className={cn(
"btn-primary mt-3 h-12 w-full rounded-[13px] text-[14.5px] font-medium",