diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 2e1d268..4196357 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
-version = "0.60.1"
+version = "0.60.2"
dependencies = [
"anyhow",
"argon2",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 71afb92..de7a7d8 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.60.1"
+version = "0.60.2"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/back-nav-flow.spec.ts b/frontend/e2e/back-nav-flow.spec.ts
new file mode 100644
index 0000000..1d44293
--- /dev/null
+++ b/frontend/e2e/back-nav-flow.spec.ts
@@ -0,0 +1,166 @@
+import { test, expect, type Page } from '@playwright/test';
+
+// Regression spec for the reader-back-loop bug: previously the reader's
+// back arrow was a plain ``, which PUSHED a new
+// history entry every tap, so browser-back kept ping-ponging between
+// detail and reader instead of walking out to home. The reader now
+// intercepts left-click and does `history.back()` when there's
+// same-origin history to pop.
+
+const MID = 'a1111111-1111-1111-1111-111111111111';
+const CID = 'c1111111-1111-1111-1111-111111111111';
+
+const mangaBody = {
+ id: MID,
+ title: 'Berserk',
+ status: 'ongoing',
+ alt_titles: [],
+ description: 'Short.',
+ cover_image_path: null,
+ created_at: '2026-01-01T00:00:00Z',
+ updated_at: '2026-01-01T00:00:00Z',
+ authors: [],
+ genres: [],
+ tags: []
+};
+
+async function mockApis(page: Page) {
+ await page.route('**/api/v1/auth/config', (r) =>
+ r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ self_register_enabled: true, private_mode: false })
+ })
+ );
+ await page.route('**/api/v1/auth/me', (r) =>
+ r.fulfill({
+ status: 401,
+ contentType: 'application/json',
+ body: JSON.stringify({ error: { code: 'x', message: 'x' } })
+ })
+ );
+ await page.route('**/api/v1/auth/me/preferences', (r) =>
+ r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })
+ );
+ await page.route('**/api/v1/me/bookmarks*', (r) =>
+ r.fulfill({ status: 401, contentType: 'application/json', body: '{}' })
+ );
+ await page.route('**/api/v1/me/read-progress*', (r) =>
+ r.fulfill({ status: 404, contentType: 'application/json', body: '{}' })
+ );
+ await page.route('**/api/v1/genres*', (r) =>
+ r.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
+ );
+ // Catalog returns a single card whose href points at MID so the
+ // SPA-click walk lands on the manga we've mocked downstream.
+ await page.route('**/api/v1/mangas', (r) =>
+ r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ items: [mangaBody],
+ page: { limit: 50, offset: 0, total: 1 }
+ })
+ })
+ );
+ await page.route('**/api/v1/mangas/**', (r) => {
+ const u = new URL(r.request().url());
+ const last = u.pathname.split('/').pop();
+ if (last === CID)
+ return r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ id: CID,
+ manga_id: MID,
+ number: 1,
+ title: 'Ch.1',
+ page_count: 1,
+ created_at: '2026-01-01T00:00:00Z'
+ })
+ });
+ if (last === 'pages')
+ return r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ pages: [
+ {
+ id: 'p',
+ chapter_id: CID,
+ page_number: 1,
+ storage_key: 'x.png',
+ content_type: 'image/png'
+ }
+ ]
+ })
+ });
+ if (u.pathname.endsWith('/chapters'))
+ return r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ items: [
+ {
+ id: CID,
+ manga_id: MID,
+ number: 1,
+ title: 'Ch.1',
+ page_count: 1,
+ created_at: '2026-01-01T00:00:00Z'
+ }
+ ],
+ page: { limit: 50, offset: 0, total: 1 }
+ })
+ });
+ return r.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify(mangaBody)
+ });
+ });
+ const png = Buffer.from(
+ '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
+ 'hex'
+ );
+ await page.route('**/api/v1/files/**', (r) =>
+ r.fulfill({ status: 200, contentType: 'image/png', body: png })
+ );
+}
+
+test.describe('back-nav flow', () => {
+
+test('phone viewport: reader back pops history (does not push), then detail back returns to home', async ({
+ page
+}) => {
+ await mockApis(page);
+ await page.setViewportSize({ width: 390, height: 844 });
+
+ await page.goto('/');
+ const initialLen = await page.evaluate(() => window.history.length);
+
+ // Walk: home → detail → reader USING SPA NAVIGATION (link clicks,
+ // not window.location.href). The bug only manifests on SPA nav
+ // because `document.referrer` stays empty across pushState — so
+ // a hard navigation would silently mask the regression.
+ await page.locator('[data-testid="manga-list"] a').first().click();
+ await page.waitForURL(/\/manga\/[^/]+$/);
+
+ await page.locator('[data-testid="chapter-list"] a').first().click();
+ await page.waitForURL(/\/chapter\//);
+
+ const lenAtReader = await page.evaluate(() => window.history.length);
+ expect(lenAtReader).toBe(initialLen + 2);
+
+ // Tap reader back → goes back to detail WITHOUT pushing a new entry.
+ await page.getByTestId('back-to-manga').click();
+ await page.waitForURL(/\/manga\/[^/]+$/);
+ const lenAtDetail = await page.evaluate(() => window.history.length);
+ expect(lenAtDetail).toBe(lenAtReader);
+
+ // Tap detail back → walks out to home.
+ await page.getByTestId('detail-back').click();
+ await page.waitForURL((url) => url.pathname === '/');
+});
+
+});
diff --git a/frontend/package.json b/frontend/package.json
index 6814999..bafdb17 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.60.1",
+ "version": "0.60.2",
"private": true,
"type": "module",
"scripts": {
diff --git a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
index 27bad73..fbbca5a 100644
--- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
+++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
@@ -446,6 +446,28 @@
progressTimer = setTimeout(flushProgress, 1500);
}
+ /**
+ * Reader back behavior — pop browser history instead of pushing a
+ * fresh entry for `/manga/{id}`. Previously this was a naked
+ * `` and every tap pushed, so browser-back ping-ponged
+ * between detail and reader instead of walking out to home.
+ *
+ * Just check `history.length > 1` — `document.referrer` does NOT
+ * update across SvelteKit SPA navigations, so referrer-based
+ * gating silently fell back to the default href every time.
+ * Middle-click / cmd-click / right-click are passed through so
+ * "open in new tab" still works.
+ */
+ function onBackClick(e: MouseEvent) {
+ if (!browser) return;
+ if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ if (window.history.length > 1) {
+ e.preventDefault();
+ window.history.back();
+ }
+ // else: let the href navigate (deep-link / fresh tab path)
+ }
+
// Single-mode: every page change moves the high-water mark.
// Intentionally NOT depending on `mode` — toggling layout doesn't
// change the read position, and re-running this effect on a mode
@@ -552,7 +574,12 @@