mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user