Merge pull request #1466 from BradGroux/fix/mobile-viewport-1463

fix: prevent mobile viewport expansion when opening menus
This commit is contained in:
Brad Groux 2026-09-04 07:24:52 -05:00 • committed by GitHub
commit 5cbaca7206
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 206 additions and 3 deletions

View file

@ -21,6 +21,8 @@ iOS uses Safari's Add to Home Screen flow instead of the browser install prompt.
Chrome shows the install prompt only when the manifest and service worker are reachable from the current origin.
On narrow screens, use **More views** in the header to reach Activity, Backlog, Archive, and other views. Their separate wide-screen shortcuts are omitted to preserve room for the logo, connection status, and task controls. Dropdown menus remain anchored to their controls without changing the page scale.
## Offline Behavior
The service worker caches only the static app shell, manifest, icons, and same-origin static assets. It does not cache `/api` responses, WebSocket traffic, task data, work products, tokens, comments, or mutation responses.

197
e2e/mobile-viewport.spec.ts Normal file
View file

@ -0,0 +1,197 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { bypassAuth, cleanupRoutes, deleteTask, seedTestTask } from './helpers/auth';
interface ViewportProbe {
frame: number;
samples: number[];
menuOverflows: string[];
}
declare global {
interface Window {
viewportProbe: ViewportProbe;
}
}
async function startViewportProbe(page: Page) {
await page.evaluate(() => {
const probe: ViewportProbe = { frame: 0, samples: [], menuOverflows: [] };
window.viewportProbe = probe;
const sample = () => {
probe.samples.push(innerWidth);
for (const menu of document.querySelectorAll('.mantine-Select-dropdown')) {
const rect = menu.getBoundingClientRect();
const style = getComputedStyle(menu);
if (
rect.width > 0 &&
Number(style.opacity) > 0.1 &&
style.visibility !== 'hidden' &&
(rect.left < -1 || rect.right > document.documentElement.clientWidth + 1)
) {
probe.menuOverflows.push(rect.left + '..' + rect.right);
}
}
probe.frame = requestAnimationFrame(sample);
};
sample();
});
}
async function assertReachable(locator: Locator) {
await expect(locator).toBeVisible();
expect(
await locator.evaluate((el) => {
const rect = el.getBoundingClientRect();
const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
return (
rect.left >= 0 &&
rect.right <= document.documentElement.clientWidth &&
!!hit &&
el.contains(hit)
);
})
).toBe(true);
}
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test.beforeEach(async ({ page }) => bypassAuth(page));
test.afterEach(async ({ page }) => {
await page.evaluate(() => cancelAnimationFrame(window.viewportProbe?.frame));
await cleanupRoutes(page);
});
for (const width of [320, 390, 430]) {
for (const textSize of [16, 20]) {
test(`Mobile controls fit ${width}px with ${textSize}px text`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 844 });
await page.addInitScript(
(theme) => localStorage.setItem('veritas-kanban-theme', theme),
textSize === 20 ? 'light' : 'dark'
);
const taskTitle = `Viewport ${width} ${textSize}`;
const task = await seedTestTask(page, { title: taskTitle, type: 'code' });
try {
await page.goto('/');
await page.addStyleTag({ content: `:root { font-size: ${textSize}px !important; }` });
await expect(page.locator('#mobile-board-columns')).toBeVisible();
expect(await page.evaluate(() => innerWidth)).toBe(width);
await startViewportProbe(page);
const header = page.locator('.desktop-app-header');
for (const name of [
'Refresh page',
'WebSocket connected',
'New Task',
'More views',
'Search',
'Session menu',
]) {
await assertReachable(header.getByRole('button', { name, exact: true }));
}
const overlaps = await header.evaluate((el) => {
const buttons = [...el.querySelectorAll('button')]
.map((button) => ({
name: button.getAttribute('aria-label'),
rect: button.getBoundingClientRect(),
}))
.filter(({ rect }) => rect.width > 0 && rect.height > 0);
return buttons.flatMap((a, index) =>
buttons
.slice(index + 1)
.filter(
(b) =>
Math.min(a.rect.right, b.rect.right) > Math.max(a.rect.left, b.rect.left) &&
Math.min(a.rect.bottom, b.rect.bottom) > Math.max(a.rect.top, b.rect.top)
)
.map((b) => [a.name, b.name])
);
});
expect(overlaps).toEqual([]);
await testInfo.attach('mobile-header', {
body: await page.screenshot(),
contentType: 'image/png',
});
await header.getByRole('button', { name: 'More views', exact: true }).click();
await page.getByRole('menuitem', { name: 'Activity', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Activity', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Mobile board', exact: true }).click();
await expect(page.locator('#mobile-board-columns')).toBeVisible();
await page.getByRole('button', { name: 'Mobile home', exact: true }).click();
await expect.poll(() => page.evaluate(() => scrollY)).toBe(0);
await page.getByRole('combobox', { name: 'Filter by type', exact: true }).click();
await page.getByRole('option', { name: 'All Types', exact: true }).click();
await page.getByRole('heading', { name: taskTitle, exact: true }).click();
const detail = page.getByTestId('task-detail-panel');
await expect(detail).toBeVisible();
const mode = detail.getByRole('combobox', { name: 'Task workspace mode', exact: true });
await mode.click();
await assertReachable(page.getByRole('option', { name: 'Plan', exact: true }));
await testInfo.attach('mobile-menu', {
body: await page.screenshot(),
contentType: 'image/png',
});
await page.getByRole('option', { name: 'Plan', exact: true }).click();
await detail.getByRole('combobox', { name: 'Plan section', exact: true }).click();
await page.getByRole('option', { name: 'Details', exact: true }).click();
await mode.click();
await page.getByRole('option', { name: 'Overview', exact: true }).click();
await assertReachable(detail.getByRole('button', { name: 'Close task workspace' }));
await testInfo.attach('mobile-workspace', {
body: await page.screenshot(),
contentType: 'image/png',
});
await detail.getByRole('button', { name: 'Close task workspace' }).click();
await expect(detail).not.toBeVisible();
await page.getByRole('button', { name: 'Mobile board', exact: true }).click();
const result = await page.evaluate(() => {
cancelAnimationFrame(window.viewportProbe.frame);
return window.viewportProbe;
});
expect(result.samples.length).toBeGreaterThan(5);
expect(Math.min(...result.samples)).toBe(width);
expect(Math.max(...result.samples)).toBe(width);
expect(result.menuOverflows).toEqual([]);
} finally {
await deleteTask(page, task.id as string);
}
});
}
}
test.describe('wide browser Select positioning', () => {
test.use({ viewport: { width: 1440, height: 900 }, isMobile: false, hasTouch: false });
test('preserves wide navigation and anchors a portaled menu after scrolling', async ({
page,
}) => {
const task = await seedTestTask(page, { title: 'Desktop dropdown positioning', type: 'code' });
try {
await page.goto('/');
await expect(page.locator('#mobile-board-columns')).toBeVisible();
const header = page.locator('.desktop-app-header');
await assertReachable(header.getByRole('button', { name: 'Activity', exact: true }));
await page
.getByRole('heading', { name: 'Desktop dropdown positioning', exact: true })
.click();
const detail = page.getByTestId('task-detail-panel');
await detail.getByRole('button', { name: 'Plan', exact: true }).click();
const priority = detail.getByRole('combobox', { name: 'Priority', exact: true });
await priority.scrollIntoViewIfNeeded();
await priority.click();
const option = page.getByRole('option', { name: 'Medium', exact: true });
await assertReachable(option);
const dropdown = page.locator('.mantine-Select-dropdown:visible');
const [targetBox, menuBox] = await Promise.all([
priority.boundingBox(),
dropdown.boundingBox(),
]);
expect(targetBox).not.toBeNull();
expect(menuBox).not.toBeNull();
expect(Math.abs(menuBox!.x - targetBox!.x)).toBeLessThanOrEqual(2);
expect(Math.abs(menuBox!.width - targetBox!.width)).toBeLessThanOrEqual(2);
await option.click();
await expect(priority).toHaveValue('Medium');
await detail.getByRole('button', { name: 'Close task workspace' }).click();
await expect(detail).not.toBeVisible();
} finally {
await deleteTask(page, task.id as string);
}
});
});

View file

@ -488,9 +488,11 @@ export function Header() {
</Button>
)}
{!isDesktopClient && (
<Group gap={4} wrap="nowrap" className="hidden xl:flex">
{PRIMARY_NAVIGATION_VIEWS.map(renderNavigationAction)}
</Group>
<div className="hidden xl:block">
<Group gap={4} wrap="nowrap">
{PRIMARY_NAVIGATION_VIEWS.map(renderNavigationAction)}
</Group>
</div>
)}
<Menu position="bottom-end" shadow="md" withinPortal>
<Menu.Target>

View file

@ -146,6 +146,8 @@ export const veritasMantineTheme = createTheme({
Select: {
defaultProps: {
radius: 'sm',
// Portaled menus must not expand the mobile document while positioning.
comboboxProps: { floatingStrategy: 'fixed' },
},
styles: {
input: {