diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index c8bd883..17c3a96 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.100.0"
+version = "0.101.0"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 0432e04..05bfcf7 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.100.0"
+version = "0.101.0"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/reader-swipe.spec.ts b/frontend/e2e/reader-swipe.spec.ts
new file mode 100644
index 0000000..efb0586
--- /dev/null
+++ b/frontend/e2e/reader-swipe.spec.ts
@@ -0,0 +1,73 @@
+import { test, expect, type Page } from './fixtures';
+
+// Single-page reader: a horizontal swipe turns the page; a mostly-vertical
+// drag does not (so panning a tall page never turns it). Swipe is wired into
+// the mobile tap-zone overlay, which only exists in single mode.
+
+const MANGA_ID = 'm1';
+const CHAPTER_ID = 'c1';
+
+async function mockReader(page: Page) {
+ await page.route('**/api/v1/**', async (route) => {
+ const { pathname } = new URL(route.request().url());
+ const json = (status: number, body: unknown) =>
+ route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) });
+
+ if (pathname.includes('/files/')) {
+ return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: '' });
+ }
+ if (pathname.endsWith('/auth/config')) return json(200, { self_register_enabled: true, private_mode: false });
+ if (pathname.endsWith('/auth/me')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ if (pathname.endsWith('/auth/me/preferences')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
+ return json(200, { pages: [
+ { id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'k/1', content_type: 'image/svg+xml' },
+ { id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'k/2', content_type: 'image/svg+xml' }
+ ] });
+ }
+ if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, { id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 });
+ if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) return json(200, { items: [{ id: CHAPTER_ID, manga_id: MANGA_ID, number: 1, title: null, page_count: 2, created_at: '2026-01-01T00:00:00Z', size_bytes: 0 }], page: { limit: 200, offset: 0, total: 1 } });
+ if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, { id: MANGA_ID, title: 'Berserk', status: 'ongoing', alt_titles: [], description: null, cover_image_path: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', authors: [], genres: [], tags: [], content_warnings: [], chapter_storage_bytes: 0 });
+ if (pathname.includes('/me/read-progress')) return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
+ });
+}
+
+// Dispatch a touch pointer swipe on the tap-zone overlay.
+async function swipe(page: Page, dx: number, dy: number) {
+ await page.evaluate(
+ ({ dx, dy }) => {
+ const el = document.querySelector('[data-testid="reader-tap-center"]');
+ if (!el) throw new Error('tap zone not found');
+ const sx = 300;
+ const sy = 500;
+ const ev = (type: string, x: number, y: number) =>
+ new PointerEvent(type, { clientX: x, clientY: y, pointerType: 'touch', bubbles: true, cancelable: true });
+ el.dispatchEvent(ev('pointerdown', sx, sy));
+ el.dispatchEvent(ev('pointermove', sx + dx / 2, sy + dy / 2));
+ el.dispatchEvent(ev('pointerup', sx + dx, sy + dy));
+ },
+ { dx, dy }
+ );
+}
+
+test('horizontal swipe turns the page; vertical drag does not', async ({ page }) => {
+ await mockReader(page);
+ await page.setViewportSize({ width: 390, height: 844 }); // mobile → tap zones render
+ await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
+
+ const img = page.getByTestId('reader-page');
+ await expect(img).toHaveAttribute('src', /k\/1/);
+
+ // A near-vertical drag must NOT turn the page.
+ await swipe(page, 6, -220);
+ await expect(img).toHaveAttribute('src', /k\/1/);
+
+ // A leftward horizontal swipe advances to the next page.
+ await swipe(page, -200, 8);
+ await expect(img).toHaveAttribute('src', /k\/2/);
+
+ // A rightward horizontal swipe goes back.
+ await swipe(page, 200, 8);
+ await expect(img).toHaveAttribute('src', /k\/1/);
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index c78181e..4e0da67 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mangalord-frontend",
- "version": "0.100.0",
+ "version": "0.101.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mangalord-frontend",
- "version": "0.100.0",
+ "version": "0.101.0",
"devDependencies": {
"@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0",
diff --git a/frontend/package.json b/frontend/package.json
index 60f903f..ef6b757 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.100.0",
+ "version": "0.101.0",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/lib/components/TapZone.svelte b/frontend/src/lib/components/TapZone.svelte
index e188354..310a00b 100644
--- a/frontend/src/lib/components/TapZone.svelte
+++ b/frontend/src/lib/components/TapZone.svelte
@@ -1,5 +1,6 @@