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
+3 -1
View File
@@ -326,6 +326,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) **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. ⚠️ **With the keyboard up, a shape change baselines to `window.innerHeight`** (the layout viewport, keyboard-free on both engines), never the shrunk visual height: the shrunk baseline made the settle event that follows every rotation or fold read as the keyboard closing, and the layout could not recover, since no further drop could re-arm the show branch. ⚠️ **A base gutter that a LATER `@media` block overrides needs its own fold restatement in that block, on a ZERO base**: the phone-width path picker and path preview drop to `padding: 0` under 600px, and the unconditional fold rules at the end of the file put 16px and 18px back on every phone (measured at 393 and 500). The palette's compound rule lives INSIDE the 600-768px band whose mobile.css shorthand it composes with, because outside it there is no side gutter and the addition pushed the shell 6px off centre; the response viewer's tabletop cap has a twin at the end of mobile.css, whose phone block (under 600px) otherwise outranks it. `test/foldable-layout.test.ts` simulates the cascade across BOTH stylesheets at every breakpoint, with and without the fold rules, so a moved gutter or an unscoped composition fails there rather than on hardware nobody has. Profiles: `iPhone Duo (outer)` / `iPhone Duo (inner)` in `test/mobile/devices.ts`; unit coverage in `test/viewport-shape-change.test.ts`. → [architecture-invariants#folding-devices](docs/architecture-invariants.md#folding-devices)
**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) **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. **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.
@@ -431,7 +433,7 @@ Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test
**Testing against the live instance**: prod is HTTPS-only on :3000 (`curl -sk https://localhost:3000/...`). ⚠️ `w1`/`w2`/`w3` are the user's REAL sessions — never send input to them. Create your own throwaway session (`POST /api/sessions` then `POST /api/sessions/:id/shell`; creation alone leaves `pid: null` and no pane), test against that, and `DELETE` it by exact id when done. **Testing against the live instance**: prod is HTTPS-only on :3000 (`curl -sk https://localhost:3000/...`). ⚠️ `w1`/`w2`/`w3` are the user's REAL sessions — never send input to them. Create your own throwaway session (`POST /api/sessions` then `POST /api/sessions/:id/shell`; creation alone leaves `pid: null` and no pane), test against that, and `DELETE` it by exact id when done.
**Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (136 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`. **Respawn tests**: Use `MockSession` from `test/mocks/index.ts` (defined in `test/mocks/mock-session.ts`). **Route tests**: `app.inject({ method, url, payload })` in `test/routes/` — no live port needed. **Mobile tests**: Playwright suite in `test/mobile/` (138 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
## Debugging ## Debugging
+8
View File
@@ -398,6 +398,14 @@ Anatomy: `.set-shell` → `.set-shell-head` (title + `.set-head-actions`) + `.se
**Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`. **Foldable settings identity**: responsive layout remains width-driven through `MobileDetection.getDeviceType()`, but the localStorage namespace/defaults use `MobileDetection.isHandheldDevice()` so an Android foldable keeps `codeman-app-settings-mobile` after unfolding past the desktop breakpoint. The stable handheld check prefers explicit phone/tablet/desktop UA tokens, then `navigator.userAgentData.mobile`; Android WebView is covered by the `Mobile` UA fallback. Do not switch per-device settings namespaces from instantaneous viewport width — a posture-triggered WebView reload would lose opt-in UI such as `showResponseViewer` and `extendedKeyboardBar`. Regression profile: `OPPO Find N5 (unfolded)` in `test/mobile/devices.ts`.
### Folding devices
**A fold is not a keyboard.** `KeyboardHandler.handleViewportResize()` (mobile-handlers.js) used to read any visual-viewport height drop over 150px as the keyboard appearing. A virtual keyboard only ever takes HEIGHT, so a resize that changes the WIDTH is the device changing shape (a rotation, or a foldable opening or closing) and is skipped by both detection branches. Closing an iPhone Duo (626→466pt wide, 890→678pt tall) otherwise 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 was sticky because clearing it needed the height back within 100px of a baseline belonging to a display the user was no longer looking at. The shape branch re-baselines instead, which is also what lets a keyboard opened after the fold be detected, and `init()` seeds `lastViewportWidth` so the first resize of the page is not itself read as a shape change.
**With the keyboard up, the new baseline is `window.innerHeight`, never the shrunk visual height.** The page sets no `interactive-widget`, so the default resizes-visual mode holds on both engines: the keyboard shrinks only the visual viewport and the layout viewport stays the display's full height (`updateLayoutForKeyboard()` relies on the same fact). The first version baselined to the shrunk height, which made `heightDiff` 0, so the settle event the OS animation fires at the new width (or any later address-bar drift) satisfied the hide branch and ran `onKeyboardHide()` with the keyboard still on screen: accessory bar hidden, the toolbar lift dropped, `main`'s padding cleared. It could not recover, because no further 150px drop can re-arm the show branch against a baseline already sitting at the shrunk height. Reproduced against the real handler in the vm harness with both the fold flavour (626x590 → 466x378 → 466x378) and the rotation flavour (393x359 → 852x150 → 852x160); `test/viewport-shape-change.test.ts` models the two heights separately and pins both.
**The hinge is a reserved region, and the CSS is inert by construction.** The CSS Viewport Segments media features report two segments only while a foldable is actually bent; `--fold-inline-end` / `--fold-block-end` (end of styles.css) measure the strip to keep clear from the LEADING segment (`env(viewport-segment-right 0 0)` and `env(viewport-segment-bottom 0 0)`, physical sides in every text direction) and are `0px` on everything else. The seven centred overlays are all `position: fixed; inset: 0` flex boxes; 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. The cascade traps, each measured in headless Chromium against styles.css + mobile.css in index.html link order: (1) a later `padding-right` longhand beats the earlier `padding` shorthand it composes with, so each fold rule re-states the overlay's own gutter; (2) a base gutter that a LATER `@media` block overrides needs its own fold restatement in that block on a ZERO base, since the unconditional rules at the end of the file otherwise put the gutter back (the phone path picker and path preview drop to `padding: 0` under 600px and came back at 16px and 18px on every phone); (3) a compound rule written to outrank a mobile.css shorthand must be scoped to the band where that shorthand applies, because outside it there is no gutter to compose with (the palette's `.modal.command-palette-modal` added 0.75rem at 393, 900 and 1400px and pushed the shell 6px off centre, and inside the band it lost its bottom gutter to the shorthand until it restated that side too); (4) mobile.css loads AFTER styles.css, so a same-specificity rule there wins under its own breakpoint (the response viewer's `max-height: 92dvh` under 600px beat the tabletop cap, which now has a twin at the end of mobile.css). `test/foldable-layout.test.ts` DERIVES the overlay list from the stylesheet, simulates the cascade across both files at every breakpoint with and without the fold rules, and requires the two results to differ by exactly the fold strip, so each of the four fails there instead of on hardware nobody has.
## Security layers ## Security layers
### Layer-by-layer detail ### Layer-by-layer detail
+56 -8
View File
@@ -223,6 +223,10 @@ const MobileDetection = {
const KeyboardHandler = { const KeyboardHandler = {
VIEWPORT_SETTLE_MS: 80, VIEWPORT_SETTLE_MS: 80,
lastViewportHeight: 0, 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, keyboardVisible: false,
initialViewportHeight: 0, initialViewportHeight: 0,
_viewportSettleTimer: null, _viewportSettleTimer: null,
@@ -241,6 +245,9 @@ const KeyboardHandler = {
this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; this.initialViewportHeight = window.visualViewport?.height || window.innerHeight;
this.lastViewportHeight = this.initialViewportHeight; 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 // Simple focus handler - scroll input into view after keyboard appears
this._focusinHandler = (e) => { this._focusinHandler = (e) => {
@@ -307,13 +314,35 @@ const KeyboardHandler = {
this._settleAnchorY = null; 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() { handleViewportResize() {
const currentHeight = window.visualViewport?.height || window.innerHeight; 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; const heightDiff = this.initialViewportHeight - currentHeight;
// Keyboard appeared (viewport shrunk by more than 150px) // Keyboard appeared (viewport shrunk by more than 150px). Both detection
if (heightDiff > 150 && !this.keyboardVisible) { // branches are skipped on a shape change, whichever way the height moved.
if (!shapeChanged && heightDiff > 150 && !this.keyboardVisible) {
this.keyboardVisible = true; this.keyboardVisible = true;
document.body.classList.add('keyboard-visible'); document.body.classList.add('keyboard-visible');
// While the keyboard is open, size the app to the visual viewport so // 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) // Keyboard hidden (viewport grew back close to initial)
// Use 100px threshold (not 50) to handle iOS address bar drift, // Use 100px threshold (not 50) to handle iOS address bar drift,
// iOS 26's persistent 24px discrepancy, and Safari bottom bar changes // 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; this.keyboardVisible = false;
document.body.classList.remove('keyboard-visible'); document.body.classList.remove('keyboard-visible');
this.onKeyboardHide(); this.onKeyboardHide();
@@ -333,11 +362,30 @@ const KeyboardHandler = {
MobileDetection.updateAppHeight(); MobileDetection.updateAppHeight();
} }
// Update baseline when keyboard is not visible — adapts to address bar // Update baseline when keyboard is not visible: adapts to address bar
// state changes, orientation changes, and other viewport shifts // state changes, orientation changes, and other viewport shifts. A shape
if (!this.keyboardVisible) { // change re-baselines even with the keyboard up (it may genuinely still be
// open, but its old baseline belongs to a display that is gone), and still
// writes --app-height below so the keyboard-open sizing follows the new
// display.
//
// ⚠️ With the keyboard up, the new baseline must be the KEYBOARD-FREE
// height of the display the device moved to, which is window.innerHeight
// (the layout viewport; the page sets no interactive-widget, so the
// keyboard shrinks only the visual viewport on both engines, the same
// fact updateLayoutForKeyboard() relies on). Baselining to the SHRUNK
// visual height made heightDiff 0, so the very next same-width resize
// (the settle event the OS animation produces, or any address-bar drift)
// satisfied the hide branch and tore the keyboard layout down with the
// keyboard still on screen, and it could not recover: no further 150px
// drop can re-arm the show branch against a baseline that already sits
// at the shrunk height.
if (shapeChanged) {
this.initialViewportHeight = this.keyboardVisible ? window.innerHeight : currentHeight;
} else if (!this.keyboardVisible) {
this.initialViewportHeight = currentHeight; this.initialViewportHeight = currentHeight;
} else { }
if (this.keyboardVisible) {
document.documentElement.style.setProperty('--app-height', `${currentHeight}px`); document.documentElement.style.setProperty('--app-height', `${currentHeight}px`);
} }
+12
View File
@@ -3850,3 +3850,15 @@ html[data-session-list="sidebar"] .session-sidebar .session-tab .tab-close {
transition: none; transition: none;
} }
} }
/* Folding devices, tabletop pose: cap the response viewer to the bottom
segment. Twin of the rule at the end of styles.css, needed here because the
phone block (under 600px) above sets `max-height: 92dvh` on the same selector at the same
specificity, and this file loads later, so the styles.css copy loses on a
phone-width foldable. Keep the value identical; test/foldable-layout.test.ts
compares them. */
@media (vertical-viewport-segments: 2) {
.response-viewer {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
+133
View File
@@ -18066,3 +18066,136 @@ html[data-session-list="sidebar"][data-sidebar="collapsed"] .btn-sidebar-toggle
transition: none; transition: none;
} }
} }
/* ============================================================
=== Folding devices: keep dialogs off the hinge ===
Apple's "Designing for iPhone Duo" calls the band a partly-open
display folds through a RESERVED REGION: content avoids covering
it, and system components (alerts, sheets, context menus) move
aside for it. On the web that region is described by the CSS
Viewport Segments media features and env() variables, which report
two segments only while a foldable is actually bent. Flat, open
or closed, it is one segment and everything below is inert.
Codeman's centred overlays are all `position: fixed; inset: 0`
flex-centring boxes, so their dialog lands dead on the hinge in
book pose (a vertical fold) or tabletop pose (a horizontal one).
The fix shrinks the CONTENT box with padding rather than the box
itself, so each overlay's backdrop still covers the whole viewport
and still swallows taps on the far side of the fold. Shrinking
the box would leave the trailing segment unshaded and live.
⚠️ Each rule re-states the overlay's OWN gutter, because a
later `padding-right` longhand beats the earlier `padding`
shorthand it is composing with and would otherwise erase it.
test/foldable-layout.test.ts reads both numbers out of this file
and fails if they drift apart, and simulates the cascade at every
breakpoint so a base gutter overridden by a later @media block (the
phone path picker below) needs its own zero-base restatement.
⚠️ Physical sides, not logical ones: dialogs go in the LEFT
segment (and the TOP one in tabletop pose) in every language. The
HIG keeps Duo's side controls on the same physical edge in RTL
because they are aligned with the hardware, and a dialog that
changed sides with the text direction would fight that.
============================================================ */
:root {
/* Width of the trailing strip to leave clear so a centred dialog cannot sit
under a vertical hinge, and the matching bottom strip for a horizontal one.
0px on every non-folding device, and on a foldable held flat. */
--fold-inline-end: 0px;
--fold-block-end: 0px;
}
@media (horizontal-viewport-segments: 2) {
:root {
--fold-inline-end: calc(100vw - env(viewport-segment-right 0 0, 100vw));
}
}
@media (vertical-viewport-segments: 2) {
:root {
--fold-block-end: calc(100vh - env(viewport-segment-bottom 0 0, 100vh));
}
}
/* No gutter of their own. */
.modal,
.file-preview-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
/* The palette is a .modal, so the generic rule above covers it everywhere
EXCEPT the 600-768px band, where mobile.css (loaded after this file) pads
it with the SHORTHAND `10vh 0.75rem 0`: that shorthand beats the generic
rule on both sides, so this compound rule (0,2,0) restates that band's own
gutters, 0.75rem at the side and none at the bottom, plus the fold strips.
Scoped to the same band on purpose: unscoped, it ADDED 0.75rem on every
other width, where the palette has no side gutter to compose with, and
pushed the shell 6px off centre (measured at 393, 900 and 1400). */
@media (max-width: 768px) and (min-width: 600px) {
.modal.command-palette-modal {
padding-right: calc(0.75rem + var(--fold-inline-end));
padding-bottom: var(--fold-block-end);
}
}
.path-picker-overlay {
padding-right: calc(16px + var(--fold-inline-end));
padding-bottom: calc(16px + var(--fold-block-end));
}
.path-preview-overlay {
padding-right: calc(18px + var(--fold-inline-end));
padding-bottom: calc(18px + var(--fold-block-end));
}
/* Under 600px both dialogs are flush sheets: their `@media (max-width: 600px)`
rules drop the gutter to 0 (borderless right and bottom edges, the picker
docked to the bottom). The two rules above sit later in the file at the same
specificity, so on their own they put a 16px and 18px gutter back on every
phone, fold or no fold (measured at 393 and 500: dialog edges floating 16px
off the screen edge). Restate the fold strip on a ZERO base here. */
@media (max-width: 600px) {
.path-picker-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
.path-preview-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
}
.offline-overlay {
padding-right: calc(20px + var(--fold-inline-end));
padding-bottom: calc(20px + var(--safe-area-bottom) + var(--fold-block-end));
}
.solo-gone-overlay {
padding-right: calc(24px + var(--fold-inline-end));
padding-bottom: calc(24px + var(--fold-block-end));
}
/* Top-anchored, so only the trailing side and the bottom stop matter. */
.paste-overlay {
padding-right: var(--fold-inline-end);
padding-bottom: var(--fold-block-end);
}
/* The response viewer is a bottom sheet, so a vertical hinge running through it
is fine, since it is a wide surface like the terminal and inset dialogs are what
the fold guidance is about. A horizontal hinge is not: in tabletop pose the
sheet would climb out of the bottom segment and fold away mid-transcript.
⚠️ mobile.css carries an identical twin at its end: its 430px block sets
`max-height: 92dvh` at the same specificity and loads later, so this copy
alone loses on a phone-width foldable. test/foldable-layout.test.ts pins
the two values equal. */
@media (vertical-viewport-segments: 2) {
.response-viewer {
max-height: min(88vh, env(viewport-segment-height 0 1, 88vh));
}
}
+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). 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 ## 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 - **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit) - **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 # Quick mode: 6 representative devices, skip full matrix
CI_QUICK=1 npm run test:mobile 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 npm run test:mobile -- test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run) # 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 | | `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence | | `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes | | `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 | | `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA | | `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 | | standard-tablet | 768–834px | ~8 | iPad Mini |
| large-tablet | 835px+ | ~5 | iPad Pro 11" | | 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 ### How Devices Are Differentiated
@@ -106,7 +106,7 @@ Test File
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback ├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions ├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
├─ helpers/visual.ts → pixelmatch screenshot comparison ├─ helpers/visual.ts → pixelmatch screenshot comparison
└─ devices.ts → 136-device registry └─ devices.ts → 138-device registry
``` ```
### Keyboard Simulation — 3-Layer Approach ### 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 // resolution CSS viewport crosses Codeman's desktop breakpoint while the
// browser remains a mobile/touch device. // browser remains a mobile/touch device.
custom('OPPO Find N5 (unfolded)', 1124, 1240, 2, ANDROID_MOBILE_UA('15', 'CPH2671'), false), 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 () => { 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 device = DEVICE_REGISTRY.find((entry) => entry.name === 'OPPO Find N5 (unfolded)')!;
const { page, context } = await createDevicePage(device, BASE_URL, 'chromium'); 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);
});
});