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
+452
View File
@@ -0,0 +1,452 @@
/**
* @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.
*
* Three things about it fail silently and none 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.
* 3. The gutter an overlay ends up with is a CASCADE across two files and
* several breakpoints, not one rule: a later @media block can zero it (the
* phone path picker under 600px), mobile.css can replace it with a
* shorthand (the palette between 600 and 768px) and, loading later, can
* outrank a same-specificity rule (the response viewer under 600px). So the
* cascade is simulated at every breakpoint, once with the fold rules and
* once without, and the two results must differ by exactly the fold strip.
* Each of the three shipped once with the top-level-only comparison green.
*
* 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}))`;
}
function isFoldValue(value: string): boolean {
return value.includes('--fold-inline-end') || value.includes('--fold-block-end');
}
/** Every rule that adds the fold inset to `selector`, top level or inside @media, in source order. */
function foldRulesFor(selector: string): Rule[] {
const found: Rule[] = [];
STYLES.walkRules((rule) => {
if (!rule.selectors.some((s) => s === selector || s.endsWith(selector))) return;
if (Object.values(declsOf(rule)).some(isFoldValue)) found.push(rule);
});
return found;
}
/** The first (unconditional, for the derived overlays) fold rule for `selector`. */
function foldRuleFor(selector: string): Rule | undefined {
return foldRulesFor(selector)[0];
}
// ─── Cascade simulation ──────────────────────────────────────────────────────
//
// A small model of what the browser does for one element's padding: every rule
// in styles.css then mobile.css (index.html link order) whose selector is a
// class compound matching the element, whose enclosing @media matches the
// width, ordered by specificity then source order, shorthand expanded to the
// side asked for. Deliberately narrow: rules nested inside another rule (the
// skin block) or under an at-rule other than @media / @supports are ignored,
// and a media query with any feature other than min/max-width is treated as
// not matching, which is right for a FLAT device (viewport-segments queries
// only match while bent). The numbers it produces were checked against
// getComputedStyle in headless Chromium at every width below.
type Side = 'right' | 'bottom';
interface PaddingDecl {
order: number;
file: 'styles.css' | 'mobile.css';
classes: string[];
specificity: number;
media: string | null;
prop: 'padding' | `padding-${Side}`;
value: string;
fold: boolean;
}
/** `.a.b` -> ['a', 'b']; anything that is not a pure class compound -> null. */
function classCompound(selector: string): string[] | null {
const trimmed = selector.trim();
if (!/^(\.[A-Za-z0-9_-]+)+$/.test(trimmed)) return null;
return trimmed.slice(1).split('.');
}
const PADDING_DECLS: PaddingDecl[] = [];
{
let order = 0;
for (const [file, root] of [
['styles.css', STYLES],
['mobile.css', MOBILE],
] as const) {
root.walkRules((rule) => {
const media: string[] = [];
let nested = false;
for (let p = rule.parent; p && p.type !== 'root'; p = p.parent) {
if (p.type === 'rule') nested = true;
else if (p.type === 'atrule' && p.name === 'media') media.push(p.params);
else if (p.type === 'atrule' && p.name !== 'supports') nested = true;
}
if (nested) return;
for (const selector of rule.selectors) {
const classes = classCompound(selector);
if (!classes) continue;
rule.each((node) => {
if (node.type !== 'decl') return;
if (!/^padding(-right|-bottom)?$/.test(node.prop)) return;
PADDING_DECLS.push({
order: order++,
file,
classes,
specificity: classes.length,
media: media.length ? media.join(' and ') : null,
prop: node.prop as PaddingDecl['prop'],
value: node.value,
fold: isFoldValue(node.value),
});
});
}
});
}
}
/** Does a width-only media query match `width`? Anything else is "not on a flat device". */
function mediaMatches(params: string, width: number): boolean {
return params.split(',').some((alt) =>
alt.split(/\s+and\s+/).every((term) => {
const t = term.trim();
if (t === 'screen' || t === 'all') return true;
const m = /^\((max|min)-width:\s*(\d+(?:\.\d+)?)px\)$/.exec(t);
if (!m) return false;
return m[1] === 'max' ? width <= Number(m[2]) : width >= Number(m[2]);
})
);
}
/** Split a shorthand on whitespace outside parentheses. */
function tokens(value: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = '';
for (const ch of value.trim()) {
if (ch === '(') depth++;
if (ch === ')') depth--;
if (/\s/.test(ch) && depth === 0) {
if (cur) out.push(cur);
cur = '';
} else cur += ch;
}
if (cur) out.push(cur);
return out;
}
/** The side of a 1-4 value `padding` shorthand. */
function shorthandSideOf(value: string, side: Side): string {
const t = tokens(value);
if (t.length < 1 || t.length > 4) throw new Error(`padding shorthand not handled: ${value}`);
const [top, right = top, bottom = top, left = right] = t;
void left;
return side === 'right' ? right : bottom;
}
/**
* What `padding-<side>` resolves to for an element carrying `classes` at
* `width`, as the declaration VALUE that wins (null when nothing sets it).
* `withFold: false` drops every declaration that references a fold variable,
* which is the cascade a non-folding build would have.
*/
function cascadedPadding(classes: string[], side: Side, width: number, withFold: boolean): string | null {
const have = new Set(classes);
const winners = PADDING_DECLS.filter(
(d) =>
(withFold || !d.fold) &&
(d.file === 'styles.css' || width <= 1023) &&
(d.media === null || mediaMatches(d.media, width)) &&
d.classes.every((c) => have.has(c)) &&
(d.prop === 'padding' || d.prop === `padding-${side}`)
);
winners.sort((a, b) => a.specificity - b.specificity || a.order - b.order);
const last = winners.at(-1);
if (!last) return null;
return last.prop === 'padding' ? shorthandSideOf(last.value, side) : last.value;
}
/** Every breakpoint either stylesheet keys on, plus a phone, a Duo posture and a desktop. */
const WIDTHS = [393, 430, 500, 600, 626, 768, 900, 1400];
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'));
});
/**
* The elements whose padding cascade is simulated: every derived overlay as
* a bare element, plus the open command palette, which is a `.modal` wearing
* two more classes and the one overlay mobile.css pads with a shorthand.
*/
const ELEMENTS: { name: string; classes: string[] }[] = [
...CENTRED_OVERLAYS.map((o) => ({ name: o.selector, classes: classCompound(o.selector)! })),
{ name: '.modal.command-palette-modal.active', classes: ['modal', 'command-palette-modal', 'active'] },
];
it('simulates the cascade the browser measured', () => {
// Anchors for the model, all read off getComputedStyle in headless
// Chromium (styles.css + mobile.css in index.html link order): the phone
// path picker is flush under 600px and keeps its 16px gutter above it;
// the palette carries mobile.css's 0.75rem side gutter only inside the
// 600-768px band. A model that cannot reproduce these numbers proves
// nothing about the fold rules built on top of them.
const picker = ['path-picker-overlay'];
expect(cascadedPadding(picker, 'right', 393, false)).toBe('0');
expect(cascadedPadding(picker, 'right', 626, false)).toBe('16px');
const palette = ELEMENTS.at(-1)!.classes;
expect(cascadedPadding(palette, 'right', 393, false)).toBeNull();
expect(cascadedPadding(palette, 'right', 626, false)).toBe('0.75rem');
expect(cascadedPadding(palette, 'bottom', 626, false)).toBe('0');
expect(cascadedPadding(palette, 'right', 900, false)).toBeNull();
});
it.each(ELEMENTS.map((e) => [e.name, e.classes] as const))(
'%s ends up with exactly its own gutter plus the fold strip at every breakpoint',
(_, classes) => {
for (const width of WIDTHS) {
for (const side of ['right', 'bottom'] as const) {
const foldVar = side === 'right' ? '--fold-inline-end' : '--fold-block-end';
const base = cascadedPadding(classes, side, width, false);
const actual = cascadedPadding(classes, side, width, true);
expect(actual, `padding-${side} at ${width}px (base ${base})`).toBe(composed(base, foldVar));
}
}
}
);
it('composes with the padding shorthand mobile.css gives the command palette, inside that band only', () => {
// mobile.css loads after styles.css and sets a `padding` SHORTHAND on
// .command-palette-modal between 600 and 768px, exactly where a folding
// phone lives, so a bare .command-palette-modal rule would lose to it and
// the compound rule has to restate BOTH of that band's gutters. Scoped to
// the same band: unscoped, it added 0.75rem where the palette has no side
// gutter at all and pushed the shell 6px off centre.
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 band = mobileRule!.parent;
expect(band).toMatchObject({ type: 'atrule', name: 'media' });
const shorthand = declsOf(mobileRule!).padding;
const fold = foldRulesFor('.command-palette-modal');
expect(fold).toHaveLength(1);
expect(fold[0].selector).toBe('.modal.command-palette-modal');
expect(fold[0].parent).toMatchObject({ type: 'atrule', name: 'media', params: (band as postcss.AtRule).params });
expect(declsOf(fold[0])['padding-right']).toBe(composed(shorthandSideOf(shorthand, 'right'), '--fold-inline-end'));
expect(declsOf(fold[0])['padding-bottom']).toBe(composed(shorthandSideOf(shorthand, 'bottom'), '--fold-block-end'));
});
it('gives the response viewer cap a later twin in mobile.css', () => {
// mobile.css sets `max-height` on .response-viewer at the same specificity
// under 600px and loads later, so the styles.css cap alone loses on a
// phone-width foldable. The twin must come after that rule and carry the
// identical value.
const capOf = (root: postcss.Root) =>
rulesFor(root, '.response-viewer').find((r) => declsOf(r)['max-height']?.includes('viewport-segment'));
const styles = capOf(STYLES);
const mobile = capOf(MOBILE);
expect(mobile, 'mobile.css has no twin of the tabletop cap').toBeDefined();
expect(mobile!.parent).toMatchObject({ params: '(vertical-viewport-segments: 2)' });
expect(declsOf(mobile!)['max-height']).toBe(declsOf(styles!)['max-height']);
const competing = rulesFor(MOBILE, '.response-viewer').filter((r) => r !== mobile && declsOf(r)['max-height']);
expect(competing.length).toBeGreaterThan(0);
for (const rule of competing) expect(rule.source!.start!.line).toBeLessThan(mobile!.source!.start!.line);
});
});
/**
* 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 the closed iPhone Duo the phone layout and the open one the tablet layout', () => {
// 466 sits under the 600px phone cut (#390 moved it up from 430) and 626
// above it, below 768. Deliberate (see shouldUseMobileOverview), and pinned
// because the tier flipping under a fold is the kind of thing that looks
// like a bug later: closed, the Duo is a phone; open, it is a small tablet.
expect(detectionFor(postures[0].ua, postures[0].w).getDeviceType()).toBe('mobile');
expect(detectionFor(postures[1].ua, postures[1].w).getDeviceType()).toBe('tablet');
});
});
+6 -6
View File
@@ -2,11 +2,11 @@
Comprehensive mobile UI testing for Codeman's web interface using Playwright with dual-engine support (Chromium + WebKit).
**326 tests across 136 devices — all passing.**
**326 tests across 138 devices — all passing.**
## Purpose
Validates Codeman's mobile UI across 136 devices, covering:
Validates Codeman's mobile UI across 138 devices, covering:
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
@@ -34,7 +34,7 @@ npm run test:mobile -- test/mobile/keyboard.test.ts
# Quick mode: 6 representative devices, skip full matrix
CI_QUICK=1 npm run test:mobile
# Full device matrix only (136 devices)
# Full device matrix only (138 devices)
npm run test:mobile -- test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run)
@@ -51,7 +51,7 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (138 devices) |
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
@@ -66,7 +66,7 @@ npm run test:mobile -- test/mobile/visual-regression.test.ts
| standard-tablet | 768–834px | ~8 | iPad Mini |
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
136 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 68 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
138 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 70 custom entries for newer devices (iPhone 16/17, iPhone Duo in both postures, Pixel 9, Galaxy S25, OPPO Find N5 unfolded, iPad Air M2, Surface Pro, etc.).
### How Devices Are Differentiated
@@ -106,7 +106,7 @@ Test File
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
├─ helpers/visual.ts → pixelmatch screenshot comparison
└─ devices.ts → 136-device registry
└─ devices.ts → 138-device registry
```
### Keyboard Simulation — 3-Layer Approach
+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. The postures straddle the 600px
// phone cut (#390): closed, 466 is a phone; open, 626 is a small tablet, so
// the layout tier flips with the fold. Deliberate, per the note on
// shouldUseMobileOverview(), and worth a profile precisely because it is
// easy to regress into a single-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');
+280
View File
@@ -0,0 +1,280 @@
// 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.
*
* Two heights are modelled, because the handler reads both: `visualViewport`
* is what the keyboard shrinks, and `window.innerHeight` is the layout
* viewport, which stays the display's full height while the keyboard is up
* (the page sets no interactive-widget, so the default resizes-visual mode
* holds on both engines). resizeTo() ties them unless a test says otherwise.
*
* `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 layout = { height: start.height };
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 layout.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 visual viewport and fire the resize the browser would fire.
* `layoutHeight` is window.innerHeight; pass the display's full height to
* model a keyboard that is up (the visual viewport shrunk, the layout one
* not), and leave it out for a plain resize, where the two agree.
*/
const resizeTo = (width: number, height: number, layoutHeight = height) => {
viewport.width = width;
viewport.height = height;
layout.height = layoutHeight;
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, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(true);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
expect(appHeight.at(-1)).toBe(`${DUO_OUTER.height - KEYBOARD_HEIGHT}px`);
// The baseline is the new display's KEYBOARD-FREE height (window.innerHeight),
// never the shrunk visual height, or the next resize reads as the keyboard
// closing (see the two tests below).
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('survives the settle event that follows a fold with the keyboard up', () => {
// The OS animates a shape change, so the browser fires one resize that
// changes the width and then at least one more at the settled width (see
// _scheduleViewportSettle). Baselining the first to the SHRUNK visual
// height made heightDiff 0 on the second, which satisfied the hide branch
// and tore the keyboard layout down with the keyboard still on screen, and
// nothing could re-arm the show branch against that baseline.
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('survives the settle event that follows a rotation with the keyboard up', () => {
// Same latch on a plain phone: portrait 393x659, keyboard up (visual 359),
// rotate to landscape 852x393 with the keyboard still up (visual 150),
// then the settle event at 160. The old baseline of 150 read the 160 as
// the keyboard closing.
const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 });
resizeTo(393, 359, 659);
expect(handler.keyboardVisible).toBe(true);
resizeTo(852, 150, 393);
resizeTo(852, 160, 393);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
expect(handler.initialViewportHeight).toBe(393);
});
it('still detects the keyboard closing after a fold with it up', () => {
// The keyboard-free baseline is what makes the eventual close visible:
// dismissing it returns the visual viewport to the display height.
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
});