mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(mobile): iPhone Duo support (fold-aware dialogs, no phantom keyboard)
Apple's "Designing for iPhone Duo" asks an app to adapt to both displays, to stay continuous as the device opens and closes, and to treat the band a partly-open display folds through as a reserved region. Three things here. 1. A visual-viewport resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is never the virtual keyboard, which only ever takes height. handleViewportResize() read any height drop over 150px as the keyboard appearing, so closing a Duo (890 to 678pt tall) latched keyboardVisible with no keyboard on screen: the accessory bar appeared, main grew 84px of dead padding, and updateAppHeight() stopped refreshing --app-height. The latch was sticky, because clearing it needs the height back within 100px of a baseline belonging to a display the user is no longer looking at. Rotating any phone hit the same latch. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected. 2. The hinge is now a reserved region in CSS. --fold-inline-end and --fold-block-end measure the strip to keep clear from the Viewport Segments env() variables, and are 0px everywhere else, so the seven centred overlays are inert by construction off a foldable. Each shrinks its content box with padding rather than the box itself, so the backdrop still covers the far side of the fold and still swallows taps there. 3. iPhone Duo (outer) and iPhone Duo (inner) join the mobile device registry, derived from Apple's published pixel specs at 3x. Verified in Chromium: flat, a dialog stays centred at 313 of a 626pt viewport; in book pose it centres at 153 inside the 0-305 leading segment with its right edge at 293, while the backdrop still spans all 626. The 3-term calc on the offline overlay resolves to 367px in tabletop pose and 20px flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
@@ -334,10 +363,15 @@ const KeyboardHandler = {
|
||||
}
|
||||
|
||||
// Update baseline when keyboard is not visible — adapts to address bar
|
||||
// state changes, orientation changes, and other viewport shifts
|
||||
if (!this.keyboardVisible) {
|
||||
// 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.
|
||||
if (shapeChanged || !this.keyboardVisible) {
|
||||
this.initialViewportHeight = currentHeight;
|
||||
} else {
|
||||
}
|
||||
if (this.keyboardVisible) {
|
||||
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
|
||||
}
|
||||
|
||||
|
||||
@@ -18066,3 +18066,106 @@ 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/iphone-duo-fold.test.ts reads both numbers out of this file
|
||||
and fails if they drift apart.
|
||||
|
||||
⚠️ 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);
|
||||
}
|
||||
|
||||
/* Specificity 0,2,0 on purpose: mobile.css loads after this file and gives the
|
||||
palette a `padding` SHORTHAND under 768px, exactly the width where a folding
|
||||
phone lives, so a bare .command-palette-modal rule here would lose to it. The
|
||||
0.75rem side gutter is that mobile rule's; the desktop rule sets no side
|
||||
padding, so composing with it is a no-op above 768px. */
|
||||
.modal.command-palette-modal {
|
||||
padding-right: calc(0.75rem + var(--fold-inline-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));
|
||||
}
|
||||
|
||||
.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. */
|
||||
@media (vertical-viewport-segments: 2) {
|
||||
.response-viewer {
|
||||
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user