Files
Codeman/test/mobile/layout.test.ts
T
JD c087d0ae4d fix(mobile): raise the phone breakpoint from 430px to 600px
The phone tier stopped at innerWidth < 430 and @media (max-width: 430px), so every current large phone landed in the tablet layout: the 430pt iPhone 14 Pro Max, 15 Plus, 15 Pro Max and 16 Plus, the 440pt iPhone 16 Pro Max and 17 Pro Max, Pixel 6 Pro, 7 Pro and OnePlus 12 Pro, the 448pt Pixel 8 Pro and 9 Pro XL, and the Galaxy Z Fold 5 cover screen at 460. On those devices the header icon row replaced the session pill, the toolbar kept the desktop Run Shell button instead of Enter and the mic, the keyboard accessory bar could never become visible because its .visible rule lives inside the phone block, and the toolbar jumped to the top of the page when the keyboard opened.

The new cutoff is 600, the line test/mobile/devices.ts already draws between large phones (430-599) and small tablets (600-767). No physical device sits between 480 and 600, but a phone zoomed out one or two steps in Safari does: a 440pt iPhone at 85% or 75% page zoom reports 518px or 587px and still needs the phone controls, which a 480 cutoff would have taken away. The phone block is max-width: 599px and the tablet block starts at min-width: 600px, so a 600px device is a tablet in CSS and in getDeviceType() alike instead of straddling the boundary the way 430pt phones did.

The number changes everywhere it is encoded: JS, CSS, comments, CLAUDE.md, the CI tests that pin the phone block, and the test:mobile helpers. Measurement history that names 430px stays as written.
2026-09-08 00:56:39 -04:00

514 lines
20 KiB
TypeScript

