diff --git a/frontend/e2e/mobile-ui.spec.ts b/frontend/e2e/mobile-ui.spec.ts index 03bc418..4fb83a5 100644 --- a/frontend/e2e/mobile-ui.spec.ts +++ b/frontend/e2e/mobile-ui.spec.ts @@ -10,6 +10,129 @@ async function openTaskComposer(page: Page) { return page.getByRole('dialog', { name: /添加(?:今天)?任务/ }) } +test('bottom navigation uses the paper baseline geometry', async ({ page }) => { + await page.goto('/') + const metrics = await page.locator('.bottom').evaluate(element => { + const nav = element as HTMLElement + const rect = nav.getBoundingClientRect() + const buttons = [...nav.querySelectorAll('button')] + const active = nav.querySelector('button.active')! + const activeStyle = getComputedStyle(active) + const marker = getComputedStyle(active, '::before') + const fab = document.querySelector('.unified-fab')! + const fabRect = fab.getBoundingClientRect() + return { + viewportWidth: innerWidth, + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + nav: { left: rect.left, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }, + buttonWidths: buttons.map(button => button.getBoundingClientRect().width), + buttonHeights: buttons.map(button => button.getBoundingClientRect().height), + background: getComputedStyle(nav).backgroundColor, + borderTopWidth: getComputedStyle(nav).borderTopWidth, + borderRadius: getComputedStyle(nav).borderRadius, + boxShadow: getComputedStyle(nav).boxShadow, + activeBackground: activeStyle.backgroundColor, + activeColor: activeStyle.color, + marker: { height: marker.height, background: marker.backgroundColor }, + fabGap: rect.top - fabRect.bottom, + } + }) + expect(metrics.documentOverflow).toBe(0) + expect(metrics.nav).toEqual({ left: 0, right: metrics.viewportWidth, bottom: page.viewportSize()!.height, width: metrics.viewportWidth, height: 64 }) + expect(Math.max(...metrics.buttonWidths) - Math.min(...metrics.buttonWidths)).toBeLessThanOrEqual(1) + expect(metrics.buttonHeights.every(height => height >= 44)).toBeTruthy() + expect(metrics.background).toBe('rgb(255, 253, 248)') + expect(metrics.borderTopWidth).toBe('1px') + expect(metrics.borderRadius).toBe('0px') + expect(metrics.boxShadow).toBe('none') + expect(metrics.activeBackground).toBe('rgba(0, 0, 0, 0)') + expect(metrics.activeColor).toBe('rgb(241, 90, 41)') + expect(metrics.marker).toEqual({ height: '3px', background: 'rgb(241, 90, 41)' }) + expect(metrics.fabGap).toBeGreaterThanOrEqual(12) + + const fab = page.locator('.unified-fab') + const fabBox = await fab.boundingBox() + expect(fabBox).not.toBeNull() + await page.mouse.move(fabBox!.x + fabBox!.width / 2, fabBox!.y + fabBox!.height / 2) + await page.mouse.down() + await page.mouse.move(fabBox!.x + fabBox!.width / 2, page.viewportSize()!.height - 1, { steps: 8 }) + await page.mouse.up() + const draggedGap = await page.evaluate(() => { + const nav = document.querySelector('.bottom')!.getBoundingClientRect() + const button = document.querySelector('.unified-fab')!.getBoundingClientRect() + return nav.top - button.bottom + }) + expect(draggedGap).toBeGreaterThanOrEqual(12) +}) + +test('bottom navigation keeps its safe-area gap after dragging', async ({ page }) => { + await page.goto('/') + const nav = page.locator('.bottom') + await expect(nav).toBeVisible() + await page.locator('html').evaluate(element => element.style.setProperty('--safe-area-bottom', '34px', 'important')) + await expect.poll(() => nav.evaluate(element => getComputedStyle(element).height)).toBe('98px') + + const navBox = await nav.boundingBox() + expect(navBox).not.toBeNull() + expect(navBox!.height).toBe(98) + + const fab = page.locator('.unified-fab') + const fabBox = await fab.boundingBox() + expect(fabBox).not.toBeNull() + await page.mouse.move(fabBox!.x + fabBox!.width / 2, fabBox!.y + fabBox!.height / 2) + await page.mouse.down() + await page.mouse.move(fabBox!.x + fabBox!.width / 2, page.viewportSize()!.height - 1, { steps: 8 }) + await page.mouse.up() + + const gap = await page.evaluate(() => { + const navigation = document.querySelector('.bottom')!.getBoundingClientRect() + const button = document.querySelector('.unified-fab')!.getBoundingClientRect() + return navigation.top - button.bottom + }) + expect(gap).toBeGreaterThanOrEqual(12) +}) + +test('all bottom destinations expose one active page and desktop layout stays unchanged', async ({ page }) => { + await page.goto('/') + const navigation = page.getByRole('navigation', { name: '主要导航' }) + for (const label of ['今天', '习惯', '倒数日', '设置']) { + await bottomTab(page, label).click() + await expect(navigation.locator('[aria-current="page"]')).toHaveCount(1) + await expect(bottomTab(page, label)).toHaveAttribute('aria-current', 'page') + } + + await bottomTab(page, '今天').click() + await page.setViewportSize({ width: 1440, height: 900 }) + const desktop = await page.locator('.shell').evaluate(element => { + const shell = getComputedStyle(element) + const navigation = document.querySelector('.bottom')! + const navStyle = getComputedStyle(navigation) + const fab = document.querySelector('.unified-fab')! + const fabStyle = getComputedStyle(fab) + const fabRect = fab.getBoundingClientRect() + return { + shellDisplay: shell.display, + shellColumns: shell.gridTemplateColumns, + navDisplay: navStyle.display, + fab: { position: fabStyle.position, width: fabRect.width, height: fabRect.height }, + } + }) + expect(desktop.shellDisplay).toBe('grid') + expect(desktop.shellColumns.split(' ').length).toBe(3) + expect(desktop.navDisplay).toBe('none') + expect(desktop.fab).toEqual({ position: 'fixed', width: 56, height: 56 }) + + const desktopFab = page.locator('.unified-fab') + const desktopFabBox = await desktopFab.boundingBox() + expect(desktopFabBox).not.toBeNull() + await page.mouse.move(desktopFabBox!.x + desktopFabBox!.width / 2, desktopFabBox!.y + desktopFabBox!.height / 2) + await page.mouse.down() + await page.mouse.move(desktopFabBox!.x + desktopFabBox!.width / 2, 899, { steps: 8 }) + await page.mouse.up() + const desktopBottomGap = await desktopFab.evaluate(element => innerHeight - element.getBoundingClientRect().bottom) + expect(desktopBottomGap).toBe(84) +}) + test('settings are continuous, fit viewport, and controls are touch sized', async ({ page }) => { await page.goto('/') await bottomTab(page, '设置').click() diff --git a/frontend/src/components/FloatingAddButton.vue b/frontend/src/components/FloatingAddButton.vue index f3799ce..c3b3967 100644 --- a/frontend/src/components/FloatingAddButton.vue +++ b/frontend/src/components/FloatingAddButton.vue @@ -1,7 +1,7 @@