Phase 5 (final) of the mobile redesign: /profile/account becomes an
inset-grouped iOS-style hub on mobile and the new /library route hosts
a SegmentedControl over Bookmarks / Collections / History. Profile-
layout horizontal tabs hide below 640px. Desktop layout is unchanged.
- New /library/+page.ts loads bookmarks, collections, and read-history
in parallel so segmented-control tabs swap instantly without a new
round trip. 401 → unauthenticated path with sign-in prompt.
- New /library/+page.svelte renders the SegmentedControl with ?tab=
as the source of truth. Bookmarks reuses the existing BookmarkList,
Collections reuses CollectionsGrid, History inlines a slim cover +
title + "Continue Ch. N" row list. `goto(..., { replaceState: true })`
drives the URL — plain `replaceState` from $app/navigation doesn't
reliably re-trigger $page-derived state.
- BottomNav's Library tab now points at /library (Phase 1 placeholder
was /bookmarks). The Phase 1 mobile-chrome spec is updated to expect
the new target and mocks the additional library data endpoints.
- /profile/+layout.svelte hides the horizontal tabs below 640px — the
bottom-nav Library/Account tabs plus the account hub carry mobile
cross-section navigation.
- /profile/account/+page.svelte gains a mobile hub: a centered avatar
+ username + "Member since" header, a row group with Profile /
Preferences / Change password rows (the last opening a bottom
Sheet hosting the existing password form snippet), and a separate
group with a red "Log out" row that reuses the layout's logout
flow (logout API → session.setUser(null) → preferences.clearForLogout
→ goto('/login')). Desktop keeps the inline card with the form.
- matchMedia gates the hub vs. desktop card so the password form
testids never duplicate on the page — the snippet is rendered in
exactly one mount at a time.
- 9 Playwright tests cover the Library nav handoff, segmented-control
sub-tab swap with URL, sign-in CTAs on both routes, account hub
composition, password sheet open flow, logout flow (POST /auth/
logout + redirect to /login), profile tabs hiding on mobile, and
the desktop regression where the inline card is the only password
surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
4.1 KiB
TypeScript
106 lines
4.1 KiB
TypeScript
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 /library and marks itself active', async ({ page }) => {
|
|
await mockAnonymous(page);
|
|
await page.route('**/api/v1/me/bookmarks*', (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: { code: 'x', message: 'x' } })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/collections*', (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: { code: 'x', message: 'x' } })
|
|
})
|
|
);
|
|
await page.route('**/api/v1/me/read-progress*', (route) =>
|
|
route.fulfill({
|
|
status: 401,
|
|
contentType: 'application/json',
|
|
body: JSON.stringify({ error: { code: 'x', message: 'x' } })
|
|
})
|
|
);
|
|
await page.setViewportSize(MOBILE);
|
|
await page.goto('/');
|
|
|
|
await page.getByTestId('bottom-nav-library').click();
|
|
await expect(page).toHaveURL(/\/library$/);
|
|
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();
|
|
});
|
|
});
|