diff --git a/CLAUDE.md b/CLAUDE.md index 35684118..73686fcd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``. 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` (``) 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 diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index e76e5017..5b8b99a5 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -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 ( diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 1d16cf86..fa19970d 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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(); diff --git a/test/mobile/README.md b/test/mobile/README.md index 36044a15..65d89ea0 100644 --- a/test/mobile/README.md +++ b/test/mobile/README.md @@ -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 diff --git a/test/mobile/devices.ts b/test/mobile/devices.ts index 9202dcc5..c30b7191 100644 --- a/test/mobile/devices.ts +++ b/test/mobile/devices.ts @@ -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 = { - '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')!, }; diff --git a/test/mobile/settings.test.ts b/test/mobile/settings.test.ts index c18ecf87..5d6282b2 100644 --- a/test/mobile/settings.test.ts +++ b/test/mobile/settings.test.ts @@ -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(); + } + }); }); });