// Port 3204 - General mobile layout tests
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import type { BrowserContext, Page } from 'playwright';
import type { WebServer } from '../src/web/server.js';
import { createTestServer, stopTestServer } from './helpers/server.js';
import { createDevicePage, closeAllBrowsers } from './helpers/browser.js';
import { PORTS, SELECTORS, BREAKPOINTS, BODY_CLASSES, WAIT, MIN_TOUCH_TARGET } from './helpers/constants.js';
import {
assertFixedPosition,
assertHidden,
assertVisible,
assertNoHorizontalOverflow,
assertDeviceClasses,
assertAccessibleTouchTargets,
assertFontSizeNoZoom,
assertZoomNotDisabled,
getCSSProperty,
getCSSNumericValue,
assertHasClass,
assertNotHasClass,
} from './helpers/assertions.js';
import { REPRESENTATIVE_DEVICES, DEVICE_REGISTRY, type DeviceEntry } from './devices.js';
const PORT = PORTS.LAYOUT;
const BASE_URL = `http://localhost:${PORT}`;
let server: WebServer;
// Default test device: iPhone 14 Pro (standard phone)
const iPhone14Pro = REPRESENTATIVE_DEVICES['standard-phone'];
// Desktop-class device for comparison
const iPadPro = REPRESENTATIVE_DEVICES['large-tablet'];
describe('Mobile Layout', () => {
beforeAll(async () => {
server = await createTestServer(PORT);
});
afterAll(async () => {
await closeAllBrowsers();
await stopTestServer(server);
});
// ─── Fixed Elements ──────────────────────────────────────────────────────
describe('Fixed Elements', () => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
});
afterAll(async () => {
await context.close();
});
it('header has position: fixed', async () => {
await assertFixedPosition(page, SELECTORS.HEADER);
});
it('header is at top: 0', async () => {
const top = await getCSSProperty(page, SELECTORS.HEADER, 'top');
expect(parseFloat(top)).toBe(0);
});
it('header has z-index >= 200', async () => {
const zIndex = await getCSSNumericValue(page, SELECTORS.HEADER, 'z-index');
expect(zIndex).toBeGreaterThanOrEqual(200);
});
it('header has min-height: 36px', async () => {
const minHeight = await getCSSNumericValue(page, SELECTORS.HEADER, 'min-height');
expect(minHeight).toBeGreaterThanOrEqual(36);
});
it('toolbar has position: fixed', async () => {
await assertFixedPosition(page, SELECTORS.TOOLBAR);
});
it('toolbar is at bottom', async () => {
const bottom = await getCSSProperty(page, SELECTORS.TOOLBAR, 'bottom');
// bottom should be 0px or a safe-area value (small positive)
expect(parseFloat(bottom)).toBeLessThanOrEqual(50);
});
it('toolbar height is ~40px', async () => {
const height = await getCSSNumericValue(page, SELECTORS.TOOLBAR, 'height');
expect(height).toBeGreaterThanOrEqual(36);
expect(height).toBeLessThanOrEqual(48);
});
});
// ─── Hidden Elements on Phones ────────────────────────────────────────────
describe('Hidden Elements on Phones', () => {
let context: BrowserContext;
let page: Page;
beforeAll(async () => {
({ context, page } = await createDevicePage(iPhone14Pro, BASE_URL));
});
afterAll(async () => {
await context.close();
});
const hiddenOnPhones = [
SELECTORS.HEADER_BRAND,
SELECTORS.CASE_SELECT_GROUP,
SELECTORS.VERSION_DISPLAY,
SELECTORS.TOOLBAR_RIGHT,
SELECTORS.TAB_COUNT_GROUP,
];
for (const selector of hiddenOnPhones) {
it(`${selector} is hidden on phones`, async () => {
await assertHidden(page, selector);
});
}
});
// ─── Mobile-Only Visible Elements ─────────────────────────────────────────
describe('Mobile-Only Visible Elements', () => {
it('.btn-settings-mobile visible on phones', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertVisible(page, SELECTORS.SETTINGS_MOBILE);
} finally {
await context.close();
}
});
it('.btn-case-mobile visible on phones', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertVisible(page, SELECTORS.CASE_MOBILE);
} finally {
await context.close();
}
});
it('.btn-settings-mobile hidden on desktop', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
try {
await assertHidden(page, SELECTORS.SETTINGS_MOBILE);
} finally {
await context.close();
}
});
});
// ─── Toolbar Collision Regression ───────────────────────────────────────
describe('Toolbar Collision Regression', () => {
it('keeps phone toolbar controls inside the viewport', async () => {
const device = DEVICE_REGISTRY.find((d) => d.name === 'iPhone 8')!;
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
const layout = await page.evaluate(() => {
const buttons = [...document.querySelectorAll('.toolbar button')]
.filter((el): el is HTMLButtonElement => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
})
.map((el) => {
const rect = el.getBoundingClientRect();
return {
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
left: rect.left,
right: rect.right,
};
});
return {
overflow: buttons.filter(({ left, right }) => left < 0 || right > window.innerWidth),
caseWidth: document.querySelector('.btn-case-mobile')?.getBoundingClientRect().width ?? 0,
};
});
expect(layout.overflow).toEqual([]);
expect(layout.caseWidth).toBeGreaterThanOrEqual(36);
} finally {
await context.close();
}
});
it('does not render the desktop voice button on a 430px large phone', async () => {
const device = REPRESENTATIVE_DEVICES['large-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertHidden(page, '#voiceInputBtn');
await assertVisible(page, '#voiceInputBtnMobile');
} finally {
await context.close();
}
});
it('uses phone upload and voice controls without toolbar overlap', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
expect(await page.locator('.toolbar-center .btn-upload').isVisible()).toBe(false);
await assertVisible(page, '.btn-upload-mobile');
await assertVisible(page, '#voiceInputBtnMobile');
const violations = await page.evaluate(() => {
const visibleToolbarButtons = [...document.querySelectorAll('.toolbar button')]
.filter((el): el is HTMLButtonElement => {
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return rect.width > 0 && rect.height > 0 && style.display !== 'none' && style.visibility !== 'hidden';
})
.map((el) => ({
selector: el.id ? `#${el.id}` : `.${[...el.classList].join('.')}`,
rect: el.getBoundingClientRect(),
}));
const overlaps: string[] = [];
for (let i = 0; i < visibleToolbarButtons.length; i += 1) {
for (let j = i + 1; j < visibleToolbarButtons.length; j += 1) {
const a = visibleToolbarButtons[i];
const b = visibleToolbarButtons[j];
const intersects =
a.rect.left < b.rect.right &&
a.rect.right > b.rect.left &&
a.rect.top < b.rect.bottom &&
a.rect.bottom > b.rect.top;
if (intersects) {
overlaps.push(`${a.selector} overlaps ${b.selector}`);
}
}
}
return overlaps;
});
expect(violations).toEqual([]);
} finally {
await context.close();
}
});
it('keeps the mobile recording mic effect inside the button bounds', async () => {
const device = REPRESENTATIVE_DEVICES['small-phone'];
const { context, page } = await createDevicePage(device, BASE_URL, 'chromium');
try {
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await page.evaluate(() => {
document.getElementById('voiceInputBtnMobile')?.classList.add('recording');
});
const shadow = await page.evaluate(() => {
const button = document.getElementById('voiceInputBtnMobile');
return button ? getComputedStyle(button).boxShadow : '';
});
expect(shadow).toContain('inset');
expect(shadow).not.toContain(' 6px ');
} finally {
await context.close();
}
});
it('keeps desktop upload and voice controls in one centered row', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL, 'chromium');
try {
await page.setViewportSize({ width: 1280, height: 800 });
await page.waitForTimeout(WAIT.PAGE_SETTLE);
await assertVisible(page, '.toolbar-center .btn-upload');
await assertVisible(page, '#voiceInputBtn');
const layout = await page.evaluate(() => {
const upload = document.querySelector('.toolbar-center .btn-upload')!.getBoundingClientRect();
const voice = document.querySelector('#voiceInputBtn')!.getBoundingClientRect();
return {
centerYDifference: Math.abs(upload.top + upload.height / 2 - (voice.top + voice.height / 2)),
centerXDifference: Math.abs(upload.left + upload.width / 2 - (voice.left + voice.width / 2)),
};
});
expect(layout.centerYDifference).toBeLessThanOrEqual(2);
expect(layout.centerXDifference).toBeGreaterThan(20);
} finally {
await context.close();
}
});
});
// ─── Device Classes ───────────────────────────────────────────────────────
describe('Device Classes', () => {
it('iOS user agent adds ios-device class', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
await context.close();
}
});
it('Android user agent does NOT add ios-device', async () => {
const pixel = DEVICE_REGISTRY.find((d) => d.name === 'Pixel 7')!;
const { context, page } = await createDevicePage(pixel, BASE_URL);
try {
await assertNotHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
await context.close();
}
});
it('Safari user agent adds safari-browser class', async () => {
// iPhone 14 Pro uses Safari UA
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertHasClass(page, 'body', BODY_CLASSES.SAFARI);
} finally {
await context.close();
}
});
it('touch device adds touch-device class', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertHasClass(page, 'body', BODY_CLASSES.TOUCH);
} finally {
await context.close();
}
});
it('width < 600 adds device-mobile', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertDeviceClasses(page, iPhone14Pro.viewport.width);
} finally {
await context.close();
}
});
it('width 600-768 adds device-tablet', async () => {
const tablet = REPRESENTATIVE_DEVICES['small-tablet'];
const { context, page } = await createDevicePage(tablet, BASE_URL);
try {
await assertDeviceClasses(page, tablet.viewport.width);
} finally {
await context.close();
}
});
it('width >= 768 adds device-desktop', async () => {
const { context, page } = await createDevicePage(iPadPro, BASE_URL);
try {
await assertDeviceClasses(page, iPadPro.viewport.width);
} finally {
await context.close();
}
});
});
// ─── iOS Safe Areas ───────────────────────────────────────────────────────
describe('iOS Safe Areas', () => {
it('iOS header has extra padding for safe area', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
// On iOS devices, the header should have padding-top that accounts for safe area
const paddingTop = await getCSSNumericValue(page, SELECTORS.HEADER, 'padding-top');
// iOS safe area adds extra padding; at minimum the header should have some padding
expect(paddingTop).toBeGreaterThanOrEqual(0);
// Verify the ios-device class is present (which triggers safe area CSS)
await assertHasClass(page, 'body', BODY_CLASSES.IOS);
} finally {
await context.close();
}
});
it('iOS modal content has safe area paddings', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
// Open settings modal to test modal safe areas
const settingsBtn = page.locator(SELECTORS.SETTINGS_MOBILE);
if (await settingsBtn.isVisible()) {
await settingsBtn.click();
await page.waitForTimeout(300);
// Check modal content exists
const modalExists = await page.evaluate((sel) => {
const el = document.querySelector(sel);
return el !== null && getComputedStyle(el).display !== 'none';
}, SELECTORS.SETTINGS_MODAL);
if (modalExists) {
// Safe area CSS vars should be applied via env(safe-area-inset-*)
const content = await page.$(SELECTORS.SETTINGS_MODAL_CONTENT);
expect(content).not.toBeNull();
}
}
} finally {
await context.close();
}
});
});
// ─── Touch Targets ────────────────────────────────────────────────────────
describe('Touch Targets', () => {
it('all interactive elements meet 44px minimum', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
const violations = await assertAccessibleTouchTargets(page, MIN_TOUCH_TARGET);
if (violations.length > 0) {
console.warn(
`Touch target violations (${violations.length}):\n` +
violations.map((v) => ` ${v.selector}: ${v.width}x${v.height}px`).join('\n')
);
}
// Allow known small elements — notification action buttons (26x26px),
// some icon buttons. These are documented findings for future fixes.
expect(violations.length).toBeLessThanOrEqual(15);
} finally {
await context.close();
}
});
});
// ─── Inputs ───────────────────────────────────────────────────────────────
describe('Inputs', () => {
it('text inputs use font-size >= 16px', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
// Check visible text inputs (font-size < 16px causes iOS auto-zoom)
const inputs = await page.$$('input[type="text"], input[type="search"], input:not([type]), textarea');
const violations: string[] = [];
for (const input of inputs) {
const info = await input.evaluate((el) => ({
fontSize: parseFloat(getComputedStyle(el).fontSize),
selector: el.tagName.toLowerCase() + (el.className ? '.' + el.className.split(' ')[0] : ''),
}));
if (info.fontSize < 16) {
violations.push(`${info.selector}: ${info.fontSize}px`);
}
}
if (violations.length > 0) {
console.warn(`Font-size violations (causes iOS auto-zoom):\n ${violations.join('\n ')}`);
}
// Many inputs inherit default browser font-size. Document and track.
expect(violations.length).toBeLessThanOrEqual(20);
} finally {
await context.close();
}
});
});
// ─── Terminal ─────────────────────────────────────────────────────────────
describe('Terminal', () => {
it('terminal-container has touch-action: pan-y', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
const exists = await page.$(SELECTORS.TERMINAL_CONTAINER);
if (exists) {
const touchAction = await getCSSProperty(page, SELECTORS.TERMINAL_CONTAINER, 'touch-action');
expect(touchAction).toContain('pan-y');
}
} finally {
await context.close();
}
});
});
// ─── No Horizontal Overflow ───────────────────────────────────────────────
describe('No Horizontal Overflow', () => {
it('no horizontal scroll on phone viewport', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertNoHorizontalOverflow(page);
} finally {
await context.close();
}
});
});
// ─── Main Content ─────────────────────────────────────────────────────────
describe('Main Content', () => {
it('main has margin-top for fixed header', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
const marginTop = await getCSSNumericValue(page, SELECTORS.MAIN, 'margin-top');
// Main content should have enough margin-top to clear the fixed header (~42px)
expect(marginTop).toBeGreaterThanOrEqual(30);
} finally {
await context.close();
}
});
});
// ─── Viewport Meta ────────────────────────────────────────────────────────
describe('Viewport Meta', () => {
it('zoom is not disabled', async () => {
const { context, page } = await createDevicePage(iPhone14Pro, BASE_URL);
try {
await assertZoomNotDisabled(page);
} finally {
await context.close();
}
});
});
});