fix(mobile): preserve settings across foldable postures

This commit is contained in:
codeman-local
2026-07-20 21:22:28 +08:00
parent b10e354936
commit 51cb3a7205
6 changed files with 299 additions and 194 deletions
+3 -1
View File
@@ -228,6 +228,8 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
**Theme skins** (App Settings → Display): the `skin` setting selects a palette via a `data-skin` attribute on `<html>`. Values: `daylight-blue` (default), `daylight-green`, `og` (OG Codeman). CSS lives under `[data-skin="…"]` blocks in `styles.css`. To avoid a flash-of-wrong-theme, an **inline pre-paint script** in `index.html` (`<head>`) reads `localStorage['codeman:skin']` and sets `data-skin` before first paint; `settings-ui.js` `applySkin()` applies it live on save (sets `html[data-skin]` + `window.__codemanSkin`, syncs the standalone `codeman:skin` key with the settings blob, and calls terminal-ui.js `applyTerminalSkin()` to re-theme live terminals). `skin` is a **per-device/client-only** setting — it's destructured OUT of the server payload (settings-ui.js, alongside `localEchoEnabled`/`cjkInputEnabled`/`extendedKeyboardBar`), so it does NOT sync across devices.
**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`.
**Respawn presets**: `solo-work` (3s/60min), `subagent-workflow` (45s/240min), `team-lead` (90s/480min), `ralph-todo` (8s/480min), `overnight-autonomous` (10s/480min).
**Keyboard shortcuts**: Escape (close), Ctrl+? (shortcut overlay), Ctrl/Cmd/Alt+K (session palette), Ctrl+W (kill), Ctrl+Tab (next), Alt+[/] (prev/next tab), Alt+1-9 (switch tab), Ctrl+Shift+{/} (move tab left/right), Shift+Enter or Ctrl+Enter (newline), Ctrl+L (clear), Ctrl+Shift+R (restore size), Ctrl+Shift+V (voice input), Ctrl/Cmd +/- (font), Shift+Wheel (local scrollback when mouse passthrough is active). Rebindable via the registry (see Command palette above).
@@ -296,7 +298,7 @@ Raw `npx vitest` skips `config/vitest.config.ts`; always use `npm test --` or pa
**Ports**: Pick unique ports manually. Search `const PORT =` before adding new tests.
**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/` (135 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/` (136 device profiles). Browser-testing infra and practices: `docs/browser-testing-guide.md`.
## Debugging
+29
View File
@@ -43,6 +43,35 @@ const MobileDetection = {
);
},
/**
* Check whether this browser belongs to a handheld device.
*
* Unlike getDeviceType(), this classification must remain stable when a
* foldable changes posture. An unfolded phone can expose a desktop-width
* viewport, but it still needs the same per-device settings that were saved
* while folded. User-Agent Client Hints are preferred where available; the
* legacy token fallback covers Android WebView and iPhone browsers.
*/
isHandheldDevice() {
if (!this.isTouchDevice()) return false;
const userAgent = navigator.userAgent || '';
// Prefer explicit UA form-factor signals. Besides matching real browsers,
// this avoids Chromium emulation reporting userAgentData.mobile=true for
// an iPad/tablet context created with isMobile=true.
if (/iPad|Tablet|Silk|PlayBook|Kindle|Windows NT|CrOS|Macintosh/i.test(userAgent)) {
return false;
}
if (/Android/i.test(userAgent) && !/Mobile/i.test(userAgent)) return false;
if (/Mobi|iPhone|iPod/i.test(userAgent)) return true;
const uaDataMobile = navigator.userAgentData?.mobile;
if (typeof uaDataMobile === 'boolean') return uaDataMobile;
return false;
},
/** Check if device is iOS (iPhone, iPad, iPod) */
isIOS() {
return (
+10 -6
View File
@@ -1758,17 +1758,21 @@ Object.assign(CodemanApp.prototype, {
return settings.ralphTrackerEnabled ?? false;
},
// Get the settings storage key based on device type (mobile vs desktop)
// Keep the settings namespace stable across foldable posture changes. Layout
// still follows viewport width, but an unfolded phone remains the same
// handheld device and must not silently switch to desktop preferences.
getSettingsStorageKey() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
return isMobile ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
const isHandheld =
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
return isHandheld ? 'codeman-app-settings-mobile' : 'codeman-app-settings';
},
// Get default settings based on device type
// Note: Notification prefs are handled separately by NotificationManager
getDefaultSettings() {
const isMobile = MobileDetection.getDeviceType() === 'mobile';
if (isMobile) {
const isHandheld =
MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile';
if (isHandheld) {
// Mobile defaults: minimal UI for small screens
return {
// Header visibility - hide everything on mobile
@@ -2180,7 +2184,7 @@ Object.assign(CodemanApp.prototype, {
// so mobile defaults to OFF; the desktop blob is untouched and keeps its value.
try {
if (
MobileDetection.getDeviceType() === 'mobile' &&
(MobileDetection.isHandheldDevice?.() ?? MobileDetection.getDeviceType() === 'mobile') &&
!localStorage.getItem('codeman:planUsagePerDeviceMigrated')
) {
const s = this.loadAppSettingsFromStorage();
+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).
**325 tests across 135 devices — all passing.**
**326 tests across 136 devices — all passing.**
## Purpose
Validates Codeman's mobile UI across 135 devices, covering:
Validates Codeman's mobile UI across 136 devices, covering:
- **Keyboard simulation** — 3-layer approach to emulate virtual keyboards in headless browsers
- **Touch/swipe interactions** — CDP trusted events (Chromium) + synthetic fallback (WebKit)
@@ -26,7 +26,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/keyboard.test.t
# Quick mode — 6 representative devices, skip full matrix
CI_QUICK=1 npx vitest run --config test/mobile/vitest.config.ts
# Full device matrix only (135 devices)
# Full device matrix only (136 devices)
npx vitest run --config test/mobile/vitest.config.ts test/mobile/device-matrix.test.ts
# Update visual baselines (delete old baselines, re-run)
@@ -43,7 +43,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
| `subagent-windows.test.ts` | 3202 | Mobile subagent card dimensions, stacking, interactions |
| `settings.test.ts` | 3203 | Settings modal, mobile defaults, persistence |
| `layout.test.ts` | 3204 | General mobile layout, fixed elements, device classes |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (135 devices) |
| `device-matrix.test.ts` | 3205 | Cross-device parametric tests (136 devices) |
| `visual-regression.test.ts` | 3206 | Screenshot comparison at key breakpoints |
| `accessibility.test.ts` | 3207 | WCAG touch targets, zoom, focus, ARIA |
@@ -58,7 +58,7 @@ npx vitest run --config test/mobile/vitest.config.ts test/mobile/visual-regressi
| standard-tablet | 768–834px | ~8 | iPad Mini |
| large-tablet | 835px+ | ~5 | iPad Pro 11" |
135 devices are defined in `devices.ts` — 68 from Playwright's built-in device profiles plus 67 custom entries for newer devices (iPhone 16/17, Pixel 9, Galaxy S25, iPad Air M2, Surface Pro, etc.).
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.).
### How Devices Are Differentiated
@@ -98,7 +98,7 @@ Test File
├─ helpers/touch-sim.ts → CDP trusted touch / synthetic fallback
├─ helpers/assertions.ts → Layout, CSS, accessibility assertions
├─ helpers/visual.ts → pixelmatch screenshot comparison
└─ devices.ts → 135-device registry
└─ devices.ts → 136-device registry
```
### Keyboard Simulation — 3-Layer Approach
+168 -157
View File
@@ -1,6 +1,7 @@
import { devices as playwrightDevices } from 'playwright';
export type DeviceCategory = 'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
export type DeviceCategory =
'small-phone' | 'standard-phone' | 'large-phone' | 'small-tablet' | 'standard-tablet' | 'large-tablet';
export interface DeviceEntry {
name: string;
@@ -55,14 +56,7 @@ function fromPlaywright(name: string): DeviceEntry | null {
}
/** Create a custom DeviceEntry for devices not in Playwright. */
function custom(
name: string,
width: number,
height: number,
dpr: number,
ua: string,
isIOS: boolean,
): DeviceEntry {
function custom(name: string, width: number, height: number, dpr: number, ua: string, isIOS: boolean): DeviceEntry {
return {
name,
category: categoryFor(width),
@@ -83,89 +77,89 @@ function custom(
const PLAYWRIGHT_DEVICE_NAMES = [
// Small phones (<375px)
'iPhone SE', // 320x568
'Galaxy S9+', // 320x658
'Nokia Lumia 520', // 320x533
'Galaxy S III', // 360x640
'Galaxy Note 3', // 360x640
'Galaxy Note II', // 360x640
'Galaxy S5', // 360x640
'Galaxy S8', // 360x740
'Galaxy S24', // 360x780
'BlackBerry Z30', // 360x640
'iPhone SE', // 320x568
'Galaxy S9+', // 320x658
'Nokia Lumia 520', // 320x533
'Galaxy S III', // 360x640
'Galaxy Note 3', // 360x640
'Galaxy Note II', // 360x640
'Galaxy S5', // 360x640
'Galaxy S8', // 360x740
'Galaxy S24', // 360x780
'BlackBerry Z30', // 360x640
'Microsoft Lumia 550', // 360x640
'Microsoft Lumia 950', // 360x640
'Nexus 5', // 360x640
'Moto G4', // 360x640
'Pixel 4', // 353x745
'Nexus 5', // 360x640
'Moto G4', // 360x640
'Pixel 4', // 353x745
// Standard phones (375-429px)
'iPhone 6', // 375x667
'iPhone 7', // 375x667
'iPhone 8', // 375x667
'iPhone 6', // 375x667
'iPhone 7', // 375x667
'iPhone 8', // 375x667
'iPhone SE (3rd gen)', // 375x667
'iPhone X', // 375x812
'iPhone 11 Pro', // 375x635
'iPhone 12 Mini', // 375x629
'iPhone 13 Mini', // 375x629
'LG Optimus L70', // 384x640
'Nexus 4', // 384x640
'iPhone 12', // 390x664
'iPhone 12 Pro', // 390x664
'iPhone 13', // 390x664
'iPhone 13 Pro', // 390x664
'iPhone 14', // 390x664
'iPhone 14 Pro', // 393x660
'iPhone 15', // 393x659
'iPhone 15 Pro', // 393x659
'Pixel 3', // 393x786
'Pixel 5', // 393x727
'Pixel 2', // 411x731
'Pixel 2 XL', // 411x823
'Pixel 7', // 412x839
'Pixel 4a (5G)', // 412x765
'Nexus 5X', // 412x732
'Nexus 6', // 412x732
'Nexus 6P', // 412x732
'iPhone 6 Plus', // 414x736
'iPhone 7 Plus', // 414x736
'iPhone 8 Plus', // 414x736
'iPhone XR', // 414x896
'iPhone 11', // 414x715
'iPhone 11 Pro Max', // 414x715
'iPhone 12 Pro Max', // 428x746
'iPhone 13 Pro Max', // 428x746
'iPhone 14 Plus', // 428x746
'iPhone X', // 375x812
'iPhone 11 Pro', // 375x635
'iPhone 12 Mini', // 375x629
'iPhone 13 Mini', // 375x629
'LG Optimus L70', // 384x640
'Nexus 4', // 384x640
'iPhone 12', // 390x664
'iPhone 12 Pro', // 390x664
'iPhone 13', // 390x664
'iPhone 13 Pro', // 390x664
'iPhone 14', // 390x664
'iPhone 14 Pro', // 393x660
'iPhone 15', // 393x659
'iPhone 15 Pro', // 393x659
'Pixel 3', // 393x786
'Pixel 5', // 393x727
'Pixel 2', // 411x731
'Pixel 2 XL', // 411x823
'Pixel 7', // 412x839
'Pixel 4a (5G)', // 412x765
'Nexus 5X', // 412x732
'Nexus 6', // 412x732
'Nexus 6P', // 412x732
'iPhone 6 Plus', // 414x736
'iPhone 7 Plus', // 414x736
'iPhone 8 Plus', // 414x736
'iPhone XR', // 414x896
'iPhone 11', // 414x715
'iPhone 11 Pro Max', // 414x715
'iPhone 12 Pro Max', // 428x746
'iPhone 13 Pro Max', // 428x746
'iPhone 14 Plus', // 428x746
// Large phones (430-599px)
'iPhone 14 Pro Max', // 430x740
'iPhone 15 Plus', // 430x739
'iPhone 15 Pro Max', // 430x739
'Galaxy A55', // 480x1040
'Nokia N9', // 480x854
'iPhone 14 Pro Max', // 430x740
'iPhone 15 Plus', // 430x739
'iPhone 15 Pro Max', // 430x739
'Galaxy A55', // 480x1040
'Nokia N9', // 480x854
// Small tablets (600-767px)
'Blackberry PlayBook', // 600x1024
'Nexus 7', // 600x960
'Galaxy Tab S9', // 640x1024
'iPad (gen 11)', // 656x944
'Galaxy Tab S4', // 712x1138
'Nexus 7', // 600x960
'Galaxy Tab S9', // 640x1024
'iPad (gen 11)', // 656x944
'Galaxy Tab S4', // 712x1138
// Standard tablets (768-834px)
'iPad (gen 5)', // 768x1024
'iPad (gen 6)', // 768x1024
'iPad Mini', // 768x1024
'Kindle Fire HDX', // 800x1280
'Nexus 10', // 800x1280
'iPad (gen 7)', // 810x1080
'iPad (gen 5)', // 768x1024
'iPad (gen 6)', // 768x1024
'iPad Mini', // 768x1024
'Kindle Fire HDX', // 800x1280
'Nexus 10', // 800x1280
'iPad (gen 7)', // 810x1080
// Large tablets (834px+)
'iPad Pro 11', // 834x1194
'iPad Pro 11', // 834x1194
];
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES
.map(n => fromPlaywright(n))
.filter((d): d is DeviceEntry => d !== null);
const playwrightEntries: DeviceEntry[] = PLAYWRIGHT_DEVICE_NAMES.map((n) => fromPlaywright(n)).filter(
(d): d is DeviceEntry => d !== null
);
// ---------------------------------------------------------------------------
// Custom devices — newer models and those missing from Playwright
@@ -185,84 +179,101 @@ const ANDROID_TABLET_UA = (androidVer: string, model: string) =>
const customEntries: DeviceEntry[] = [
// ── Small phones (<375px) ──────────────────────────────────────────────
custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true),
custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true),
custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true),
custom('iPod Touch (7th gen)',320, 568, 2, IOS_MOBILE_UA('15_8'), true),
custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false),
custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false),
custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false),
custom('iPhone 5', 320, 568, 2, IOS_MOBILE_UA('10_3_4'), true),
custom('iPhone 5s', 320, 568, 2, IOS_MOBILE_UA('12_5_7'), true),
custom('iPhone 5c', 320, 568, 2, IOS_MOBILE_UA('10_3_3'), true),
custom('iPod Touch (7th gen)', 320, 568, 2, IOS_MOBILE_UA('15_8'), true),
custom('Galaxy Y', 240, 320, 1, ANDROID_MOBILE_UA('2.3.6', 'GT-S5360'), false),
custom('Galaxy Ace', 320, 480, 1, ANDROID_MOBILE_UA('2.3.7', 'GT-S5830'), false),
custom('Pixel 4a', 353, 745, 2.75, ANDROID_MOBILE_UA('12', 'Pixel 4a'), false),
// ── Standard phones (375-429px) ────────────────────────────────────────
custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false),
custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false),
custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false),
custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false),
custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false),
custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false),
custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false),
custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false),
custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false),
custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false),
custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false),
custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false),
custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false),
custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false),
custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false),
custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false),
custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false),
custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false),
custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false),
custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false),
custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false),
custom('Xiaomi Redmi Note 13',393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false),
custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false),
custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false),
custom('iPhone 16', 393, 659, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro', 402, 674, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20', 360, 800, 3, ANDROID_MOBILE_UA('12', 'SM-G980F'), false),
custom('Galaxy S20 FE', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G780F'), false),
custom('Galaxy S21', 360, 800, 3, ANDROID_MOBILE_UA('13', 'SM-G991B'), false),
custom('Galaxy S21 FE', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-G990B'), false),
custom('Galaxy S22', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S901B'), false),
custom('Galaxy S23', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S911B'), false),
custom('Galaxy S24 FE', 360, 780, 3, ANDROID_MOBILE_UA('14', 'SM-S721B'), false),
custom('Galaxy A54', 360, 800, 3, ANDROID_MOBILE_UA('14', 'SM-A546B'), false),
custom('Galaxy A34', 360, 800, 2.625, ANDROID_MOBILE_UA('14', 'SM-A346B'), false),
custom('Galaxy A14', 384, 854, 1.5, ANDROID_MOBILE_UA('13', 'SM-A145F'), false),
custom('Galaxy Z Flip 5', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F731B'), false),
custom('Galaxy Z Flip 4', 412, 919, 2.625, ANDROID_MOBILE_UA('14', 'SM-F721B'), false),
custom('Pixel 6', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6'), false),
custom('Pixel 6a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6a'), false),
custom('Pixel 7a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7a'), false),
custom('Pixel 8', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8'), false),
custom('Pixel 8a', 412, 892, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8a'), false),
custom('Pixel 9', 412, 923, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9'), false),
custom('OnePlus 12', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2581'), false),
custom('OnePlus Nord 3', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'CPH2491'), false),
custom('Xiaomi 14', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23127PN0CC'), false),
custom('Xiaomi Redmi Note 13', 393, 873, 2.75, ANDROID_MOBILE_UA('14', '23106RN0DA'), false),
custom('Nothing Phone (2)', 412, 915, 2.625, ANDROID_MOBILE_UA('14', 'A065'), false),
custom('Sony Xperia 1 V', 411, 960, 2.625, ANDROID_MOBILE_UA('14', 'XQ-DQ72'), false),
// ── Large phones (430-599px) ───────────────────────────────────────────
custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false),
custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false),
custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false),
custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false),
custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false),
custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false),
custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false),
custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false),
custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false),
custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false),
custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false),
custom('iPhone 16 Plus', 430, 739, 3, IOS_MOBILE_UA('18_0'), true),
custom('iPhone 16 Pro Max', 440, 756, 3, IOS_MOBILE_UA('18_0'), true),
custom('Galaxy S20 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G988B'), false),
custom('Galaxy S21 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('13', 'SM-G998B'), false),
custom('Galaxy S22 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S908B'), false),
custom('Galaxy S23 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S918B'), false),
custom('Galaxy S24 Ultra', 432, 960, 3, ANDROID_MOBILE_UA('14', 'SM-S928B'), false),
custom('Galaxy Z Fold 5', 460, 1016, 2.5, ANDROID_MOBILE_UA('14', 'SM-F946B'), false),
custom('Pixel 6 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 6 Pro'), false),
custom('Pixel 7 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 7 Pro'), false),
custom('Pixel 8 Pro', 448, 998, 2.625, ANDROID_MOBILE_UA('14', 'Pixel 8 Pro'), false),
custom('Pixel 9 Pro XL', 448, 998, 2.75, ANDROID_MOBILE_UA('15', 'Pixel 9 Pro XL'), false),
custom('OnePlus 12 Pro', 440, 990, 2.625, ANDROID_MOBILE_UA('14', 'CPH2583'), false),
// ── Small tablets (600-767px) ──────────────────────────────────────────
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
custom('Kindle Fire HD 8', 600, 1024, 1.5, 'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36', false),
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
custom('Galaxy Tab A8', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-X200'), false),
custom('Galaxy Tab S6 Lite', 600, 1024, 1.5, ANDROID_TABLET_UA('14', 'SM-P613'), false),
custom('Galaxy Tab A7 Lite', 600, 960, 1.5, ANDROID_TABLET_UA('13', 'SM-T220'), false),
custom(
'Kindle Fire HD 8',
600,
1024,
1.5,
'Mozilla/5.0 (Linux; Android 11; KFRAPWI) AppleWebKit/537.36 (KHTML, like Gecko) Silk/110.1.4 like Chrome/110.0.5481.154 Safari/537.36',
false
),
custom('Lenovo Tab M10', 600, 1024, 1.5, ANDROID_TABLET_UA('12', 'TB-X606F'), false),
custom('Xiaomi Pad 6', 600, 960, 2, ANDROID_TABLET_UA('14', '23043RP34G'), false),
// ── Standard tablets (768-834px) ───────────────────────────────────────
custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true),
custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true),
custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false),
custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false),
custom('iPad Air (5th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad (9th gen)', 810, 1080, 2, IPAD_UA('16_0'), true),
custom('iPad (10th gen)', 820, 1180, 2, IPAD_UA('16_0'), true),
custom('iPad Mini (6th gen)', 768, 1024, 2, IPAD_UA('16_0'), true),
custom('Galaxy Tab S7', 800, 1280, 2, ANDROID_TABLET_UA('13', 'SM-T870'), false),
custom('Galaxy Tab S8', 800, 1280, 2, ANDROID_TABLET_UA('14', 'SM-X700'), false),
// ── Large tablets (834px+) ──────────────────────────────────────────────
custom('iPad Pro 12.9 (6th gen)', 1024, 1366, 2, IPAD_UA('16_0'), true),
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
custom('Surface Pro 7', 912, 1368, 2,
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0', false),
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
custom('iPad Pro 11 (4th gen)', 834, 1194, 2, IPAD_UA('16_0'), true),
custom('iPad Air (M2)', 834, 1194, 2, IPAD_UA('17_0'), true),
custom(
'Surface Pro 7',
912,
1368,
2,
'Mozilla/5.0 (Windows NT 10.0; ARM; Surface Pro 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.7632.6 Safari/537.36 Edg/145.0.0.0',
false
),
custom('Galaxy Tab S8+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X800'), false),
custom('Galaxy Tab S9+', 840, 1344, 2.25, ANDROID_TABLET_UA('14', 'SM-X810'), false),
custom('Galaxy Tab S9 Ultra', 900, 1440, 2.25, ANDROID_TABLET_UA('14', 'SM-X910'), false),
custom('Pixel Tablet', 888, 1280, 2, ANDROID_TABLET_UA('14', 'GPD8'), false),
custom('Lenovo Tab P12 Pro', 900, 1440, 2, ANDROID_TABLET_UA('13', 'TB-Q706F'), false),
// Find N5 inner display is 2248x2480 physical pixels. At DPR 2 its full-
// 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),
];
// ---------------------------------------------------------------------------
@@ -276,29 +287,29 @@ export const DEVICE_REGISTRY: DeviceEntry[] = [...playwrightEntries, ...customEn
// Per-category exports
// ---------------------------------------------------------------------------
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-phone');
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-phone');
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-phone');
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'small-tablet');
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'standard-tablet');
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.category === 'large-tablet');
export const SMALL_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-phone');
export const STANDARD_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-phone');
export const LARGE_PHONES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-phone');
export const SMALL_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'small-tablet');
export const STANDARD_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'standard-tablet');
export const LARGE_TABLETS: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.category === 'large-tablet');
// ---------------------------------------------------------------------------
// Platform exports
// ---------------------------------------------------------------------------
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => d.isIOS);
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter(d => !d.isIOS);
export const IOS_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => d.isIOS);
export const ANDROID_DEVICES: DeviceEntry[] = DEVICE_REGISTRY.filter((d) => !d.isIOS);
// ---------------------------------------------------------------------------
// Representative devices — one per category for quick smoke tests
// ---------------------------------------------------------------------------
export const REPRESENTATIVE_DEVICES: Record<DeviceCategory, DeviceEntry> = {
'small-phone': SMALL_PHONES.find(d => d.name === 'iPhone SE')!,
'standard-phone': STANDARD_PHONES.find(d => d.name === 'iPhone 14 Pro')!,
'large-phone': LARGE_PHONES.find(d => d.name === 'iPhone 15 Pro Max')!,
'small-tablet': SMALL_TABLETS.find(d => d.name === 'Nexus 7')!,
'standard-tablet': STANDARD_TABLETS.find(d => d.name === 'iPad Mini')!,
'large-tablet': LARGE_TABLETS.find(d => d.name === 'iPad Pro 11')!,
'small-phone': SMALL_PHONES.find((d) => d.name === 'iPhone SE')!,
'standard-phone': STANDARD_PHONES.find((d) => d.name === 'iPhone 14 Pro')!,
'large-phone': LARGE_PHONES.find((d) => d.name === 'iPhone 15 Pro Max')!,
'small-tablet': SMALL_TABLETS.find((d) => d.name === 'Nexus 7')!,
'standard-tablet': STANDARD_TABLETS.find((d) => d.name === 'iPad Mini')!,
'large-tablet': LARGE_TABLETS.find((d) => d.name === 'iPad Pro 11')!,
};
+83 -24
View File
@@ -5,10 +5,8 @@ import { PORTS, SELECTORS, KEYBOARD, STORAGE_KEYS, BODY_CLASSES, WAIT } from './
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { showKeyboard, hideKeyboard } from './helpers/keyboard-sim.js';
import {
assertVisible, assertHidden, getCSSProperty, getCSSNumericValue,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES } from './devices.js';
import { assertVisible, assertHidden, getCSSProperty, getCSSNumericValue } from './helpers/assertions.js';
import { DEVICE_REGISTRY, REPRESENTATIVE_DEVICES } from './devices.js';
import type { WebServer } from '../src/web/server.js';
const PORT = PORTS.SETTINGS;
@@ -94,9 +92,7 @@ describe('Settings Modal', () => {
if (gearBox && toolbarBox) {
// Gear button should be within toolbar's vertical range
expect(gearBox.y).toBeGreaterThanOrEqual(toolbarBox.y - 5);
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(
toolbarBox.y + toolbarBox.height + 5,
);
expect(gearBox.y + gearBox.height).toBeLessThanOrEqual(toolbarBox.y + toolbarBox.height + 5);
}
});
});
@@ -304,11 +300,14 @@ describe('Settings Modal', () => {
try {
// Store a test setting
await page.evaluate((key) => {
localStorage.setItem(key, JSON.stringify({
showFontControls: true,
showMonitor: true,
subagentTrackingEnabled: true,
}));
localStorage.setItem(
key,
JSON.stringify({
showFontControls: true,
showMonitor: true,
subagentTrackingEnabled: true,
})
);
}, STORAGE_KEYS.SETTINGS_MOBILE);
// Reload page
@@ -335,20 +334,32 @@ describe('Settings Modal', () => {
try {
// Store both mobile and desktop settings
await page.evaluate(({ mobileKey, desktopKey, notifKey }) => {
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
}, {
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
});
await page.evaluate(
({ mobileKey, desktopKey, notifKey }) => {
localStorage.setItem(mobileKey, JSON.stringify({ showFontControls: false }));
localStorage.setItem(desktopKey, JSON.stringify({ showFontControls: true }));
localStorage.setItem(notifKey, JSON.stringify({ mobileNotif: true }));
},
{
mobileKey: STORAGE_KEYS.SETTINGS_MOBILE,
desktopKey: STORAGE_KEYS.SETTINGS_DESKTOP,
notifKey: STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE,
}
);
// Verify they are independent
const mobile = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_MOBILE);
const desktop = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.SETTINGS_DESKTOP);
const notif = await page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE);
const mobile = await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
STORAGE_KEYS.SETTINGS_MOBILE
);
const desktop = await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
STORAGE_KEYS.SETTINGS_DESKTOP
);
const notif = await page.evaluate(
(key) => JSON.parse(localStorage.getItem(key) || '{}'),
STORAGE_KEYS.NOTIFICATION_PREFS_MOBILE
);
expect(mobile.showFontControls).toBe(false);
expect(desktop.showFontControls).toBe(true);
@@ -390,5 +401,53 @@ describe('Settings Modal', () => {
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');
try {
// Seed the preferences while folded, exactly as a phone user does.
await page.setViewportSize({ width: 412, height: 915 });
await page.evaluate((key) => {
localStorage.setItem(
key,
JSON.stringify({
showResponseViewer: true,
extendedKeyboardBar: true,
})
);
}, STORAGE_KEYS.SETTINGS_MOBILE);
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(WAIT.SSE_CONNECT);
// Unfolding can reload Android WebView. The viewport now uses desktop
// layout, but the physical device and its preferences have not changed.
await page.setViewportSize(device.viewport);
await page.reload({ waitUntil: WAIT.DOM_CONTENT_LOADED });
await page.waitForTimeout(WAIT.SSE_CONNECT);
const state = await page.evaluate(() => ({
deviceType: (window as any).MobileDetection.getDeviceType(),
handheld: (window as any).MobileDetection.isHandheldDevice(),
storageKey: (window as any).app.getSettingsStorageKey(),
responseViewerVisible: !document
.querySelector('.btn-response-viewer-header')
?.classList.contains('btn-response-viewer-header--hidden'),
keyboardExtended: Boolean(document.querySelector('.keyboard-accessory-bar [data-action="arrow-left"]')),
}));
expect(state.deviceType).toBe('desktop');
expect(state.handheld).toBe(true);
expect(state.storageKey).toBe(STORAGE_KEYS.SETTINGS_MOBILE);
expect(state.responseViewerVisible).toBe(true);
expect(state.keyboardExtended).toBe(true);
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await assertVisible(page, '.keyboard-accessory-bar');
} finally {
await context.close();
}
});
});
});