From a5e9ea7ea5646bed2e34ce8a500b0ebb831754e6 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sat, 12 Sep 2026 05:25:49 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 2 + src/web/public/mobile-handlers.js | 48 +++++- src/web/public/styles.css | 103 +++++++++++++ test/foldable-layout.test.ts | 234 +++++++++++++++++++++++++++++ test/mobile/devices.ts | 22 +++ test/mobile/settings.test.ts | 40 +++++ test/viewport-shape-change.test.ts | 216 ++++++++++++++++++++++++++ 7 files changed, 658 insertions(+), 7 deletions(-) create mode 100644 test/foldable-layout.test.ts create mode 100644 test/viewport-shape-change.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 0483441be..ae3cbfadf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -322,6 +322,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Foldable settings identity**: responsive layout is width-driven via `MobileDetection.getDeviceType()`, but the localStorage namespace uses `MobileDetection.isHandheldDevice()` so an unfolded Android foldable keeps `codeman-app-settings-mobile`. ⚠️ Do not switch per-device settings namespaces from instantaneous viewport width: a posture-triggered WebView reload would lose opt-in UI. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. → [architecture-invariants#foldable-settings-identity](docs/architecture-invariants.md#foldable-settings-identity) +**Folding devices: a fold is not a keyboard, and dialogs avoid the hinge** (Apple's [Designing for iPhone Duo](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)): ⚠️ **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 an iPhone Duo (626→466pt wide, 890→678pt tall) 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, since 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, and rotating any phone hit it too. The shape branch re-baselines instead, which is also what lets a keyboard opened AFTER the fold be detected. ⚠️ `init()` must seed `lastViewportWidth`, or the very first resize reads as a shape change and swallows a real keyboard. ⚠️ **The hinge is a reserved region.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent, and `--fold-inline-end`/`--fold-block-end` (styles.css) measure the strip to keep clear: `0px` on everything else, so the rules are inert by construction rather than by a branch. Codeman's seven centred overlays are all `position: fixed; inset: 0` flex boxes, and 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. ⚠️ Each rule RE-STATES the overlay's own gutter (a later `padding-right` longhand beats the earlier `padding` shorthand it composes with), and the palette needs the compound `.modal.command-palette-modal` because mobile.css loads later and pads it with a shorthand under 768px. `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet and compares both numbers, so a new overlay or a moved gutter fails there instead of on hardware nobody has. ⚠️ Physical sides, not logical ones: dialogs sit in the LEFT segment (the TOP one in tabletop pose) in every language, because the HIG keeps Duo's side controls on the same physical edge in RTL. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. + **WebGL renderer toggle** (`webglRendererEnabled`, per-device): the GPU-stall watchdog's sticky `codeman-webgl-disabled` marker survives page loads and is cleared only by an explicit OFF→ON save or `?webgl=force`. `?nowebgl` forces the DOM renderer per-load. → [architecture-invariants#webgl-renderer-toggle](docs/architecture-invariants.md#webgl-renderer-toggle) **Shell keyboard accessory bar + one-shot Ctrl** (issue #262, `keyboard-accessory.js`): a **shell**-mode session automatically swaps the mobile accessory bar for terminal controls (Ctrl, Esc, Tab, four arrows, paste, dismiss); every other mode keeps the agent bar. `setMode()` now records the user's `extendedKeyboardBar` preference as the **base** layout and `refreshForActiveSession()` (called from `selectSession`) resolves base-vs-shell, so a settings save during a shell session cannot yank the bar away and switching back restores the user's choice. ⚠️ **Ctrl is a ONE-SHOT modifier applied in `terminal.onData`, not in a keydown handler**: a virtual keyboard emits no usable key events, so the character only exists as onData text. The hook sits AFTER `shouldSuppressTerminalQueryResponse` (xterm answers DA/CPR through onData too, and one of those would silently spend the modifier) and BEFORE every send path, so the control byte follows the normal control-char route. ⚠️ **Not every onData chunk is a keystroke**, and the query filter is not enough on its own: xterm ALSO emits mouse and focus reports on its own initiative, so the hook skips them via `isTerminalFocusOrMouseReport()` (they still reach the PTY, they just don't count as the next key). The mouse half is live — a shell session keeps the NARROW strip, so mouse DECSETs reach the browser and one tap while vim/htop runs spent the armed modifier silently (measured). The focus half is defense in depth: `FOCUS_ESCAPE_FILTER` in `session.ts` strips `\x1b[?1004h` from every PTY read, so `sendFocusMode` never turns on today; if it ever did, the bar's own post-key refocus would emit `\x1b[I` and eat the modifier before the user typed. ⚠️ It must disarm on ALL of: use, second tap, any other accessory key, session switch, keyboard dismissal, and a layout swap; a modifier left armed turns the next innocent keystroke into a control byte. ⚠️ **onData is not the only input path** — with `cjkInputEnabled` on, the CJK textarea owns the keyboard (onData returns early for everything it swallows, and the focus router sends `terminal.focus()` there, which is where the bar refocuses after every key), so `_handleCjkInput()` applies the modifier too. It is that module's single choke point to the PTY, so one call covers typed characters, IME flushes, Enter, backspace and arrows. Without it an armed modifier could neither fire NOR be spent, and survived to a later keystroke. Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Space=NUL / Ctrl+?=DEL); characters with no control equivalent pass through unchanged, like a hardware keyboard. ⚠️ The armed style is `.accessory-btn.accessory-btn-ctrl.armed` (0,3,0) in BOTH stylesheets, and it cannot outrank mobile.css's light-skin repaint at **(0,3,1)** (`:is()` inherits its most specific argument, and that list holds `.btn-toolbar.btn-shell`) — so that rule excludes the state by hand as `.accessory-btn:not(.armed)`. Without the exclusion the armed button renders identically to a resting one on all four light skins, which is worse than no armed style at all. diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index fddf5926d..7a563e1bd 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -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`); } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 6257d9979..9f6842e9e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -17817,3 +17817,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)); + } +} diff --git a/test/foldable-layout.test.ts b/test/foldable-layout.test.ts new file mode 100644 index 000000000..46e90de33 --- /dev/null +++ b/test/foldable-layout.test.ts @@ -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; + +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(); + 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'); + }); +}); diff --git a/test/mobile/devices.ts b/test/mobile/devices.ts index f0c16bb71..09be993a7 100644 --- a/test/mobile/devices.ts +++ b/test/mobile/devices.ts @@ -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), ]; // --------------------------------------------------------------------------- diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts index 5d6282b20..8965f0c68 100644 --- a/test/mobile/settings.test.ts +++ b/test/mobile/settings.test.ts @@ -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'); diff --git a/test/viewport-shape-change.test.ts b/test/viewport-shape-change.test.ts new file mode 100644 index 000000000..34b6c5507 --- /dev/null +++ b/test/viewport-shape-change.test.ts @@ -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(); + 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); + }); +});