feat(frontend): mobile account hub + library wrapper (0.60.0)

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>
This commit is contained in:
MechaCat02
2026-06-07 11:35:07 +02:00
parent f5842510b7
commit 1e3fd27308
10 changed files with 790 additions and 25 deletions

View File

@@ -63,13 +63,34 @@ test.describe('mobile chrome', () => {
await expect(home).toHaveAttribute('aria-current', 'page');
});
test('phone viewport: tapping Library navigates to /bookmarks and marks itself active', async ({ 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(/\/bookmarks$/);
await expect(page).toHaveURL(/\/library$/);
await expect(page.getByTestId('bottom-nav-library')).toHaveAttribute('aria-current', 'page');
});