diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 09b01fa..d3813f7 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.59.0"
+version = "0.60.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 83607b3..abb8d58 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.59.0"
+version = "0.60.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/mobile-account-library.spec.ts b/frontend/e2e/mobile-account-library.spec.ts
new file mode 100644
index 0000000..04d562e
--- /dev/null
+++ b/frontend/e2e/mobile-account-library.spec.ts
@@ -0,0 +1,244 @@
+import { test, expect, type Page } from '@playwright/test';
+
+// Phase 5: Account becomes an inset-grouped hub on mobile (Profile /
+// Preferences / Change password + red Log out at the bottom) and
+// /library hosts a SegmentedControl over Bookmarks / Collections /
+// History. Profile-layout horizontal tabs are hidden on mobile. Desktop
+// is unchanged.
+
+const MOBILE = { width: 390, height: 844 } as const;
+const DESKTOP = { width: 1280, height: 720 } as const;
+
+async function mockSession(
+ page: Page,
+ opts: { authed?: boolean; logoutCalls?: { count: number } } = {}
+) {
+ const authed = opts.authed ?? false;
+ await page.route('**/api/v1/auth/config', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ self_register_enabled: true, private_mode: false })
+ })
+ );
+ await page.route('**/api/v1/auth/me', (route) =>
+ route.fulfill({
+ status: authed ? 200 : 401,
+ contentType: 'application/json',
+ body: authed
+ ? JSON.stringify({
+ user: {
+ id: 'u1',
+ username: 'fabian',
+ created_at: '2026-01-05T00:00:00Z',
+ is_admin: false
+ }
+ })
+ : JSON.stringify({
+ error: { code: 'unauthenticated', message: 'unauthenticated' }
+ })
+ })
+ );
+ await page.route('**/api/v1/auth/me/preferences', (route) =>
+ route.fulfill({
+ status: authed ? 200 : 401,
+ contentType: 'application/json',
+ body: authed
+ ? JSON.stringify({
+ reader_mode: 'single',
+ reader_page_gap: 'none',
+ updated_at: '2026-01-05T00:00:00Z'
+ })
+ : JSON.stringify({ error: { code: 'x', message: 'x' } })
+ })
+ );
+ await page.route('**/api/v1/auth/logout', (route) => {
+ if (opts.logoutCalls) opts.logoutCalls.count += 1;
+ return route.fulfill({ status: 204, body: '' });
+ });
+}
+
+async function mockLibraryData(page: Page, opts: { authed?: boolean } = {}) {
+ const status = opts.authed ? 200 : 401;
+ const emptyPage = JSON.stringify({
+ items: [],
+ page: { limit: 50, offset: 0, total: 0 }
+ });
+ const unauth = JSON.stringify({
+ error: { code: 'unauthenticated', message: 'unauthenticated' }
+ });
+ await page.route('**/api/v1/me/bookmarks*', (route) =>
+ route.fulfill({
+ status,
+ contentType: 'application/json',
+ body: opts.authed ? emptyPage : unauth
+ })
+ );
+ await page.route('**/api/v1/me/collections*', (route) =>
+ route.fulfill({
+ status,
+ contentType: 'application/json',
+ body: opts.authed ? emptyPage : unauth
+ })
+ );
+ await page.route('**/api/v1/me/read-progress*', (route) =>
+ route.fulfill({
+ status,
+ contentType: 'application/json',
+ body: opts.authed ? emptyPage : unauth
+ })
+ );
+}
+
+test.describe('mobile library', () => {
+ test('phone viewport: BottomNav Library tab navigates to /library', async ({
+ page
+ }) => {
+ await mockSession(page);
+ await mockLibraryData(page);
+ await page.route('**/api/v1/mangas*', (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ items: [],
+ page: { limit: 50, offset: 0, total: 0 }
+ })
+ })
+ );
+ await page.setViewportSize(MOBILE);
+ await page.goto('/');
+
+ await page.getByTestId('bottom-nav-library').click();
+ await expect(page).toHaveURL(/\/library$/);
+ await expect(page.getByTestId('library-tabs')).toBeVisible();
+ });
+
+ test('phone viewport: SegmentedControl swaps Library sub-tabs and updates ?tab=', async ({
+ page
+ }) => {
+ await mockSession(page, { authed: true });
+ await mockLibraryData(page, { authed: true });
+ await page.setViewportSize(MOBILE);
+ await page.goto('/library');
+
+ // Default sub-tab is bookmarks — URL has no `tab=` param.
+ await expect(page).toHaveURL(/\/library$/);
+ await expect(page.getByTestId('library-bookmarks-empty')).toBeVisible();
+
+ await page
+ .getByTestId('library-tabs')
+ .getByRole('radio', { name: 'Collections' })
+ .click();
+ await expect(page).toHaveURL(/\?tab=collections$/);
+ await expect(page.getByTestId('library-collections-empty')).toBeVisible();
+
+ await page
+ .getByTestId('library-tabs')
+ .getByRole('radio', { name: 'History' })
+ .click();
+ await expect(page).toHaveURL(/\?tab=history$/);
+ await expect(page.getByTestId('library-history-empty')).toBeVisible();
+
+ // Returning to Bookmarks clears the param entirely.
+ await page
+ .getByTestId('library-tabs')
+ .getByRole('radio', { name: 'Bookmarks' })
+ .click();
+ await expect(page).toHaveURL(/\/library$/);
+ });
+
+ test('phone viewport on /library unauth: sign-in prompt, no list', async ({
+ page
+ }) => {
+ await mockSession(page);
+ await mockLibraryData(page);
+ await page.setViewportSize(MOBILE);
+ await page.goto('/library');
+
+ await expect(page.getByTestId('library-signin')).toBeVisible();
+ await expect(page.getByTestId('library-bookmarks-empty')).toHaveCount(0);
+ });
+});
+
+test.describe('mobile account hub', () => {
+ test('phone viewport authed: inset-grouped hub renders with profile / preferences / change-password / logout rows', async ({
+ page
+ }) => {
+ await mockSession(page, { authed: true });
+ await page.setViewportSize(MOBILE);
+ await page.goto('/profile/account');
+
+ await expect(page.getByTestId('account-hub')).toBeVisible();
+ await expect(page.getByTestId('account-username')).toHaveText('fabian');
+ await expect(page.getByTestId('account-row-profile')).toBeVisible();
+ await expect(page.getByTestId('account-row-preferences')).toBeVisible();
+ await expect(page.getByTestId('account-row-change-password')).toBeVisible();
+ await expect(page.getByTestId('account-row-logout')).toBeVisible();
+ // Desktop card MUST not render — only the hub view is active
+ // on mobile so we don't double-up the password form testids.
+ await expect(page.getByTestId('account-desktop-card')).toHaveCount(0);
+ });
+
+ test('phone viewport authed: Change password row opens the bottom sheet', async ({
+ page
+ }) => {
+ await mockSession(page, { authed: true });
+ await page.setViewportSize(MOBILE);
+ await page.goto('/profile/account');
+
+ await expect(page.getByTestId('password-sheet')).toBeHidden();
+ await page.getByTestId('account-row-change-password').click();
+ await expect(page.getByTestId('password-sheet')).toBeVisible();
+ await expect(
+ page.getByTestId('password-sheet').getByTestId('current-password')
+ ).toBeVisible();
+ });
+
+ test('phone viewport authed: Log out row fires /auth/logout and routes to /login', async ({
+ page
+ }) => {
+ const logoutCalls = { count: 0 };
+ await mockSession(page, { authed: true, logoutCalls });
+ await page.setViewportSize(MOBILE);
+ await page.goto('/profile/account');
+
+ await page.getByTestId('account-row-logout').click();
+
+ await expect(page).toHaveURL(/\/login$/);
+ expect(logoutCalls.count).toBe(1);
+ });
+
+ test('phone viewport unauth: sign-in CTA, no hub', async ({ page }) => {
+ await mockSession(page);
+ await page.setViewportSize(MOBILE);
+ await page.goto('/profile/account');
+
+ await expect(page.getByTestId('account-signin')).toBeVisible();
+ await expect(page.getByTestId('account-hub')).toHaveCount(0);
+ });
+
+ test('phone viewport authed: profile horizontal tabs are hidden', async ({
+ page
+ }) => {
+ await mockSession(page, { authed: true });
+ await page.setViewportSize(MOBILE);
+ await page.goto('/profile/account');
+
+ await expect(page.getByTestId('tab-account')).toBeHidden();
+ });
+
+ test('desktop viewport authed: existing password card visible, hub is not', async ({
+ page
+ }) => {
+ await mockSession(page, { authed: true });
+ await page.setViewportSize(DESKTOP);
+ await page.goto('/profile/account');
+
+ await expect(page.getByTestId('account-desktop-card')).toBeVisible();
+ await expect(page.getByTestId('password-form')).toBeVisible();
+ await expect(page.getByTestId('account-hub')).toHaveCount(0);
+ // Desktop profile tabs remain.
+ await expect(page.getByTestId('tab-account')).toBeVisible();
+ });
+});
diff --git a/frontend/e2e/mobile-chrome.spec.ts b/frontend/e2e/mobile-chrome.spec.ts
index 0cc9ff8..7036150 100644
--- a/frontend/e2e/mobile-chrome.spec.ts
+++ b/frontend/e2e/mobile-chrome.spec.ts
@@ -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');
});
diff --git a/frontend/package.json b/frontend/package.json
index 432228f..aeaba1c 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.59.0",
+ "version": "0.60.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 57de468..391944d 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -39,6 +39,7 @@
'/upload': 'Mangalord | Upload',
'/bookmarks': 'Mangalord | Bookmarks',
'/collections': 'Mangalord | Collections',
+ '/library': 'Mangalord | Library',
'/profile': 'Mangalord | Profile',
'/profile/account': 'Mangalord | Account',
'/profile/bookmarks': 'Mangalord | Bookmarks',
@@ -53,19 +54,21 @@
const layoutTitle = $derived(STATIC_TITLES[$page.route?.id ?? ''] ?? 'Mangalord');
- // Mobile bottom-nav tabs. Library consolidates Bookmarks / Collections /
- // History — it currently links to /bookmarks as the closest existing
- // route; Phase 5 introduces /library with internal segmented sub-tabs
- // and this href flips at that point. Browse temporarily shares Home's
- // destination until the curated-feed split lands.
+ // Mobile bottom-nav tabs. Library is now its own wrapper at /library
+ // (Phase 5) hosting a SegmentedControl over Bookmarks / Collections /
+ // History; the older top-level /bookmarks + /collections routes stay
+ // active for desktop users and as deep-link targets, so they remain
+ // in the `match` set. Browse temporarily shares Home's destination
+ // until the curated-feed split lands.
const MOBILE_TABS: BottomNavTab[] = [
{ label: 'Home', href: '/', icon: House, match: [] },
{ label: 'Browse', href: '/', icon: Search, match: [] },
{
label: 'Library',
- href: '/bookmarks',
+ href: '/library',
icon: BookOpen,
match: [
+ '/library',
'/bookmarks',
'/collections',
'/profile/bookmarks',
diff --git a/frontend/src/routes/library/+page.svelte b/frontend/src/routes/library/+page.svelte
new file mode 100644
index 0000000..3d87dd6
--- /dev/null
+++ b/frontend/src/routes/library/+page.svelte
@@ -0,0 +1,219 @@
+
+
+
+ Sign in to see your library. +
+{:else if data.error} ++ Couldn't load library: {data.error} +
+{:else if activeTab === 'bookmarks'} + {#if data.bookmarks.length === 0} +No bookmarks yet.
+ {:else} ++ You don't have any collections yet. Open any manga and use + Add to collection to start one. +
+ {:else} ++ Nothing here yet — open any manga and a row will land here once you turn + a page. +
+{:else} +- Sign in to change your password. -
-{:else} -- Changing your password signs out every other device using this account. - Bot API tokens keep working — revoke them individually from the bot-token - list if you want to invalidate them too. -
+{#snippet passwordForm()} -+ Sign in to manage your account. +
+{:else if isMobileViewport} ++ Changing your password signs out every other device using this account. + Bot API tokens keep working — revoke them individually from the bot-token + list if you want to invalidate them too. +
+ {@render passwordForm()} ++ Changing your password signs out every other device. Bot API tokens are + unaffected. +
+ {@render passwordForm()} +