diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 78f890b..5f63575 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.55.0"
+version = "0.56.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index b45b810..5ee5a97 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.55.0"
+version = "0.56.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/mobile-chrome.spec.ts b/frontend/e2e/mobile-chrome.spec.ts
new file mode 100644
index 0000000..0cc9ff8
--- /dev/null
+++ b/frontend/e2e/mobile-chrome.spec.ts
@@ -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();
+ });
+});
diff --git a/frontend/package.json b/frontend/package.json
index 4c674e8..c808836 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.55.0",
+ "version": "0.56.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/app.html b/frontend/src/app.html
index d73162b..ac28d6d 100644
--- a/frontend/src/app.html
+++ b/frontend/src/app.html
@@ -3,7 +3,10 @@
-
+
Mangalord
+
+
+
+
diff --git a/frontend/src/lib/components/AppBar.svelte.test.ts b/frontend/src/lib/components/AppBar.svelte.test.ts
new file mode 100644
index 0000000..4634f07
--- /dev/null
+++ b/frontend/src/lib/components/AppBar.svelte.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/svelte';
+import { createRawSnippet } from 'svelte';
+import AppBar from './AppBar.svelte';
+
+afterEach(() => cleanup());
+
+describe('AppBar', () => {
+ it('renders the title as the h1', () => {
+ render(AppBar, { props: { title: 'Mangalord' } });
+ expect(screen.getByRole('heading', { level: 1, name: 'Mangalord' })).toBeTruthy();
+ });
+
+ it('omits the back control when neither backHref nor onBack is provided', () => {
+ render(AppBar, { props: { title: 'Home' } });
+ expect(screen.queryByRole('link', { name: 'Back' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
+ });
+
+ it('renders a back link when backHref is provided', () => {
+ render(AppBar, { props: { title: 'Detail', backHref: '/library' } });
+ const back = screen.getByRole('link', { name: 'Back' });
+ expect((back as HTMLAnchorElement).getAttribute('href')).toBe('/library');
+ });
+
+ it('renders a back button calling onBack when onBack is provided (and no href)', () => {
+ const onBack = vi.fn();
+ render(AppBar, { props: { title: 'Detail', onBack } });
+ screen.getByRole('button', { name: 'Back' }).click();
+ expect(onBack).toHaveBeenCalledOnce();
+ });
+
+ it('renders the trailing snippet when provided', () => {
+ const trailing = createRawSnippet(() => ({
+ render: () => ``
+ }));
+ render(AppBar, { props: { title: 'Detail', trailing } });
+ expect(screen.getByRole('button', { name: 'More' })).toBeTruthy();
+ });
+});
diff --git a/frontend/src/lib/components/BottomNav.svelte b/frontend/src/lib/components/BottomNav.svelte
new file mode 100644
index 0000000..4bf0677
--- /dev/null
+++ b/frontend/src/lib/components/BottomNav.svelte
@@ -0,0 +1,123 @@
+
+
+
+
+
diff --git a/frontend/src/lib/components/BottomNav.svelte.test.ts b/frontend/src/lib/components/BottomNav.svelte.test.ts
new file mode 100644
index 0000000..5122b60
--- /dev/null
+++ b/frontend/src/lib/components/BottomNav.svelte.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/svelte';
+import Home from '@lucide/svelte/icons/house';
+import Search from '@lucide/svelte/icons/search';
+import BookOpen from '@lucide/svelte/icons/book-open';
+import User from '@lucide/svelte/icons/user';
+import BottomNav, { type BottomNavTab } from './BottomNav.svelte';
+
+afterEach(() => cleanup());
+
+const tabs: BottomNavTab[] = [
+ { label: 'Home', href: '/', icon: Home, match: [] },
+ { label: 'Browse', href: '/browse', icon: Search, match: [] },
+ {
+ label: 'Library',
+ href: '/library',
+ icon: BookOpen,
+ match: ['/bookmarks', '/collections', '/profile/bookmarks', '/profile/collections', '/profile/history']
+ },
+ {
+ label: 'Account',
+ href: '/profile/account',
+ icon: User,
+ match: ['/profile', '/profile/preferences']
+ }
+];
+
+describe('BottomNav', () => {
+ it('renders all tabs as links with correct hrefs', () => {
+ render(BottomNav, { props: { tabs, currentPath: '/' } });
+ for (const tab of tabs) {
+ const link = screen.getByRole('link', { name: new RegExp(tab.label) });
+ expect((link as HTMLAnchorElement).getAttribute('href')).toBe(tab.href);
+ }
+ });
+
+ it('marks Home as aria-current on the root path', () => {
+ render(BottomNav, { props: { tabs, currentPath: '/' } });
+ const home = screen.getByRole('link', { name: /Home/ });
+ expect(home.getAttribute('aria-current')).toBe('page');
+ const library = screen.getByRole('link', { name: /Library/ });
+ expect(library.getAttribute('aria-current')).toBeNull();
+ });
+
+ it('marks Library as active when on /bookmarks', () => {
+ render(BottomNav, { props: { tabs, currentPath: '/bookmarks' } });
+ const library = screen.getByRole('link', { name: /Library/ });
+ expect(library.getAttribute('aria-current')).toBe('page');
+ });
+
+ it('marks Library as active on nested /profile/collections', () => {
+ render(BottomNav, { props: { tabs, currentPath: '/profile/collections' } });
+ const library = screen.getByRole('link', { name: /Library/ });
+ expect(library.getAttribute('aria-current')).toBe('page');
+ });
+
+ it('prefers the longer prefix when matches overlap (Account beats Library on /profile/account)', () => {
+ render(BottomNav, { props: { tabs, currentPath: '/profile/account' } });
+ const account = screen.getByRole('link', { name: /Account/ });
+ const library = screen.getByRole('link', { name: /Library/ });
+ expect(account.getAttribute('aria-current')).toBe('page');
+ expect(library.getAttribute('aria-current')).toBeNull();
+ });
+
+ it('leaves all tabs inactive on an unknown route', () => {
+ render(BottomNav, { props: { tabs, currentPath: '/unknown' } });
+ for (const tab of tabs) {
+ const link = screen.getByRole('link', { name: new RegExp(tab.label) });
+ expect(link.getAttribute('aria-current')).toBeNull();
+ }
+ });
+});
diff --git a/frontend/src/lib/components/SegmentedControl.svelte b/frontend/src/lib/components/SegmentedControl.svelte
new file mode 100644
index 0000000..7c36015
--- /dev/null
+++ b/frontend/src/lib/components/SegmentedControl.svelte
@@ -0,0 +1,74 @@
+
+
+
+ {#each options as opt (opt.value)}
+
+ {/each}
+
+
+
diff --git a/frontend/src/lib/components/SegmentedControl.svelte.test.ts b/frontend/src/lib/components/SegmentedControl.svelte.test.ts
new file mode 100644
index 0000000..9f393f6
--- /dev/null
+++ b/frontend/src/lib/components/SegmentedControl.svelte.test.ts
@@ -0,0 +1,53 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup } from '@testing-library/svelte';
+import SegmentedControl from './SegmentedControl.svelte';
+
+afterEach(() => cleanup());
+
+const opts = [
+ { label: 'Bookmarks', value: 'bookmarks' as const },
+ { label: 'Collections', value: 'collections' as const },
+ { label: 'History', value: 'history' as const }
+];
+
+describe('SegmentedControl', () => {
+ it('marks the active option with aria-checked=true and a single .active class', () => {
+ render(SegmentedControl, {
+ props: {
+ options: opts,
+ value: 'collections',
+ onchange: () => {},
+ ariaLabel: 'Library tab'
+ }
+ });
+ const active = screen.getByRole('radio', { name: 'Collections' });
+ expect(active.getAttribute('aria-checked')).toBe('true');
+ const inactive = screen.getByRole('radio', { name: 'Bookmarks' });
+ expect(inactive.getAttribute('aria-checked')).toBe('false');
+ });
+
+ it('fires onchange with the new value when an inactive option is clicked', () => {
+ const onchange = vi.fn();
+ render(SegmentedControl, {
+ props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' }
+ });
+ screen.getByRole('radio', { name: 'History' }).click();
+ expect(onchange).toHaveBeenCalledWith('history');
+ });
+
+ it('does not fire onchange when the currently active option is clicked again', () => {
+ const onchange = vi.fn();
+ render(SegmentedControl, {
+ props: { options: opts, value: 'bookmarks', onchange, ariaLabel: 'Library tab' }
+ });
+ screen.getByRole('radio', { name: 'Bookmarks' }).click();
+ expect(onchange).not.toHaveBeenCalled();
+ });
+
+ it('exposes the group with the provided aria-label', () => {
+ render(SegmentedControl, {
+ props: { options: opts, value: 'bookmarks', onchange: () => {}, ariaLabel: 'Library tab' }
+ });
+ expect(screen.getByRole('radiogroup', { name: 'Library tab' })).toBeTruthy();
+ });
+});
diff --git a/frontend/src/lib/components/Sheet.svelte b/frontend/src/lib/components/Sheet.svelte
new file mode 100644
index 0000000..bed6931
--- /dev/null
+++ b/frontend/src/lib/components/Sheet.svelte
@@ -0,0 +1,227 @@
+
+
+{#if open}
+
+
+
+
+
{@render children()}
+ {#if footer}
+
+ {/if}
+
+
+{/if}
+
+
diff --git a/frontend/src/lib/components/Sheet.svelte.test.ts b/frontend/src/lib/components/Sheet.svelte.test.ts
new file mode 100644
index 0000000..b59ff7e
--- /dev/null
+++ b/frontend/src/lib/components/Sheet.svelte.test.ts
@@ -0,0 +1,84 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { render, screen, cleanup, fireEvent } from '@testing-library/svelte';
+import { createRawSnippet } from 'svelte';
+import Sheet from './Sheet.svelte';
+
+afterEach(() => cleanup());
+
+function bodySnippet(text: string) {
+ return createRawSnippet(() => ({
+ render: () => `${text}
`
+ }));
+}
+
+describe('Sheet', () => {
+ it('renders nothing when open=false', () => {
+ render(Sheet, {
+ props: {
+ open: false,
+ title: 'Filter',
+ onClose: () => {},
+ children: bodySnippet('hello')
+ }
+ });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('renders the dialog with title and body when open=true', () => {
+ render(Sheet, {
+ props: {
+ open: true,
+ title: 'Filter',
+ onClose: () => {},
+ children: bodySnippet('body content')
+ }
+ });
+ const dialog = screen.getByRole('dialog', { name: 'Filter' });
+ expect(dialog).toBeTruthy();
+ expect(screen.getByText('body content')).toBeTruthy();
+ });
+
+ it('calls onClose when the X close button is clicked', () => {
+ const onClose = vi.fn();
+ render(Sheet, {
+ props: {
+ open: true,
+ title: 'Filter',
+ onClose,
+ children: bodySnippet('x')
+ }
+ });
+ screen.getByRole('button', { name: 'Close' }).click();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('calls onClose when the scrim is clicked (sheet variant always dismisses on scrim tap)', () => {
+ const onClose = vi.fn();
+ const { container } = render(Sheet, {
+ props: {
+ open: true,
+ title: 'Filter',
+ onClose,
+ children: bodySnippet('x'),
+ testid: 'filter-sheet'
+ }
+ });
+ const scrim = container.querySelector('[data-testid="filter-sheet-scrim"]') as HTMLElement;
+ scrim.click();
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+
+ it('calls onClose when Escape is pressed', async () => {
+ const onClose = vi.fn();
+ render(Sheet, {
+ props: {
+ open: true,
+ title: 'Filter',
+ onClose,
+ children: bodySnippet('x')
+ }
+ });
+ await fireEvent.keyDown(document, { key: 'Escape' });
+ expect(onClose).toHaveBeenCalledOnce();
+ });
+});
diff --git a/frontend/src/lib/styles/tokens.css b/frontend/src/lib/styles/tokens.css
index b1cad87..579bfd7 100644
--- a/frontend/src/lib/styles/tokens.css
+++ b/frontend/src/lib/styles/tokens.css
@@ -66,9 +66,27 @@
in +layout.svelte and the reader, so they reflect rendered
size and survive font / zoom / wrap changes. */
--app-header-h: 60px;
+ --mobile-app-bar-h: 48px;
+ --app-bottom-nav-h: 56px;
--reader-nav-h: 56px;
--reader-bar-h: 56px;
+ /* Mobile breakpoint tokens. CSS @media queries can't read custom
+ properties, so these are documented anchors — keep the pixel
+ values in @media (max-width: 640px) etc. in lockstep. */
+ --bp-sm: 640px;
+ --bp-md: 768px;
+ --bp-lg: 1024px;
+
+ /* Safe-area helpers for notched / home-indicator devices. Resolve
+ to 0 on devices without insets, so they're safe everywhere as
+ long as
+ is set (see app.html). */
+ --safe-top: env(safe-area-inset-top, 0px);
+ --safe-bottom: env(safe-area-inset-bottom, 0px);
+ --safe-left: env(safe-area-inset-left, 0px);
+ --safe-right: env(safe-area-inset-right, 0px);
+
--z-dropdown: 10;
--z-sticky: 50;
--z-modal: 100;
diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 1f530af..4508652 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -7,17 +7,25 @@
import { preferences } from '$lib/preferences.svelte';
import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
+ import AppBar from '$lib/components/AppBar.svelte';
+ import BottomNav, { type BottomNavTab } from '$lib/components/BottomNav.svelte';
import Upload from '@lucide/svelte/icons/upload';
import UserCircle from '@lucide/svelte/icons/user-circle';
import Bookmark from '@lucide/svelte/icons/bookmark';
import FolderOpen from '@lucide/svelte/icons/folder-open';
import LogOut from '@lucide/svelte/icons/log-out';
import Shield from '@lucide/svelte/icons/shield';
+ import House from '@lucide/svelte/icons/house';
+ import Search from '@lucide/svelte/icons/search';
+ import BookOpen from '@lucide/svelte/icons/book-open';
+ import User from '@lucide/svelte/icons/user';
import '$lib/styles/tokens.css';
let { children, data } = $props();
let loggingOut = $state(false);
let headerEl: HTMLElement | undefined = $state();
+ let appBarEl: HTMLElement | undefined = $state();
+ let bottomNavEl: HTMLElement | undefined = $state();
// Static-route title map. Dynamic pages (manga / author / collection /
// chapter) override this via their own , since the
@@ -45,6 +53,46 @@
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.
+ const MOBILE_TABS: BottomNavTab[] = [
+ { label: 'Home', href: '/', icon: House, match: [] },
+ { label: 'Browse', href: '/', icon: Search, match: [] },
+ {
+ label: 'Library',
+ href: '/bookmarks',
+ icon: BookOpen,
+ match: [
+ '/bookmarks',
+ '/collections',
+ '/profile/bookmarks',
+ '/profile/collections',
+ '/profile/history'
+ ]
+ },
+ {
+ label: 'Account',
+ href: '/profile/account',
+ icon: User,
+ match: ['/profile', '/profile/preferences']
+ }
+ ];
+
+ // Auth-gate and reader routes hide the mobile chrome entirely — the
+ // bottom nav would be visual clutter on a login form and would fight
+ // the reader's own chrome for screen real-estate. The desktop header
+ // is unaffected; it continues to follow the reader fullscreen slide
+ // transform.
+ const HIDE_MOBILE_CHROME_PATHS = new Set(['/login', '/register']);
+ const showMobileChrome = $derived.by(() => {
+ if (HIDE_MOBILE_CHROME_PATHS.has($page.url.pathname)) return false;
+ if ($page.route?.id === '/manga/[id]/chapter/[chapter_id]') return false;
+ return true;
+ });
+
// Seed authConfig from the universal layout load. $effect keeps
// the store in sync if `data` is replaced by a subsequent layout
// load (client-side nav). The first run also covers initial
@@ -58,25 +106,55 @@
theme.init();
preferences.init();
if (!session.loaded) session.refresh();
+ });
- // Publish the header's measured height as a CSS custom
- // property so sticky descendants (e.g. the reader nav) can
- // pin themselves directly below it without guessing. A
- // ResizeObserver keeps it in sync as the viewport reflows
- // (the nav `flex-wrap: wrap`s on narrow widths), the user
- // zooms, or fonts swap. Hard-coded pixel offsets in tokens
- // are wrong in principle — actual height varies with all
- // of the above.
+ // Three runtime height publishers — each is an independent $effect
+ // keyed by its element ref, so they handle mount, unmount, and
+ // SPA navigation (e.g. entering / leaving the reader, which
+ // toggles `showMobileChrome`) without leaking observers.
+ //
+ // Skipping publish when offsetHeight === 0 is what lets the
+ // desktop header and the mobile AppBar coexist in the DOM: only
+ // the one visible at the current viewport publishes a non-zero
+ // height; the other (display: none) stays at its CSS fallback.
+ $effect(() => {
if (!headerEl) return;
+ const el = headerEl;
const publish = () => {
- document.documentElement.style.setProperty(
- '--app-header-h',
- `${headerEl!.offsetHeight}px`
- );
+ const h = el.offsetHeight;
+ if (h > 0) document.documentElement.style.setProperty('--app-header-h', `${h}px`);
};
publish();
const ro = new ResizeObserver(publish);
- ro.observe(headerEl);
+ ro.observe(el);
+ return () => ro.disconnect();
+ });
+
+ $effect(() => {
+ if (!appBarEl) return;
+ const el = appBarEl;
+ const publish = () => {
+ const h = el.offsetHeight;
+ if (h > 0)
+ document.documentElement.style.setProperty('--mobile-app-bar-h', `${h}px`);
+ };
+ publish();
+ const ro = new ResizeObserver(publish);
+ ro.observe(el);
+ return () => ro.disconnect();
+ });
+
+ $effect(() => {
+ if (!bottomNavEl) return;
+ const el = bottomNavEl;
+ const publish = () => {
+ const h = el.offsetHeight;
+ if (h > 0)
+ document.documentElement.style.setProperty('--app-bottom-nav-h', `${h}px`);
+ };
+ publish();
+ const ro = new ResizeObserver(publish);
+ ro.observe(el);
return () => ro.disconnect();
});
@@ -109,7 +187,13 @@
{layoutTitle}
-
+{#if showMobileChrome}
+
+{/if}
+
+