From cf8971faaeb646919e77eeb5b0d3eb204a466aa1 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sat, 4 Jul 2026 20:44:30 +0200 Subject: [PATCH] fix(reader): render pages at a consistent width instead of thin stripes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reader page sizing was height-driven — `max-height: 90vh` in single mode and unbounded natural width in continuous mode. A long vertical page hit the height cap first and, preserving aspect ratio, collapsed into a thin vertical stripe; narrow and wide scans rendered at different widths. Switch both modes to width-driven sizing via a capped `--reader-page-width` reading column (min(100%, 700px)): every page renders at the same width regardless of intrinsic dimensions, and tall pages extend downward and scroll instead of shrinking. Top-align the single-mode grid and make the prev/next chevrons sticky so they stay reachable on a long page. Add a Playwright spec asserting equal rendered width across differently- proportioned pages and that a tall page exceeds the viewport height. The page-context-menu fixture served a 1x1 image that now renders as a 700x700 square taller than the viewport, making Playwright scroll it into view before right-clicking — a synthetic scroll that trips the menu's by-design close-on-scroll. Give that fixture a realistic landscape aspect so the page fits the viewport, matching real user behaviour (no scroll on right-click). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/Cargo.toml | 2 +- frontend/e2e/page-context-menu.spec.ts | 13 +- frontend/e2e/reader-page-sizing.spec.ts | 149 ++++++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../[id]/chapter/[chapter_id]/+page.svelte | 32 +++- 6 files changed, 185 insertions(+), 17 deletions(-) create mode 100644 frontend/e2e/reader-page-sizing.spec.ts 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) =====