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:
Codeman maintainer
2026-09-14 15:57:08 +02:00
parent a5cf1f6005
commit 8389423459
7 changed files with 658 additions and 7 deletions
+22
View File
@@ -279,6 +279,28 @@ const customEntries: DeviceEntry[] = [
// resolution CSS viewport crosses Codeman's desktop breakpoint while the
// browser remains a mobile/touch device.
custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false),
// iPhone Duo, both postures. Apple publishes pixels, not points: the outer
// display is 1398x2034 and the inner one 1878x2670, both @3x (460 and 430
// ppi over 5.36" and 7.58" diagonals), so the CSS viewports below are those
// divided by 3.
//
// No browser-chrome allowance is subtracted, unlike the other iOS entries:
// per Apple's "Designing for iPhone Duo", the system moves toolbars and tab
// bars to the SIDE on the outer display and on the inner one in landscape,
// so the ~193pt vertical allowance copied from other iPhones would be wrong
// in both axes. The registry's other foldable (Find N5) uses the full
// viewport for the same reason.
//
// The PAIR is what earns its place here. Both postures land in the tablet
// band (466 and 626 are each above the 430px phone cut and below 768), so a
// 5.4" phone in someone's hand gets the roomier layout. Deliberate, per the
// note on shouldUseMobileOverview(), and worth a profile precisely because it
// is easy to regress into a phone-width assumption. What must NOT move with
// the fold is the per-device settings identity, which is UA-based and
// therefore identical across the two; test/mobile/settings.test.ts pins it.
custom('iPhone Duo (outer)', 466, 678, 3, IOS_MOBILE_UA('26_0'), true),
custom('iPhone Duo (inner)', 626, 890, 3, IOS_MOBILE_UA('26_0'), true),
];
// ---------------------------------------------------------------------------
+40
View File
@@ -402,6 +402,46 @@ describe('Settings Modal', () => {
}
});
it('keeps handheld settings and finds no keyboard when an iPhone Duo closes', async () => {
// The Duo pair does not cross the desktop breakpoint the way Find N5 does
// (466 and 626 are both in the tablet band), so what this covers is the
// other half of "a continuous experience as the device opens and closes":
// the fold takes 212px of height, which handleViewportResize() used to
// read as the virtual keyboard appearing. Unit-covered in
// test/viewport-shape-change.test.ts; this drives the real resize.
const inner = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (inner)')!;
const outer = DEVICE_REGISTRY.find((entry) => entry.name === 'iPhone Duo (outer)')!;
const { page, context } = await createDevicePage(inner, BASE_URL, 'chromium');
try {
await page.evaluate((key) => {
localStorage.setItem(key, JSON.stringify({ showResponseViewer: true }));
}, STORAGE_KEYS.SETTINGS_MOBILE);
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(WAIT.SSE_CONNECT);
await page.setViewportSize(outer.viewport);
await page.waitForTimeout(WAIT.SSE_CONNECT);
const state = await page.evaluate(() => ({
handheld: (window as any).MobileDetection.isHandheldDevice(),
storageKey: (window as any).app.getSettingsStorageKey(),
// The two user-visible symptoms of the latch. KeyboardHandler itself
// is a script-scope const with no window export, and the flag is
// asserted directly in the unit test.
keyboardClass: document.body.classList.contains('keyboard-visible'),
mainPadding: (document.querySelector('.main') as HTMLElement | null)?.style.paddingBottom ?? '',
}));
expect(state.handheld).toBe(true);
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
expect(state.keyboardClass).toBe(false);
expect(state.mainPadding).toBe('');
} finally {
await context.close();
}
});
it('keeps handheld settings when a foldable unfolds past the desktop breakpoint', async () => {
const device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium');