Keep the composer above the on-screen keyboard on the first tap
Both composers are position:sticky;bottom:0, which pins them to the bottom of the *layout* viewport. A phone keyboard does not shrink that viewport -- it only covers it -- so the first tap on the input left the box sitting behind the keyboard. Tapping back and focusing again appeared to work only because the page had been scrolled during the first attempt, so the browser's scroll-into-view landed somewhere else. Two engines, two halves: - Chromium honours interactive-widget=resizes-content on the viewport meta, which makes the layout viewport shrink when the keyboard opens, so 100dvh and sticky bottoms account for it on their own. - iOS Safari ignores that flag and only shrinks the visual viewport, so measure the covered height from visualViewport and publish it as --kb-inset; the composers offset their sticky bottom by it. On browsers that already resized the layout viewport the measurement is ~0, so the same rule is a no-op there instead of a double lift. The pages also gain a matching bottom margin so the last beat can still be scrolled clear of a lifted composer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011BC7Dsg4gSQm3NLcZcG5SK
This commit is contained in:
+8
-1
@@ -3,7 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<!-- interactive-widget: shrink the layout viewport when the on-screen
|
||||||
|
keyboard opens, so the bottom-sticky composers stay above it instead
|
||||||
|
of staying pinned behind it. Chromium honours this; iOS Safari does
|
||||||
|
not, which is what src/keyboard.js covers. -->
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, interactive-widget=resizes-content"
|
||||||
|
/>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
+20
-4
@@ -301,7 +301,16 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
|||||||
|
|
||||||
/* ---------- Play page ---------- */
|
/* ---------- Play page ---------- */
|
||||||
|
|
||||||
.play-page { display: flex; flex-direction: column; min-height: calc(100dvh - 56px); }
|
.play-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100dvh - 56px);
|
||||||
|
/* Room to scroll the last beat clear of a composer lifted by the keyboard
|
||||||
|
(see --kb-inset in keyboard.js); 0 on desktop and on browsers that shrink
|
||||||
|
the layout viewport themselves. Margin, not padding — .page already sets
|
||||||
|
padding and a longhand here would drop its bottom gutter. */
|
||||||
|
margin-bottom: var(--kb-inset, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story
|
/* Keep the Plot/Memory/Scripts/Insights toggles reachable while the story
|
||||||
scrolls — pin the header just below the 56px top nav. */
|
scrolls — pin the header just below the 56px top nav. */
|
||||||
@@ -446,7 +455,9 @@ button:disabled { opacity: 0.45; cursor: default; transform: none; box-shadow: n
|
|||||||
|
|
||||||
.play-controls {
|
.play-controls {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
/* Sits on the keyboard's top edge when one is open, on the viewport bottom
|
||||||
|
otherwise — --kb-inset is 0 unless a virtual keyboard is covering us. */
|
||||||
|
bottom: var(--kb-inset, 0px);
|
||||||
background: linear-gradient(to top, var(--bg) 75%, transparent);
|
background: linear-gradient(to top, var(--bg) 75%, transparent);
|
||||||
padding: 18px 0 16px;
|
padding: 18px 0 16px;
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
@@ -1952,7 +1963,12 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
|
|||||||
A plain chat scratchpad, styled as a transcript rather than as the story
|
A plain chat scratchpad, styled as a transcript rather than as the story
|
||||||
page: UI font, role labels, and a sticky composer at the bottom. */
|
page: UI font, role labels, and a sticky composer at the bottom. */
|
||||||
|
|
||||||
.chat-page { max-width: 820px; display: flex; flex-direction: column; }
|
.chat-page {
|
||||||
|
max-width: 820px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-bottom: var(--kb-inset, 0px); /* see .play-page */
|
||||||
|
}
|
||||||
.chat-meta {
|
.chat-meta {
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
margin: -10px 0 16px;
|
margin: -10px 0 16px;
|
||||||
@@ -2024,7 +2040,7 @@ button.primary.compact { padding: 3px 12px; font-size: 0.76rem; margin-left: aut
|
|||||||
|
|
||||||
.chat-composer {
|
.chat-composer {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: var(--kb-inset, 0px); /* clears the on-screen keyboard — see .play-controls */
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
// Mobile virtual keyboards vs. the bottom-sticky composers.
|
||||||
|
//
|
||||||
|
// A phone keyboard covers the bottom of the screen without shrinking the
|
||||||
|
// layout viewport, so `position: sticky; bottom: 0` keeps pinning the composer
|
||||||
|
// to a bottom edge the user can no longer see — tap the box and it vanishes
|
||||||
|
// behind the keyboard. Chromium can be told to shrink the layout viewport
|
||||||
|
// instead (`interactive-widget=resizes-content` on the viewport meta in
|
||||||
|
// index.html), which fixes it there. iOS Safari ignores that flag and only
|
||||||
|
// ever shrinks the *visual* viewport, so measure the overlap ourselves and
|
||||||
|
// publish it as --kb-inset for the composers to lift by.
|
||||||
|
//
|
||||||
|
// Where the browser did resize the layout viewport, window.innerHeight shrank
|
||||||
|
// along with it and this measures ~0 — the same CSS is a no-op there rather
|
||||||
|
// than a double lift.
|
||||||
|
|
||||||
|
const MIN_KEYBOARD = 80 // px — below this it's browser chrome, not a keyboard
|
||||||
|
|
||||||
|
export function trackKeyboardInset() {
|
||||||
|
const vv = window.visualViewport
|
||||||
|
if (!vv) return
|
||||||
|
const apply = () => {
|
||||||
|
// Pinch-zoom shrinks the visual viewport too, and that is not a keyboard.
|
||||||
|
const covered = vv.scale > 1.01 ? 0 : window.innerHeight - vv.height - vv.offsetTop
|
||||||
|
const inset = covered > MIN_KEYBOARD ? Math.round(covered) : 0
|
||||||
|
document.documentElement.style.setProperty('--kb-inset', `${inset}px`)
|
||||||
|
}
|
||||||
|
// resize fires as the keyboard animates in; scroll fires as the page pans
|
||||||
|
// under a keyboard that is already up, which moves offsetTop.
|
||||||
|
vv.addEventListener('resize', apply)
|
||||||
|
vv.addEventListener('scroll', apply)
|
||||||
|
apply()
|
||||||
|
}
|
||||||
@@ -11,8 +11,11 @@ import Scripts from './pages/Scripts.jsx'
|
|||||||
import ScriptEditor from './pages/ScriptEditor.jsx'
|
import ScriptEditor from './pages/ScriptEditor.jsx'
|
||||||
import Settings from './pages/Settings.jsx'
|
import Settings from './pages/Settings.jsx'
|
||||||
import Chat from './pages/Chat.jsx'
|
import Chat from './pages/Chat.jsx'
|
||||||
|
import { trackKeyboardInset } from './keyboard.js'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
|
trackKeyboardInset()
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
Reference in New Issue
Block a user