fix(reader): render pages at a consistent width instead of thin stripes

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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-04 20:44:30 +02:00
parent 38146b4d03
commit cf8971faae
6 changed files with 185 additions and 17 deletions

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.94.0"
version = "0.94.1"
edition = "2021"
default-run = "mangalord"

View File

@@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="400"><rect width="1400" height="400" fill="#888"/></svg>`;
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 })
);
}

View File

@@ -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 <img> a real intrinsic
// size, so the rendered aspect ratio matches a genuine page image.
function svg(w: number, h: number): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect width="${w}" height="${h}" fill="#888"/></svg>`;
}
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);
});

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.94.0",
"version": "0.94.1",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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) =====