Merge pull request #407 from Ark0N/feat/iphone-duo

iPhone Duo support: fold-aware dialogs, and a fold is no longer mistaken for the keyboard
This commit is contained in:
Ark0N
2026-09-14 16:10:38 +02:00
committed by GitHub
10 changed files with 1012 additions and 15 deletions
+56 -8
View File
@@ -223,6 +223,10 @@ const MobileDetection = {
const KeyboardHandler = {
VIEWPORT_SETTLE_MS: 80,
lastViewportHeight: 0,
// Width of the visual viewport at the previous resize event. A virtual
// keyboard never changes it, so a change here means the device itself
// changed shape. See handleViewportResize().
lastViewportWidth: 0,
keyboardVisible: false,
initialViewportHeight: 0,
_viewportSettleTimer: null,
@@ -241,6 +245,9 @@ const KeyboardHandler = {
this.initialViewportHeight = window.visualViewport?.height || window.innerHeight;
this.lastViewportHeight = this.initialViewportHeight;
// Seed the width too, or the first resize event reads as a shape change and
// swallows a real keyboard.
this.lastViewportWidth = window.visualViewport?.width || window.innerWidth;
// Simple focus handler - scroll input into view after keyboard appears
this._focusinHandler = (e) => {
@@ -307,13 +314,35 @@ const KeyboardHandler = {
this._settleAnchorY = null;
},
/** Handle viewport resize (keyboard show/hide) */
/**
* Handle viewport resize (keyboard show/hide).
*
* ⚠️ A resize that changes the viewport WIDTH is the device changing shape
* (a rotation, or a foldable opening or closing), and is never a virtual
* keyboard, which only ever takes height. Without that distinction, closing
* an iPhone Duo (626→466pt wide, 890→678pt tall) drops the height by more
* than the 150px threshold, so the app latched `keyboardVisible` with no
* keyboard on screen: the accessory bar appeared, `main` grew 84px of dead
* padding, and `updateAppHeight()` (which bails while the keyboard is up)
* stopped refreshing --app-height. The latch is sticky, because clearing it
* needs the height back within 100px of a baseline that is now a display the
* user is no longer looking at, so it survived until the device was opened
* again. Rotating any phone hit the same latch; the fold just makes it a
* routine gesture rather than a rare one.
*
* The shape-change branch re-baselines instead, which is also what lets a
* keyboard opened AFTER the fold be detected against the new display.
*/
handleViewportResize() {
const currentHeight = window.visualViewport?.height || window.innerHeight;
const currentWidth = window.visualViewport?.width || window.innerWidth;
const shapeChanged = currentWidth !== this.lastViewportWidth;
this.lastViewportWidth = currentWidth;
const heightDiff = this.initialViewportHeight - currentHeight;
// Keyboard appeared (viewport shrunk by more than 150px)
if (heightDiff > 150 && !this.keyboardVisible) {
// Keyboard appeared (viewport shrunk by more than 150px). Both detection
// branches are skipped on a shape change, whichever way the height moved.
if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true;
document.body.classList.add('keyboard-visible');
// While the keyboard is open, size the app to the visual viewport so
@@ -324,7 +353,7 @@ const KeyboardHandler = {
// Keyboard hidden (viewport grew back close to initial)
// Use 100px threshold (not 50) to handle iOS address bar drift,
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes
else if (heightDiff < 100 && this.keyboardVisible) {
else if (!shapeChanged && heightDiff < 100 && this.keyboardVisible) {
this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible');
this.onKeyboardHide();
@@ -333,11 +362,30 @@ const KeyboardHandler = {
MobileDetection.updateAppHeight();
}
// Update baseline when keyboard is not visible — adapts to address bar
// state changes, orientation changes, and other viewport shifts
if (!this.keyboardVisible) {
// Update baseline when keyboard is not visible: adapts to address bar
// state changes, orientation changes, and other viewport shifts. A shape
// change re-baselines even with the keyboard up (it may genuinely still be
// open, but its old baseline belongs to a display that is gone), and still
// writes --app-height below so the keyboard-open sizing follows the new
// display.
//
// ⚠️ With the keyboard up, the new baseline must be the KEYBOARD-FREE
// height of the display the device moved to, which is window.innerHeight
// (the layout viewport; the page sets no interactive-widget, so the
// keyboard shrinks only the visual viewport on both engines, the same
// fact updateLayoutForKeyboard() relies on). Baselining to the SHRUNK
// visual height made heightDiff 0, so the very next same-width resize
// (the settle event the OS animation produces, or any address-bar drift)
// satisfied the hide branch and tore the keyboard layout down with the
// keyboard still on screen, and it could not recover: no further 150px
// drop can re-arm the show branch against a baseline that already sits
// at the shrunk height.
if (shapeChanged) {
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
} else if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight;
} else {
}
if (this.keyboardVisible) {
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
}
+12
View File
@@ -3850,3 +3850,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
transition: none;
}
}
/* Folding devices, tabletop pose: cap the response viewer to the bottom
segment. Twin of the rule at the end of styles.css, needed here because the
phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same
specificity, and this file loads later, so the styles.css copy loses on a
phone-width foldable. Keep the value identical; test/foldable-layout.test.ts
compares them. */
@media (vertical-viewport-segments: 2) {
.response-viewer {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
+133
View File
@@ -18066,3 +18066,136 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
transition: none;
}
}
/* ============================================================
=== Folding devices: keep dialogs off the hinge ===
Apple's "Designing for iPhone Duo" calls the band a partly-open
display folds through a RESERVED REGION: content avoids covering
it, and system components (alerts, sheets, context menus) move
aside for it. On the web that region is described by the CSS
Viewport Segments media features and env() variables, which report
two segments only while a foldable is actually bent. Flat, open
or closed, it is one segment and everything below is inert.
Codeman's centred overlays are all `position: fixed; inset: 0`
flex-centring boxes, so their dialog lands dead on the hinge in
book pose (a vertical fold) or tabletop pose (a horizontal one).
The fix shrinks the CONTENT box with padding rather than the box
itself, so each overlay's backdrop still covers the whole viewport
and still swallows taps on the far side of the fold. Shrinking
the box would leave the trailing segment unshaded and live.
⚠️ Each rule re-states the overlay's OWN gutter, because a
later `padding-right` longhand beats the earlier `padding`
shorthand it is composing with and would otherwise erase it.
test/foldable-layout.test.ts reads both numbers out of this file
and fails if they drift apart, and simulates the cascade at every
breakpoint so a base gutter overridden by a later @media block (the
phone path picker below) needs its own zero-base restatement.
⚠️ Physical sides, not logical ones: dialogs go in the LEFT
segment (and the TOP one in tabletop pose) in every language. The
HIG keeps Duo's side controls on the same physical edge in RTL
because they are aligned with the hardware, and a dialog that
changed sides with the text direction would fight that.
============================================================ */
:root {
/* Width of the trailing strip to leave clear so a centred dialog cannot sit
under a vertical hinge, and the matching bottom strip for a horizontal one.
0px on every non-folding device, and on a foldable held flat. */
--fold-inline-end: 0px;
--fold-block-end: 0px;
}
@media (horizontal-viewport-segments: 2) {
:root {
--fold-inline-end: calc(100vw - env(viewport-segment-right 0 0, 100vw));
}
}
@media (vertical-viewport-segments: 2) {
:root {
--fold-block-end: calc(100vh - env(viewport-segment-bottom 0 0, 100vh));
}
}
/* No gutter of their own. */
.modal,
.file-preview-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
/* The palette is a .modal, so the generic rule above covers it everywhere
EXCEPT the 600-768px band, where mobile.css (loaded after this file) pads
it with the SHORTHAND `10vh 0.75rem 0`: that shorthand beats the generic
rule on both sides, so this compound rule (0,2,0) restates that band's own
gutters, 0.75rem at the side and none at the bottom, plus the fold strips.
Scoped to the same band on purpose: unscoped, it ADDED 0.75rem on every
other width, where the palette has no side gutter to compose with, and
pushed the shell 6px off centre (measured at 393, 900 and 1400). */
@media (max-width: 768px) and (min-width: 600px) {
.modal.command-palette-modal {
padding-right: calc(0.75rem + var(--fold-inline-end));
padding-bottom: var(--fold-block-end);
}
}
.path-picker-overlay {
padding-right: calc(16px + var(--fold-inline-end));
padding-bottom: calc(16px + var(--fold-block-end));
}
.path-preview-overlay {
padding-right: calc(18px + var(--fold-inline-end));
padding-bottom: calc(18px + var(--fold-block-end));
}
/* Under 600px both dialogs are flush sheets: their `@media (max-width: 600px)`
rules drop the gutter to 0 (borderless right and bottom edges, the picker
docked to the bottom). The two rules above sit later in the file at the same
specificity, so on their own they put a 16px and 18px gutter back on every
phone, fold or no fold (measured at 393 and 500: dialog edges floating 16px
off the screen edge). Restate the fold strip on a ZERO base here. */
@media (max-width: 600px) {
.path-picker-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
.path-preview-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
}
.offline-overlay {
padding-right: calc(20px + var(--fold-inline-end));
padding-bottom: calc(20px + var(--safe-area-bottom) + var(--fold-block-end));
}
.solo-gone-overlay {
padding-right: calc(24px + var(--fold-inline-end));
padding-bottom: calc(24px + var(--fold-block-end));
}
/* Top-anchored, so only the trailing side and the bottom stop matter. */
.paste-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
/* The response viewer is a bottom sheet, so a vertical hinge running through it
is fine, since it is a wide surface like the terminal and inset dialogs are what
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
sheet would climb out of the bottom segment and fold away mid-transcript.
⚠️ mobile.css carries an identical twin at its end: its 430px block sets
`max-height: 92dvh` at the same specificity and loads later, so this copy
alone loses on a phone-width foldable. test/foldable-layout.test.ts pins
the two values equal. */
@media (vertical-viewport-segments: 2) {
.response-viewer {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}