diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index dc0f5e1..b5f1ef6 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "mangalord"
-version = "0.94.0"
+version = "0.94.1"
edition = "2021"
default-run = "mangalord"
diff --git a/frontend/e2e/page-context-menu.spec.ts b/frontend/e2e/page-context-menu.spec.ts
index d43de13..37e00e0 100644
--- a/frontend/e2e/page-context-menu.spec.ts
+++ b/frontend/e2e/page-context-menu.spec.ts
@@ -209,12 +209,15 @@ async function mockReader(
}
);
- const png = Buffer.from(
- '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082',
- 'hex'
- );
+ // A landscape page image (wide aspect) so that, under the width-driven
+ // reader sizing, the rendered page is SHORTER than the viewport. A tall
+ // page would push its centre off-screen, and Playwright's `.click()`
+ // would scroll it into view first — that synthetic scroll trips the
+ // context menu's (by-design) close-on-scroll and flakes these tests.
+ // Real users right-click at the visible cursor with no such scroll.
+ const image = ``;
await page.route('**/api/v1/files/**', (route) =>
- route.fulfill({ status: 200, contentType: 'image/png', body: png })
+ route.fulfill({ status: 200, contentType: 'image/svg+xml', body: image })
);
}
diff --git a/frontend/e2e/reader-page-sizing.spec.ts b/frontend/e2e/reader-page-sizing.spec.ts
new file mode 100644
index 0000000..f874d11
--- /dev/null
+++ b/frontend/e2e/reader-page-sizing.spec.ts
@@ -0,0 +1,149 @@
+import { test, expect, type Page } from './fixtures';
+
+// Regression coverage for the reader page-sizing fix: pages must render at a
+// consistent width regardless of their intrinsic dimensions, and a long
+// (tall) page must extend downward and scroll instead of collapsing into a
+// thin vertical stripe.
+//
+// The failure this guards against: sizing used to be height-driven
+// (`max-height: 90vh` in single mode; unbounded natural width in continuous
+// mode), so a tall webtoon strip shrank to a sliver and a narrow scan and a
+// wide scan rendered at different widths.
+
+const MANGA_ID = 'm1';
+const CHAPTER_ID = 'c1';
+
+// A capped reading column of ~700px on desktop; both modes should honour it.
+const READING_WIDTH = 700;
+
+const manga = {
+ id: MANGA_ID,
+ title: 'Sizing Test',
+ 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
+};
+
+const chapter = {
+ 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 1 is wide (1400×900), page 2 is a tall webtoon strip (800×4000).
+const pages = [
+ { id: 'p1', chapter_id: CHAPTER_ID, page_number: 1, storage_key: 'p/wide', content_type: 'image/svg+xml' },
+ { id: 'p2', chapter_id: CHAPTER_ID, page_number: 2, storage_key: 'p/tall', content_type: 'image/svg+xml' }
+];
+
+// An SVG with an explicit width/height gives the
a real intrinsic
+// size, so the rendered aspect ratio matches a genuine page image.
+function svg(w: number, h: number): string {
+ return ``;
+}
+
+async function mockReader(page: Page) {
+ // Single dispatcher over the whole API surface — takes precedence over
+ // the fixture's 503 fallback and avoids route-glob precedence puzzles.
+ 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) });
+
+ // Images.
+ if (pathname.includes('/files/p/wide')) {
+ return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(1400, 900) });
+ }
+ if (pathname.includes('/files/p/tall')) {
+ return route.fulfill({ status: 200, contentType: 'image/svg+xml', body: svg(800, 4000) });
+ }
+
+ // Auth: public, anonymous.
+ 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' } });
+ }
+
+ // Reader data.
+ if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}/pages`)) {
+ return json(200, { pages });
+ }
+ if (pathname.endsWith(`/mangas/${MANGA_ID}/chapters/${CHAPTER_ID}`)) return json(200, chapter);
+ if (pathname.includes(`/mangas/${MANGA_ID}/chapters`)) {
+ return json(200, { items: [chapter], page: { limit: 200, offset: 0, total: 1 } });
+ }
+ if (pathname.endsWith(`/mangas/${MANGA_ID}`)) return json(200, manga);
+ if (pathname.includes(`/me/read-progress/${MANGA_ID}`)) {
+ return json(401, { error: { code: 'unauthenticated', message: 'no' } });
+ }
+
+ console.warn(`[e2e] reader spec unmocked: ${pathname}`);
+ return json(503, { error: { code: 'e2e_unmocked', message: pathname } });
+ });
+}
+
+test('continuous mode renders all pages at a consistent capped width; tall pages scroll', async ({ page }) => {
+ await page.addInitScript(() => localStorage.setItem('mangalord-reader-mode', 'continuous'));
+ await mockReader(page);
+
+ await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
+
+ const wide = page.getByTestId('reader-page-1');
+ const tall = page.getByTestId('reader-page-2');
+ await expect(wide).toBeVisible();
+ await expect(tall).toBeVisible();
+
+ const wideBox = await wide.boundingBox();
+ const tallBox = await tall.boundingBox();
+ const viewport = page.viewportSize();
+ if (!wideBox || !tallBox || !viewport) throw new Error('missing layout metrics');
+
+ // 1. Consistent width regardless of intrinsic dimensions.
+ expect(Math.abs(wideBox.width - tallBox.width)).toBeLessThanOrEqual(1);
+
+ // 2. Capped reading column on desktop (not full-bleed, not a stripe).
+ expect(wideBox.width).toBeGreaterThan(READING_WIDTH - 40);
+ expect(wideBox.width).toBeLessThanOrEqual(READING_WIDTH + 2);
+
+ // 3. The tall page extends past the viewport (scrolls, doesn't shrink).
+ expect(tallBox.height).toBeGreaterThan(viewport.height);
+});
+
+test('single mode renders a tall page at the capped width, not a thin stripe', async ({ page }) => {
+ await mockReader(page); // default mode is single
+
+ await page.goto(`/manga/${MANGA_ID}/chapter/${CHAPTER_ID}`);
+
+ const img = page.getByTestId('reader-page');
+ await expect(img).toBeVisible();
+
+ // Advance from the wide first page to the tall second page.
+ await page.getByTestId('reader-next').click();
+ await expect(img).toHaveAttribute('src', /tall/);
+
+ const box = await img.boundingBox();
+ const viewport = page.viewportSize();
+ if (!box || !viewport) throw new Error('missing layout metrics');
+
+ // Width is the capped reading column, not a sliver.
+ expect(box.width).toBeGreaterThan(READING_WIDTH - 40);
+ expect(box.width).toBeLessThanOrEqual(READING_WIDTH + 2);
+ // And the page runs past the viewport so the reader scrolls it.
+ expect(box.height).toBeGreaterThan(viewport.height);
+});
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9388149..cb6c27e 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "mangalord-frontend",
- "version": "0.94.0",
+ "version": "0.94.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mangalord-frontend",
- "version": "0.94.0",
+ "version": "0.94.1",
"devDependencies": {
"@lucide/svelte": "^1.16.0",
"@playwright/test": "^1.48.0",
diff --git a/frontend/package.json b/frontend/package.json
index b810075..49466ed 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
- "version": "0.94.0",
+ "version": "0.94.1",
"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 ea12a06..8b0de1b 100644
--- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
+++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte
@@ -1783,11 +1783,23 @@
written by the ResizeObserver in onMount so the reservation
always matches actual rendered height. Focus mode collapses
the reservation in lockstep with the bar's slide-out. */
+ /* Sizing is width-driven: every page renders at this consistent width
+ (a capped reading column on desktop, full width on narrow screens)
+ regardless of its intrinsic dimensions. Tall pages therefore extend
+ downward and scroll instead of shrinking to fit the viewport height —
+ which is what used to turn long vertical pages into thin stripes. */
+ .page-wrap,
+ .continuous {
+ --reader-page-width: min(100%, 700px);
+ }
+
.page-wrap {
display: grid;
grid-template-columns: auto 1fr auto;
gap: var(--space-2);
- align-items: center;
+ /* start (not center): a tall page's top must sit under the reader
+ nav so the reader begins at the top and scrolls down. */
+ align-items: start;
padding-top: var(--reader-nav-h);
transition: padding-top 220ms ease-out;
}
@@ -1811,8 +1823,8 @@
}
.page-image {
+ width: var(--reader-page-width);
max-width: 100%;
- max-height: 90vh;
height: auto;
margin: 0 auto;
display: block;
@@ -1829,12 +1841,10 @@
-webkit-user-select: none;
}
- .continuous .page-image {
- /* In continuous mode the user is scrolling — let each page take
- its natural height instead of capping at viewport height, so
- there are no scroll dead-zones inside a single page. */
- max-height: none;
- }
+ /* Continuous mode inherits the width-driven sizing above: every page is
+ the same width and takes its natural (uncapped) height, so there are
+ no scroll dead-zones inside a single page and widths stay consistent
+ between a narrow scan and a wide one. */
.nav {
display: inline-flex;
@@ -1851,6 +1861,12 @@
transition:
background var(--transition),
border-color var(--transition);
+ /* Now that the page-wrap grid is top-aligned, a tall page makes its
+ row much taller than the viewport. Stick the prev/next chevrons to
+ the vertical middle of the viewport so they stay reachable while
+ the reader scrolls down a long page. */
+ position: sticky;
+ top: calc(50vh - 22px);
}
/* ===== Continuous-mode chapter bar (sticky bottom) =====