From f5842510b7ab4e480d29135ce553b2234cf4eb7a Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 7 Jun 2026 11:22:54 +0200 Subject: [PATCH] feat(frontend): mobile reader with tap zones + settings sheet (0.59.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the mobile redesign: the reader gets a mobile-native interaction model — invisible tap zones for prev / next / toggle, a chapter-jump bottom sheet, a reader settings sheet (mode / gap / brightness), a fixed bottom page scrubber, a brightness overlay driven by a CSS variable, and an idle-timer auto-hide for the chrome after 3s of inactivity. Desktop keyboard shortcuts and chevrons are preserved above 640px. - New TapZone primitive: invisible 3-column grid that splits the viewport into prev / toggle / next thirds (Mihon convention). Wired to the existing `prev()` / `next()` / new `toggleChrome()` so chapter-edge behavior, page preload, and read-progress tracking all keep working. Has its own vitest coverage. - matchMedia gates every mobile addition so the same DOM never carries two copies of the chapter selector or two sets of nav controls — the desktop at the viewport foot, single + multi-page only, sliding off in focus mode alongside the chrome. Honors env(safe-area-inset-bottom). - Reader joins the layout's `data-mobile-full-bleed` attribute so the hero/top reader-nav can sit at viewport top under main's cleared padding-top on mobile. - Playwright spec covers tap left/right/center, the 3s auto-hide, the chapter-jump sheet, the settings sheet swapping to continuous, the brightness slider driving `--reader-dim`, and a desktop regression for the unchanged inline controls. Co-Authored-By: Claude Opus 4.7 (1M context) --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- frontend/e2e/mobile-reader.spec.ts | 295 +++++++++++ frontend/package.json | 2 +- frontend/src/lib/components/TapZone.svelte | 70 +++ .../src/lib/components/TapZone.svelte.test.ts | 52 ++ .../[id]/chapter/[chapter_id]/+page.svelte | 462 +++++++++++++++++- 7 files changed, 872 insertions(+), 13 deletions(-) create mode 100644 frontend/e2e/mobile-reader.spec.ts create mode 100644 frontend/src/lib/components/TapZone.svelte create mode 100644 frontend/src/lib/components/TapZone.svelte.test.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 83252c0..09b01fa 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.58.0" +version = "0.59.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 24a0273..83607b3 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.58.0" +version = "0.59.0" edition = "2021" default-run = "mangalord" diff --git a/frontend/e2e/mobile-reader.spec.ts b/frontend/e2e/mobile-reader.spec.ts new file mode 100644 index 0000000..a76e734 --- /dev/null +++ b/frontend/e2e/mobile-reader.spec.ts @@ -0,0 +1,295 @@ +import { test, expect, type Page } from '@playwright/test'; + +// Phase 4: the reader gains a mobile chrome — invisible tap zones for +// prev/next/toggle, a chapter-jump bottom sheet, a reader-settings +// sheet (mode + gap + brightness), a fixed bottom page scrubber, a +// brightness overlay driven by a CSS variable, and an idle-timer +// auto-hide for the chrome after 3s of inactivity. Desktop chrome is +// preserved above 640px. + +const MOBILE = { width: 390, height: 844 } as const; +const DESKTOP = { width: 1280, height: 720 } as const; + +const mangaId = 'a9999999-9999-9999-9999-999999999999'; +const chapterAId = 'c9999999-9999-9999-9999-999999999999'; +const chapterBId = 'c8888888-8888-8888-8888-888888888888'; + +const mangaFixture = { + id: mangaId, + title: 'Berserk', + status: 'ongoing', + alt_titles: [], + description: null, + cover_image_path: `mangas/${mangaId}/cover.png`, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + authors: [{ id: 'au1', name: 'Kentaro Miura' }], + genres: [], + tags: [] +}; + +const chaptersFixture = [ + { + id: chapterAId, + manga_id: mangaId, + number: 1, + title: 'The Brand', + page_count: 3, + created_at: '2026-01-01T00:00:00Z' + }, + { + id: chapterBId, + manga_id: mangaId, + number: 2, + title: 'Second', + page_count: 3, + created_at: '2026-02-01T00:00:00Z' + } +]; + +const pagesFixture = [ + { + id: 'p11111111-1111-1111-1111-111111111111', + chapter_id: chapterAId, + page_number: 1, + storage_key: `mangas/${mangaId}/chapters/${chapterAId}/pages/0001.png`, + content_type: 'image/png' + }, + { + id: 'p22222222-1111-1111-1111-111111111111', + chapter_id: chapterAId, + page_number: 2, + storage_key: `mangas/${mangaId}/chapters/${chapterAId}/pages/0002.png`, + content_type: 'image/png' + }, + { + id: 'p33333333-1111-1111-1111-111111111111', + chapter_id: chapterAId, + page_number: 3, + storage_key: `mangas/${mangaId}/chapters/${chapterAId}/pages/0003.png`, + content_type: 'image/png' + } +]; + +async function mockReader(page: Page) { + await page.route('**/api/v1/auth/config', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ self_register_enabled: true, private_mode: false }) + }) + ); + await page.route('**/api/v1/auth/me', (route) => + route.fulfill({ + status: 401, + contentType: 'application/json', + body: JSON.stringify({ + error: { code: 'unauthenticated', message: 'unauthenticated' } + }) + }) + ); + await page.route('**/api/v1/auth/me/preferences', (route) => + route.fulfill({ status: 401, contentType: 'application/json', body: '{}' }) + ); + await page.route('**/api/v1/me/bookmarks*', (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ items: [], page: { limit: 50, offset: 0, total: 0 } }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(mangaFixture) + }) + ); + // Pages endpoint must come before the single-chapter handler + // because Playwright matches more recently-registered routes + // first — registering pages last keeps it on top. + await page.route(`**/api/v1/mangas/${mangaId}/chapters`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: chaptersFixture, + page: { limit: 50, offset: 0, total: chaptersFixture.length } + }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters\\?*`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + items: chaptersFixture, + page: { limit: 50, offset: 0, total: chaptersFixture.length } + }) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterAId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(chaptersFixture[0]) + }) + ); + await page.route(`**/api/v1/mangas/${mangaId}/chapters/${chapterBId}`, (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(chaptersFixture[1]) + }) + ); + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterAId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: pagesFixture }) + }) + ); + await page.route( + `**/api/v1/mangas/${mangaId}/chapters/${chapterBId}/pages`, + (route) => + route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ pages: pagesFixture }) + }) + ); + await page.route(`**/api/v1/me/read-progress/${mangaId}`, (route) => + route.fulfill({ + status: 404, + contentType: 'application/json', + body: JSON.stringify({ error: { code: 'not_found', message: 'not found' } }) + }) + ); + const png = Buffer.from( + '89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000d49444154789c63000100000005000158a3b62a0000000049454e44ae426082', + 'hex' + ); + await page.route('**/api/v1/files/**', (route) => + route.fulfill({ status: 200, contentType: 'image/png', body: png }) + ); +} + +test.describe('mobile reader', () => { + test('phone viewport: tap right advances a page, tap left goes back', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3'); + + await page.getByTestId('reader-tap-right').click(); + await expect(page.getByTestId('page-indicator')).toHaveText('Page 2 / 3'); + + await page.getByTestId('reader-tap-left').click(); + await expect(page.getByTestId('page-indicator')).toHaveText('Page 1 / 3'); + }); + + test('phone viewport: tap center toggles focus mode (chrome slides off)', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + const html = page.locator('html'); + await expect(html).not.toHaveAttribute('data-reader-fullscreen', 'true'); + + await page.getByTestId('reader-tap-center').click(); + await expect(html).toHaveAttribute('data-reader-fullscreen', 'true'); + + await page.getByTestId('reader-tap-center').click(); + await expect(html).not.toHaveAttribute('data-reader-fullscreen', 'true'); + }); + + test('phone viewport: chrome auto-hides after 3s of inactivity in single mode', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + const html = page.locator('html'); + // Default state: chrome visible. + await expect(html).not.toHaveAttribute('data-reader-fullscreen', 'true'); + // After 3s of idle the timer flips the fullscreen flag (the + // existing focus-mode CSS does the slide-off). + await expect(html).toHaveAttribute('data-reader-fullscreen', 'true', { + timeout: 6000 + }); + }); + + test('phone viewport: chapter-jump button opens the sheet and lists all chapters', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + await expect(page.getByTestId('chapter-jump-sheet')).toBeHidden(); + await page.getByTestId('reader-chapter-jump').click(); + + const sheet = page.getByTestId('chapter-jump-sheet'); + await expect(sheet).toBeVisible(); + await expect(sheet.getByTestId(`chapter-jump-${chapterAId}`)).toBeVisible(); + await expect(sheet.getByTestId(`chapter-jump-${chapterBId}`)).toBeVisible(); + }); + + test('phone viewport: settings sheet swaps mode to Continuous and the continuous container appears', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + await page.getByTestId('reader-settings-btn').click(); + await expect(page.getByTestId('reader-settings-sheet')).toBeVisible(); + + await page + .getByTestId('reader-settings-sheet') + .getByRole('radio', { name: 'Continuous' }) + .click(); + + await expect(page.getByTestId('reader-continuous')).toBeVisible(); + }); + + test('phone viewport: brightness slider drives the --reader-dim CSS variable', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(MOBILE); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + await page.getByTestId('reader-settings-btn').click(); + + // Drive the slider via fill — Playwright's fill on a range + // input sets value and dispatches input events. + await page.getByTestId('settings-brightness').fill('0.5'); + + const dim = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--reader-dim').trim() + ); + // brightness 0.5 → dim = (1 - 0.5) * 0.7 = 0.35 + expect(Number.parseFloat(dim)).toBeCloseTo(0.35, 2); + }); + + test('desktop viewport: tap zones hidden, chapter select visible, settings button hidden', async ({ + page + }) => { + await mockReader(page); + await page.setViewportSize(DESKTOP); + await page.goto(`/manga/${mangaId}/chapter/${chapterAId}`); + + await expect(page.getByTestId('reader-tap')).toHaveCount(0); + await expect(page.getByTestId('reader-chapter-select')).toBeVisible(); + await expect(page.getByTestId('reader-settings-btn')).toBeHidden(); + }); +}); diff --git a/frontend/package.json b/frontend/package.json index 32b9388..432228f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.58.0", + "version": "0.59.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/TapZone.svelte b/frontend/src/lib/components/TapZone.svelte new file mode 100644 index 0000000..f7e34a0 --- /dev/null +++ b/frontend/src/lib/components/TapZone.svelte @@ -0,0 +1,70 @@ + + +
+ + + +
+ + diff --git a/frontend/src/lib/components/TapZone.svelte.test.ts b/frontend/src/lib/components/TapZone.svelte.test.ts new file mode 100644 index 0000000..ea0faa8 --- /dev/null +++ b/frontend/src/lib/components/TapZone.svelte.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/svelte'; +import TapZone from './TapZone.svelte'; + +afterEach(() => cleanup()); + +describe('TapZone', () => { + it('renders three labelled buttons (Previous / Toggle / Next)', () => { + render(TapZone, { + props: { onPrev: () => {}, onNext: () => {}, onToggle: () => {} } + }); + expect(screen.getByRole('button', { name: 'Previous page' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Toggle controls' })).toBeTruthy(); + expect(screen.getByRole('button', { name: 'Next page' })).toBeTruthy(); + }); + + it('fires onPrev when the left zone is tapped', () => { + const onPrev = vi.fn(); + render(TapZone, { props: { onPrev, onNext: () => {}, onToggle: () => {} } }); + screen.getByRole('button', { name: 'Previous page' }).click(); + expect(onPrev).toHaveBeenCalledOnce(); + }); + + it('fires onNext when the right zone is tapped', () => { + const onNext = vi.fn(); + render(TapZone, { props: { onPrev: () => {}, onNext, onToggle: () => {} } }); + screen.getByRole('button', { name: 'Next page' }).click(); + expect(onNext).toHaveBeenCalledOnce(); + }); + + it('fires onToggle when the center zone is tapped', () => { + const onToggle = vi.fn(); + render(TapZone, { props: { onPrev: () => {}, onNext: () => {}, onToggle } }); + screen.getByRole('button', { name: 'Toggle controls' }).click(); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it('exposes zone-level testids derived from the wrapper testid', () => { + render(TapZone, { + props: { + onPrev: () => {}, + onNext: () => {}, + onToggle: () => {}, + testid: 'reader-tap' + } + }); + expect(screen.getByTestId('reader-tap')).toBeTruthy(); + expect(screen.getByTestId('reader-tap-left')).toBeTruthy(); + expect(screen.getByTestId('reader-tap-center')).toBeTruthy(); + expect(screen.getByTestId('reader-tap-right')).toBeTruthy(); + }); +}); 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 3699472..27bad73 100644 --- a/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte +++ b/frontend/src/routes/manga/[id]/chapter/[chapter_id]/+page.svelte @@ -1,5 +1,6 @@