diff --git a/docs/guides/PWA_INSTALL.md b/docs/guides/PWA_INSTALL.md index f6bff63e..c33b60c4 100644 --- a/docs/guides/PWA_INSTALL.md +++ b/docs/guides/PWA_INSTALL.md @@ -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. diff --git a/e2e/mobile-viewport.spec.ts b/e2e/mobile-viewport.spec.ts new file mode 100644 index 00000000..4f9ecec1 --- /dev/null +++ b/e2e/mobile-viewport.spec.ts @@ -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); + } + }); +}); diff --git a/web/src/components/layout/Header.tsx b/web/src/components/layout/Header.tsx index 63c06f83..d7d49a9b 100644 --- a/web/src/components/layout/Header.tsx +++ b/web/src/components/layout/Header.tsx @@ -488,9 +488,11 @@ export function Header() { )} {!isDesktopClient && ( - - {PRIMARY_NAVIGATION_VIEWS.map(renderNavigationAction)} - +
+ + {PRIMARY_NAVIGATION_VIEWS.map(renderNavigationAction)} + +
)} diff --git a/web/src/theme/mantine-theme.ts b/web/src/theme/mantine-theme.ts index 9df230b3..981ac572 100644 --- a/web/src/theme/mantine-theme.ts +++ b/web/src/theme/mantine-theme.ts @@ -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: {