feat(frontend): mobile primitives + bottom-nav chrome (0.56.0)
Phase 1 of the mobile redesign: add the reusable primitives every later phase plugs into, and switch the layout to a 4-tab bottom nav + compact AppBar on phone viewports while leaving the desktop header untouched above 640px. - New primitives: BottomNav, Sheet, AppBar, SegmentedControl, all with vitest unit tests. - Layout swaps the desktop header for AppBar + BottomNav under the existing 640px breakpoint. Login / register / reader routes opt out of the mobile chrome. - Library tab currently points at /bookmarks; the curated /library wrapper lands in Phase 5. - Independent ResizeObservers publish --app-header-h, --mobile-app-bar-h, --app-bottom-nav-h, with a zero-height skip so hidden bars don't clobber the visible one's value. - viewport-fit=cover + env(safe-area-inset-*) tokens for notched devices and the iOS home indicator. - Playwright spec covers visibility at 390px / 1280px viewports and tab navigation. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
84
frontend/e2e/mobile-chrome.spec.ts
Normal file
84
frontend/e2e/mobile-chrome.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// Mobile chrome contract: the AppBar + BottomNav are visible on phone
|
||||
// viewports and the desktop header is hidden. On desktop, the reverse.
|
||||
// On the reader route, both mobile bars are hidden so the reader's own
|
||||
// chrome owns the screen. The 640px cutover is the project's single
|
||||
// existing breakpoint and is also used here.
|
||||
|
||||
const MOBILE = { width: 390, height: 844 } as const;
|
||||
const DESKTOP = { width: 1280, height: 720 } as const;
|
||||
|
||||
async function mockAnonymous(page: Page) {
|
||||
await page.route('**/api/v1/auth/config', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/auth/me', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: { code: 'unauthenticated', message: 'unauthenticated' } })
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/mangas*', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } })
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('mobile chrome', () => {
|
||||
test('phone viewport on /: bottom nav visible, desktop header hidden', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.getByTestId('bottom-nav')).toBeVisible();
|
||||
await expect(page.getByTestId('mobile-app-bar')).toBeVisible();
|
||||
await expect(page.locator('header.desktop-header')).toBeHidden();
|
||||
});
|
||||
|
||||
test('desktop viewport on /: desktop header visible, mobile chrome hidden', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto('/');
|
||||
|
||||
await expect(page.locator('header.desktop-header')).toBeVisible();
|
||||
await expect(page.getByTestId('bottom-nav')).toBeHidden();
|
||||
await expect(page.getByTestId('mobile-app-bar')).toBeHidden();
|
||||
});
|
||||
|
||||
test('phone viewport: Home tab carries aria-current on /', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/');
|
||||
|
||||
const home = page.getByTestId('bottom-nav-home');
|
||||
await expect(home).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('phone viewport: tapping Library navigates to /bookmarks and marks itself active', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/');
|
||||
|
||||
await page.getByTestId('bottom-nav-library').click();
|
||||
await expect(page).toHaveURL(/\/bookmarks$/);
|
||||
await expect(page.getByTestId('bottom-nav-library')).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
test('phone viewport: login route hides the mobile chrome (auth gateway pattern)', async ({ page }) => {
|
||||
await mockAnonymous(page);
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page.getByTestId('bottom-nav')).toBeHidden();
|
||||
await expect(page.getByTestId('mobile-app-bar')).toBeHidden();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user