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
+234
View File
@@ -0,0 +1,234 @@
/**
* @fileoverview Folding devices: dialogs stay off the hinge, and a fold never
* changes which settings the device is using.
*
* 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
* move aside for it. On the web that region is described by the CSS Viewport
* Segments media features and env() variables, so the styles.css section this
* file guards is the whole mechanism.
*
* Two things about it fail silently and neither is observable without the
* hardware, which is why they are pinned here rather than left to a device lab:
*
* 1. Each fold rule RE-STATES the overlay's own gutter, because a later
* `padding-right` longhand beats the earlier `padding` shorthand it composes
* with and would otherwise erase it. The two numbers are read out of the
* stylesheet below and compared, so changing one alone fails here.
* 2. The overlay list is DERIVED, not typed out: every `position: fixed;
* inset: 0` flex-centring box in styles.css must have a fold rule. A new
* overlay added without one would centre its dialog on the hinge, and
* nothing else in the suite would notice.
*
* Parsed with postcss rather than regexes because the values are calc()
* expressions and some of the rules live in @media blocks. Rendered behaviour
* needs a real foldable; this is the cheap regression fence. Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import postcss, { type Rule } from 'postcss';
import { describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const STYLES = postcss.parse(readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8'));
const MOBILE = postcss.parse(readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'));
type Decls = Record<string, string>;
function declsOf(rule: Rule): Decls {
const out: Decls = {};
rule.walkDecls((d) => {
out[d.prop] = d.value;
});
return out;
}
/** Every rule in a stylesheet whose selector list contains `selector`. */
function rulesFor(root: postcss.Root, selector: string): Rule[] {
const found: Rule[] = [];
root.walkRules((rule) => {
if (rule.selectors.includes(selector)) found.push(rule);
});
return found;
}
/**
* The centred overlays, derived from the stylesheet. `.modal` is `display:none`
* until `.modal.active`, so display is deliberately not part of the shape.
*/
const CENTRED_OVERLAYS: { selector: string; decls: Decls }[] = [];
STYLES.walkRules((rule) => {
const d = declsOf(rule);
if (d.position === 'fixed' && d.inset === '0' && d['justify-content'] === 'center') {
CENTRED_OVERLAYS.push({ selector: rule.selector, decls: d });
}
});
/**
* The side of a `padding` shorthand that applies to `side`. Every centred
* overlay uses a one-value shorthand today; anything else throws rather than
* being guessed at, since a wrong guess would silently weaken the comparison.
*/
function shorthandSide(value: string): string {
const parts = value.trim().split(/\s+/);
if (parts.length !== 1) throw new Error(`multi-value padding shorthand not handled: ${value}`);
return parts[0];
}
/** What an overlay's padding on `side` resolves to before the fold rule. */
function effectivePadding(decls: Decls, side: 'right' | 'bottom'): string | null {
const longhand = decls[`padding-${side}`];
if (longhand) return longhand;
if (decls.padding) return shorthandSide(decls.padding);
return null;
}
/** The value a fold rule must carry to add `foldVar` without dropping `base`. */
function composed(base: string | null, foldVar: string): string {
if (base === null || base === '0' || base === '0px') return `var(${foldVar})`;
const inner = base.startsWith('calc(') ? base.slice('calc('.length, -1) : base;
return `calc(${inner} + var(${foldVar}))`;
}
/** The rule that adds the fold inset to `selector`, wherever it lives. */
function foldRuleFor(selector: string): Rule | undefined {
return STYLES.nodes
.filter((n): n is Rule => n.type === 'rule')
.find((rule) => {
if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return false;
const d = declsOf(rule);
return Object.values(d).some((v) => v.includes('--fold-inline-end') || v.includes('--fold-block-end'));
});
}
describe('fold reserved region: custom properties', () => {
it('defaults to zero, so nothing moves on a device that does not fold', () => {
const roots = rulesFor(STYLES, ':root').map(declsOf);
const defaults = roots.filter((d) => d['--fold-inline-end'] || d['--fold-block-end']);
// The overriding definitions live inside @media blocks, which walkRules
// reaches too, so the unconditional one is the last top-level :root.
expect(defaults.length).toBeGreaterThanOrEqual(3);
expect(defaults[0]['--fold-inline-end']).toBe('0px');
expect(defaults[0]['--fold-block-end']).toBe('0px');
});
it('measures the strip from the LEADING segment in each axis', () => {
// env() indices are [column, row] with (0,0) the top-left segment, so the
// left segment's right edge is `0 0` and the top segment's bottom edge is
// `0 0` as well. Swapping an index silently measures the wrong strip.
const byQuery = new Map<string, Decls>();
STYLES.walkAtRules('media', (at) => {
at.walkRules(':root', (rule) => byQuery.set(at.params, declsOf(rule)));
});
expect(byQuery.get('(horizontal-viewport-segments: 2)')?.['--fold-inline-end']).toBe(
'calc(100vw - env(viewport-segment-right 0 0, 100vw))'
);
expect(byQuery.get('(vertical-viewport-segments: 2)')?.['--fold-block-end']).toBe(
'calc(100vh - env(viewport-segment-bottom 0 0, 100vh))'
);
});
it('caps the response viewer to the bottom segment in tabletop pose', () => {
// A vertical hinge through a full-width bottom sheet is fine; a horizontal
// one folds the transcript away mid-read.
const rule = rulesFor(STYLES, '.response-viewer').find((r) =>
declsOf(r)['max-height']?.includes('viewport-segment')
);
expect(rule?.parent).toMatchObject({ params: '(vertical-viewport-segments: 2)' });
expect(declsOf(rule!)['max-height']).toBe('min(88vh, env(viewport-segment-height 0 1, 88vh))');
});
});
describe('fold reserved region: every centred overlay is covered', () => {
it('finds the overlays it is meant to guard', () => {
// A rename that empties this list would turn every assertion below into a
// no-op, so the count is pinned.
expect(CENTRED_OVERLAYS.length).toBe(7);
});
it.each(CENTRED_OVERLAYS.map((o) => [o.selector, o] as const))('%s keeps its dialog out of the hinge', (_, o) => {
const fold = foldRuleFor(o.selector);
expect(fold, `${o.selector} has no fold rule`).toBeDefined();
const d = declsOf(fold!);
expect(d['padding-right']).toBe(composed(effectivePadding(o.decls, 'right'), '--fold-inline-end'));
expect(d['padding-bottom']).toBe(composed(effectivePadding(o.decls, 'bottom'), '--fold-block-end'));
});
it('outranks the padding shorthand mobile.css gives the command palette', () => {
// mobile.css loads after styles.css and sets a `padding` SHORTHAND on
// .command-palette-modal under 768px, exactly the width a folding phone
// lives at, so a bare .command-palette-modal rule here would lose to it.
const mobileRule = rulesFor(MOBILE, '.command-palette-modal').find((r) => declsOf(r).padding);
expect(mobileRule, 'mobile.css no longer pads the palette; this rule can be simplified').toBeDefined();
const sideGutter = declsOf(mobileRule!).padding.trim().split(/\s+/)[1];
const fold = foldRuleFor('.command-palette-modal');
expect(fold?.selector).toBe('.modal.command-palette-modal');
expect(declsOf(fold!)['padding-right']).toBe(`calc(${sideGutter} + var(--fold-inline-end))`);
});
});
/**
* Load the real MobileDetection against a given UA and viewport width.
* `const MobileDetection = {...}` is lexical, so the export rides the same
* script, the recipe used by the other mobile-handlers tests.
*/
function detectionFor(userAgent: string, width: number) {
const context = vm.createContext({
console,
navigator: { userAgent, maxTouchPoints: 5 },
window: {
innerWidth: width,
innerHeight: 800,
addEventListener: () => {},
matchMedia: () => ({ matches: true }),
},
document: { body: { classList: { add: () => {}, remove: () => {} } }, addEventListener: () => {} },
setTimeout: () => 1,
clearTimeout: () => {},
});
vm.runInContext(
`${readFileSync(resolve(PUBLIC, 'mobile-handlers.js'), 'utf8')}\nglobalThis.__MD = MobileDetection;`,
context,
{ filename: 'mobile-handlers.js' }
);
return (context as unknown as { __MD: { isHandheldDevice(): boolean; getDeviceType(): string } }).__MD;
}
describe('a fold never changes which settings the device is using', () => {
// Per-device settings are namespaced on isHandheldDevice(), which is
// form-factor based precisely so it holds still while getDeviceType() (a
// layout decision) follows the width. A posture change that flipped the
// namespace would drop every opt-in setting the user saved while folded, and
// an Android foldable really does reload the page when it opens.
const postures = [
{ name: 'iPhone Duo (outer)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Mobile/15E148', w: 466 },
{ name: 'iPhone Duo (inner)', ua: 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_0 like Mac OS X) Mobile/15E148', w: 626 },
{ name: 'Find N5 (folded)', ua: 'Mozilla/5.0 (Linux; Android 15; CPH2671) Mobile Safari/537.36', w: 404 },
{ name: 'Find N5 (unfolded)', ua: 'Mozilla/5.0 (Linux; Android 15; CPH2671) Mobile Safari/537.36', w: 1124 },
];
it.each(postures)('$name stays handheld', ({ ua, w }) => {
expect(detectionFor(ua, w).isHandheldDevice()).toBe(true);
});
it('lets the layout follow the width even when it crosses a breakpoint', () => {
const n5 = postures[3];
expect(detectionFor(n5.ua, n5.w).getDeviceType()).toBe('desktop');
expect(detectionFor(postures[2].ua, postures[2].w).getDeviceType()).toBe('mobile');
});
it('gives both iPhone Duo displays the tablet layout', () => {
// 466 and 626 both sit above the 430px phone cut and below 768. Deliberate
// (see shouldUseMobileOverview), and pinned because a 5.4" phone landing in
// the tablet band is the kind of thing that looks like a bug later.
expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('tablet');
expect(detectionFor(postures[1].ua, postures[1].w).getDeviceType()).toBe('tablet');
});
});
+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');
+216
View File
@@ -0,0 +1,216 @@
// Port: none (pure logic in a vm context: no browser, no server).
//
// A virtual keyboard only ever takes HEIGHT off the visual viewport. Anything
// that changes its WIDTH is the device changing shape: a rotation, or a
// foldable opening or closing.
//
// KeyboardHandler.handleViewportResize() used to read any height drop over
// 150px as the keyboard appearing, so closing a foldable 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:
// clearing it needs the height back within 100px of a baseline belonging to 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.
//
// Lives outside test/mobile/ deliberately, because that suite is
// Playwright-driven and excluded from `npm run test:ci`, so a regression
// guarded only there is invisible to CI (same reasoning as the note in
// mobile-keyboard-bottom-padding.test.ts).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
interface Handler {
init(): void;
handleViewportResize(): void;
keyboardVisible: boolean;
initialViewportHeight: number;
lastViewportWidth: number;
}
/** iPhone Duo, both postures, in CSS px (see test/mobile/devices.ts). */
const DUO_INNER = { width: 626, height: 890 };
const DUO_OUTER = { width: 466, height: 678 };
/** iOS keyboard over the inner display: height only. */
const KEYBOARD_HEIGHT = 300;
/**
* Load mobile-handlers.js against a fake DOM and return its KeyboardHandler
* plus the mutable viewport it reads.
*
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to
* a second `vm.runInContext`, so the export is appended to the SAME script.
*/
function loadHandler(start: { width: number; height: number }) {
const viewport = { ...start, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} };
const bodyClasses = new Set<string>();
const appHeight: string[] = [];
const context = vm.createContext({
console,
app: { relayoutMobileSubagentWindows: () => {} },
navigator: { userAgent: 'iPhone', maxTouchPoints: 5 },
window: {
get innerWidth() {
return viewport.width;
},
get innerHeight() {
return viewport.height;
},
visualViewport: viewport,
addEventListener: () => {},
removeEventListener: () => {},
matchMedia: () => ({ matches: true }),
scrollTo: () => {},
},
document: {
body: {
classList: {
add: (c: string) => bodyClasses.add(c),
remove: (c: string) => bodyClasses.delete(c),
},
},
documentElement: {
style: {
setProperty: (name: string, value: string) => {
if (name === '--app-height') appHeight.push(value);
},
},
},
addEventListener: () => {},
removeEventListener: () => {},
getElementById: () => null,
querySelector: () => null,
},
setTimeout: () => 1,
clearTimeout: () => {},
});
vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' });
const handler = (context as unknown as { __KH: Handler }).__KH;
handler.init();
/** Move the viewport and fire the resize the browser would fire. */
const resizeTo = (width: number, height: number) => {
viewport.width = width;
viewport.height = height;
handler.handleViewportResize();
};
return { handler, resizeTo, bodyClasses, appHeight };
}
describe('handleViewportResize: height-only changes are the keyboard', () => {
it('detects the keyboard opening', () => {
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
// The baseline must survive the keyboard, or closing it is undetectable.
expect(handler.initialViewportHeight).toBe(DUO_INNER.height);
});
it('detects the keyboard closing', () => {
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
resizeTo(DUO_INNER.width, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
it('reads a drop on the very first resize as the keyboard', () => {
// init() has to seed lastViewportWidth, or this first event looks like a
// width change (0 → 626) and swallows a real keyboard.
const { handler, resizeTo } = loadHandler(DUO_INNER);
expect(handler.lastViewportWidth).toBe(DUO_INNER.width);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
});
it('ignores address-bar drift, which is under the threshold', () => {
const { handler, resizeTo } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - 90);
expect(handler.keyboardVisible).toBe(false);
});
});
describe('handleViewportResize: width changes are the device changing shape', () => {
it('does not read closing a foldable as the keyboard', () => {
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
// 890 → 678 is a 212px drop, well past the 150px keyboard threshold.
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
it('re-baselines to the display it moved to', () => {
const { handler, resizeTo } = loadHandler(DUO_INNER);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('detects a keyboard opened after the fold', () => {
// The re-baseline is what makes this work: measured against the old inner
// baseline the outer display's keyboard is a 512px drop that was already
// "open", and against no baseline at all it would never be seen.
const { handler, resizeTo } = loadHandler(DUO_INNER);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
});
it('does not read opening a foldable as the keyboard closing', () => {
const { handler, resizeTo } = loadHandler(DUO_OUTER);
resizeTo(DUO_INNER.width, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(false);
expect(handler.initialViewportHeight).toBe(DUO_INNER.height);
});
it('does not read a rotation as the keyboard', () => {
// The same latch, on hardware that has shipped for years: 659 → 330 is a
// 329px drop with no keyboard anywhere.
const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 });
resizeTo(852, 330);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
expect(handler.initialViewportHeight).toBe(330);
});
it('keeps --app-height following the new display when the keyboard was up', () => {
// Rotating with the keyboard open cannot be told from folding with it open,
// so keyboardVisible is left alone, but the baseline moves and the
// keyboard-open sizing has to follow the display rather than freeze on the
// one that is gone (updateAppHeight() bails while the keyboard is up).
const { handler, resizeTo, appHeight } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT);
expect(appHeight.at(-1)).toBe(`${DUO_OUTER.height - KEYBOARD_HEIGHT}px`);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height - KEYBOARD_HEIGHT);
});
});