thomwolf HF Staff Claude Opus 5 (1M context) commited on
Commit
07ad947
·
1 Parent(s): 699b938

Mobile keyboard: stop estimating a height nobody can measure

Browse files

When the browser reports no keyboard geometry — a cross-origin frame on mobile
Safari, which is how the Hub page serves a Space — the app assumed a keyboard ate
46% of the frame and shrank itself to the remaining 54%.

A guess in the safe direction is still a guess, and this one had a visible cost:
the abandoned strip does not stay hidden behind the keyboard. The browser
scroll-reveals a focused field, dragging that strip back into view — body's --bg
against the reader's --panel, sized by a constant rather than by the keyboard.
That is the blank band under the reader's composer. It went unnoticed for as long
as it did because in terminal mode the strip is unused black.

So: report the viewport we can see, and stop. Nothing here knows how tall a
keyboard is, and the engine that does already scrolls a focused field into view —
the same scroll-into-view that keeps xterm's pinned helper textarea above the
keyboard today. Where real geometry exists (a direct browser's visualViewport,
Chromium's virtualKeyboard) nothing changes.

Deletes the ratio, the fallback timer, the embedded-touch-layout probe, the
`focus-fallback` layout mode and a write-only `focusedInput`: 58 lines out, 24
in. `focusBaseline` stays — a keyboard is still detected as the SHRINK from it.

This is also the experiment for the remaining unknown: if the band survives
without a clamp to cause it, the cause is WebKit displacing a fixed subtree on
scroll-to-reveal, which is a different fix in a different place.

tsc clean, exchanges ok, production build succeeds. Needs a phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Files changed (1) hide show
  1. web/src/App.tsx +24 -58
web/src/App.tsx CHANGED
@@ -117,10 +117,13 @@ export default function App() {
117
 
118
  // Track the visual viewport so the mobile layout can sit above the on-screen
119
  // keyboard (which shrinks visualViewport but not the layout viewport on iOS).
120
- // The CSS variables pin the app to that viewport's exact rectangle. The Hub
121
- // page embeds the app in a cross-origin iframe; mobile Safari leaves that
122
- // child viewport unchanged when its keyboard opens. In that one no-signal
123
- // case, fall back to a conservative focus-derived visible height.
 
 
 
124
  useEffect(() => {
125
  const vv = window.visualViewport;
126
  type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
@@ -133,11 +136,10 @@ export default function App() {
133
  };
134
  const root = document.documentElement;
135
  const keyboardSignalThreshold = 80;
136
- const embedded = window.self !== window.top;
137
- let focusedInput: Element | null = null;
 
138
  let focusBaseline: ViewportBaseline | null = null;
139
- let focusFallback = false;
140
- let focusFallbackTimer: ReturnType<typeof setTimeout> | null = null;
141
 
142
  const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
143
  if (!(target instanceof HTMLElement)) return false;
@@ -147,9 +149,6 @@ export default function App() {
147
  return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
148
  .includes(target.type);
149
  };
150
- const embeddedTouchLayout = () => embedded
151
- && window.matchMedia('(max-width: 720px)').matches
152
- && (navigator.maxTouchPoints > 0 || window.matchMedia('(pointer: coarse)').matches);
153
  const captureViewport = (): ViewportBaseline => ({
154
  width: vv?.width ?? document.documentElement.clientWidth,
155
  height: vv?.height ?? window.innerHeight,
@@ -178,20 +177,18 @@ export default function App() {
178
  if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
179
  height = Math.min(height, keyboardRect.top - top);
180
  }
181
- if (hasKeyboardGeometry()) focusFallback = false;
182
- if (focusFallback && focusBaseline && acceptsKeyboardInput(document.activeElement)) {
183
- // The parent page owns the real visual viewport, but cross-origin frame
184
- // isolation prevents us from reading it. A phone keyboard typically
185
- // consumes roughly the lower half; 54% visible keeps the xterm prompt
186
- // above it without disturbing direct-app browsers with real geometry.
187
- const visibleRatio = focusBaseline.width > focusBaseline.height ? 0.48 : 0.54;
188
- height = Math.min(height, Math.round(focusBaseline.height * visibleRatio));
189
- root.dataset.keyboardLayout = 'focus-fallback';
190
- } else if (hasKeyboardGeometry()) {
191
- root.dataset.keyboardLayout = 'browser-geometry';
192
- } else {
193
- delete root.dataset.keyboardLayout;
194
- }
195
  root.style.setProperty('--vvw', `${Math.round(width)}px`);
196
  root.style.setProperty('--vvh', `${Math.round(height)}px`);
197
  root.style.setProperty('--vv-top', `${Math.round(top)}px`);
@@ -223,54 +220,24 @@ export default function App() {
223
  focusTimers.add(timer);
224
  }
225
  };
226
- const scheduleEmbeddedFallback = () => {
227
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
228
- focusFallbackTimer = null;
229
- if (!embeddedTouchLayout() || !acceptsKeyboardInput(document.activeElement)) return;
230
- focusFallbackTimer = setTimeout(() => {
231
- focusFallbackTimer = null;
232
- if (document.activeElement === focusedInput && !hasKeyboardGeometry()) {
233
- focusFallback = true;
234
- apply();
235
- }
236
- }, 500);
237
- };
238
  const onFocusIn = (event: FocusEvent) => {
239
  const target = event.target instanceof Element ? event.target : null;
240
- if (acceptsKeyboardInput(target)) {
241
- focusedInput = target;
242
- focusBaseline = captureViewport();
243
- focusFallback = false;
244
- stabilizeFocus();
245
- scheduleEmbeddedFallback();
246
- return;
247
- }
248
  stabilizeFocus();
249
  };
250
  const onFocusOut = () => {
251
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
252
- focusFallbackTimer = null;
253
  const timer = setTimeout(() => {
254
  focusTimers.delete(timer);
255
  if (!acceptsKeyboardInput(document.activeElement)) {
256
- focusedInput = null;
257
  focusBaseline = null;
258
- focusFallback = false;
259
  apply();
260
  }
261
  }, 0);
262
  focusTimers.add(timer);
263
  };
264
  const onOrientationChange = () => {
265
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
266
- focusFallbackTimer = null;
267
- focusFallback = false;
268
- if (acceptsKeyboardInput(document.activeElement)) {
269
- focusedInput = document.activeElement;
270
- focusBaseline = captureViewport();
271
- }
272
  stabilizeFocus();
273
- scheduleEmbeddedFallback();
274
  };
275
  apply();
276
  vv?.addEventListener('resize', onViewportChange);
@@ -284,7 +251,6 @@ export default function App() {
284
  return () => {
285
  for (const timer of settleTimers) clearTimeout(timer);
286
  for (const timer of focusTimers) clearTimeout(timer);
287
- if (focusFallbackTimer) clearTimeout(focusFallbackTimer);
288
  vv?.removeEventListener('resize', onViewportChange);
289
  vv?.removeEventListener('scroll', onViewportChange);
290
  vv?.removeEventListener('scrollend', onViewportChange);
 
117
 
118
  // Track the visual viewport so the mobile layout can sit above the on-screen
119
  // keyboard (which shrinks visualViewport but not the layout viewport on iOS).
120
+ // The CSS variables pin the app to that viewport's exact rectangle.
121
+ //
122
+ // Where there is no signal the Hub page embeds the app in a cross-origin
123
+ // iframe, and mobile Safari leaves that child viewport unchanged when its
124
+ // keyboard opens — the app reports the viewport it can see and stops there.
125
+ // It does not estimate one. Nothing here knows how tall a keyboard is, and
126
+ // the browser that does already scrolls a focused field into view.
127
  useEffect(() => {
128
  const vv = window.visualViewport;
129
  type VirtualKeyboardLike = EventTarget & { boundingRect?: DOMRectReadOnly };
 
136
  };
137
  const root = document.documentElement;
138
  const keyboardSignalThreshold = 80;
139
+ // The viewport as it was before a field took focus — the only thing left
140
+ // that needs remembering, because a keyboard is detected as the SHRINK from
141
+ // it (hasKeyboardGeometry), not as an absolute height.
142
  let focusBaseline: ViewportBaseline | null = null;
 
 
143
 
144
  const acceptsKeyboardInput = (target: Element | null): target is HTMLElement => {
145
  if (!(target instanceof HTMLElement)) return false;
 
149
  return !['button', 'checkbox', 'color', 'file', 'hidden', 'image', 'radio', 'range', 'reset', 'submit']
150
  .includes(target.type);
151
  };
 
 
 
152
  const captureViewport = (): ViewportBaseline => ({
153
  width: vv?.width ?? document.documentElement.clientWidth,
154
  height: vv?.height ?? window.innerHeight,
 
177
  if (keyboardRect && keyboardRect.height > 0 && keyboardRect.top > top) {
178
  height = Math.min(height, keyboardRect.top - top);
179
  }
180
+ // When the browser reports no keyboard geometry — a cross-origin frame on
181
+ // mobile Safari, which the Hub page is — the app does NOT invent a height.
182
+ // It used to assume a keyboard ate 46% and shrink to fit, and a guess that
183
+ // is wrong in the safe direction is still wrong: the abandoned strip does
184
+ // not stay hidden behind the keyboard, because the browser scroll-reveals
185
+ // a focused field and drags it back into view. That strip is the blank
186
+ // band under the reader's composer. Leaving the viewport alone hands the
187
+ // job to the engine that can actually see the keyboard — the same
188
+ // scroll-into-view that already keeps xterm's pinned helper textarea
189
+ // above it.
190
+ if (hasKeyboardGeometry()) root.dataset.keyboardLayout = 'browser-geometry';
191
+ else delete root.dataset.keyboardLayout;
 
 
192
  root.style.setProperty('--vvw', `${Math.round(width)}px`);
193
  root.style.setProperty('--vvh', `${Math.round(height)}px`);
194
  root.style.setProperty('--vv-top', `${Math.round(top)}px`);
 
220
  focusTimers.add(timer);
221
  }
222
  };
 
 
 
 
 
 
 
 
 
 
 
 
223
  const onFocusIn = (event: FocusEvent) => {
224
  const target = event.target instanceof Element ? event.target : null;
225
+ if (acceptsKeyboardInput(target)) focusBaseline = captureViewport();
 
 
 
 
 
 
 
226
  stabilizeFocus();
227
  };
228
  const onFocusOut = () => {
 
 
229
  const timer = setTimeout(() => {
230
  focusTimers.delete(timer);
231
  if (!acceptsKeyboardInput(document.activeElement)) {
 
232
  focusBaseline = null;
 
233
  apply();
234
  }
235
  }, 0);
236
  focusTimers.add(timer);
237
  };
238
  const onOrientationChange = () => {
239
+ if (acceptsKeyboardInput(document.activeElement)) focusBaseline = captureViewport();
 
 
 
 
 
 
240
  stabilizeFocus();
 
241
  };
242
  apply();
243
  vv?.addEventListener('resize', onViewportChange);
 
251
  return () => {
252
  for (const timer of settleTimers) clearTimeout(timer);
253
  for (const timer of focusTimers) clearTimeout(timer);
 
254
  vv?.removeEventListener('resize', onViewportChange);
255
  vv?.removeEventListener('scroll', onViewportChange);
256
  vv?.removeEventListener('scrollend', onViewportChange);