feat(frontend): mobile reader with tap zones + settings sheet (0.59.0)

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 <select>, mode toggle, and gap field stay
  inline above 640px; below 640px they swap to a chapter-jump
  button and a settings ⋯ button hosting the Sheets.
- Chapter jump Sheet lists every chapter with the current one
  highlighted; tapping a row navigates and dismisses.
- Reader settings Sheet uses the SegmentedControl primitive for mode
  and (continuous-only) page gap. Brightness lives next to them as a
  range slider 0.3..1; its value publishes `--reader-dim` as a CSS
  variable that drives the always-rendered fixed dim overlay
  (pointer-events: none so taps fall through). Brightness is
  client-side only in localStorage — the Preferences table doesn't
  carry it and Phase 4 deferred a backend migration.
- Idle auto-hide: 3s timer scoped to mobile + single mode, reuses
  the existing focus-mode CSS for the actual slide-off. The timer
  resets on every index/mode/viewport/fullscreen change via a tracked
  $effect.
- Bottom page scrubber: a styled <input type="range"> 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) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-07 11:22:54 +02:00
parent 780632bee3
commit f5842510b7
7 changed files with 872 additions and 13 deletions

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.58.0"
version = "0.59.0"
dependencies = [
"anyhow",
"argon2",

View File

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

View File

@@ -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();
});
});

View File

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

View File

@@ -0,0 +1,70 @@
<script lang="ts">
let {
onPrev,
onNext,
onToggle,
testid = 'tap-zone'
}: {
onPrev: () => void;
onNext: () => void;
onToggle: () => void;
testid?: string;
} = $props();
</script>
<div class="tap-zones" data-testid={testid}>
<button
type="button"
class="zone left"
onclick={onPrev}
aria-label="Previous page"
data-testid="{testid}-left"
></button>
<button
type="button"
class="zone center"
onclick={onToggle}
aria-label="Toggle controls"
data-testid="{testid}-center"
></button>
<button
type="button"
class="zone right"
onclick={onNext}
aria-label="Next page"
data-testid="{testid}-right"
></button>
</div>
<style>
/* Invisible viewport overlay split into thirds. Mirrors the Mihon
/ Tachiyomi convention: left-third = prev, center-third = toggle
chrome, right-third = next. `pointer-events: none` on the wrap
so unrelated clicks (chrome buttons, sheets) aren't trapped;
each zone re-enables pointer events locally. */
.tap-zones {
position: fixed;
inset: 0;
z-index: 5;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
pointer-events: none;
}
.zone {
pointer-events: auto;
background: transparent;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
/* No visible style — these are interaction surfaces, not
controls. Focus-visible is honored so keyboard users get
a ring if they tab into one. */
}
.zone:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -4px;
}
</style>

View File

@@ -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();
});
});

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { goto, invalidateAll } from '$app/navigation';
import { fileUrl, ApiError } from '$lib/api/client';
import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
@@ -9,6 +10,9 @@
import { resyncChapter } from '$lib/api/admin';
import { readerFullscreen } from '$lib/reader-fullscreen.svelte';
import { session } from '$lib/session.svelte';
import Sheet from '$lib/components/Sheet.svelte';
import SegmentedControl from '$lib/components/SegmentedControl.svelte';
import TapZone from '$lib/components/TapZone.svelte';
import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
@@ -18,6 +22,9 @@
import Maximize2 from '@lucide/svelte/icons/maximize-2';
import Minimize2 from '@lucide/svelte/icons/minimize-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Settings from '@lucide/svelte/icons/settings';
import BookOpen from '@lucide/svelte/icons/book-open';
import Sun from '@lucide/svelte/icons/sun';
let { data } = $props();
const manga = $derived(data.manga);
@@ -71,6 +78,69 @@
let chapterBarEl: HTMLElement | undefined = $state();
let readerNavEl: HTMLElement | undefined = $state();
// ---- Mobile reader state ----
//
// matchMedia drives every mobile-specific behavior (tap zones, idle
// auto-hide, sheets vs. inline controls). Without it we'd render
// the desktop nav AND the mobile bottom sheet hosts at the same
// time and double up testids / focus traps. SSR default is false.
let isMobileViewport = $state(false);
let chapterJumpOpen = $state(false);
let settingsOpen = $state(false);
// Brightness overlay — 1.0 is no dimming, 0.3 is maximum (70% black
// overlay opacity). Stored in localStorage only; the Preferences
// table doesn't carry this field and Phase 4 explicitly opted to
// keep it client-side rather than ship a backend migration. Init
// is deferred to a $effect so SSR doesn't touch localStorage.
const BRIGHTNESS_KEY = 'mangalord-reader-brightness';
let brightness = $state(1);
// Opt into the layout's edge-to-edge mode so the reader chrome
// (which is `position: fixed` and pins itself to the viewport top)
// doesn't sit under main's mobile padding reservation. Cleaned up
// on navigation away.
$effect(() => {
if (!browser) return;
document.documentElement.dataset.mobileFullBleed = 'true';
return () => {
delete document.documentElement.dataset.mobileFullBleed;
};
});
$effect(() => {
if (!browser) return;
const mql = window.matchMedia('(max-width: 640px)');
isMobileViewport = mql.matches;
const onChange = (e: MediaQueryListEvent) => {
isMobileViewport = e.matches;
};
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
});
onMount(() => {
const raw = localStorage.getItem(BRIGHTNESS_KEY);
const v = raw == null ? NaN : Number.parseFloat(raw);
if (Number.isFinite(v) && v >= 0.3 && v <= 1) brightness = v;
});
// Publish the dim level as a CSS variable on <html>. (1 - brightness)
// gives 0..0.7 alpha for the fixed overlay rendered at the bottom
// of the template. Persisting on every change keeps the
// localStorage shadow in sync with what the user sees.
$effect(() => {
if (!browser) return;
const dim = (1 - brightness) * 0.7;
document.documentElement.style.setProperty('--reader-dim', String(dim));
localStorage.setItem(BRIGHTNESS_KEY, String(brightness));
return () => {
// On unmount (leaving the reader), clear the dim so other
// routes don't inherit a darkened overlay.
document.documentElement.style.removeProperty('--reader-dim');
};
});
// Publish the reader nav's actual measured height. Sticky
// positioning had a "settle on scroll" effect: the bar's natural
// position sat 16px below the app header (main's space-4 padding),
@@ -121,6 +191,49 @@
};
});
// ---- Mobile chrome auto-hide ----
//
// After 3s of inactivity on mobile + single mode, slide the reader
// chrome off (existing focus-mode CSS does the actual transform).
// Every index change, mode change, viewport change, or fullscreen
// toggle restarts the countdown via the $effect below.
let idleTimer: ReturnType<typeof setTimeout> | null = null;
function clearIdleTimer() {
if (idleTimer) {
clearTimeout(idleTimer);
idleTimer = null;
}
}
function startIdleTimer() {
clearIdleTimer();
if (!isMobileViewport) return;
if (mode !== 'single') return;
if (readerFullscreen.value) return; // already hidden
idleTimer = setTimeout(() => {
readerFullscreen.value = true;
}, 3000);
}
function toggleChrome() {
readerFullscreen.value = !readerFullscreen.value;
// If we just revealed the chrome, restart the countdown — the
// user just tapped, but next 3s of stillness re-hides.
if (!readerFullscreen.value) startIdleTimer();
}
$effect(() => {
// Track the inputs that should restart the idle countdown.
// Reading them here creates the reactive dependencies.
void index;
void mode;
void isMobileViewport;
void readerFullscreen.value;
startIdleTimer();
return clearIdleTimer;
});
// ---- Navigation ----
//
// In single mode: page-by-page within the chapter, falling through
@@ -457,7 +570,7 @@
</a>
<div class="controls" role="group" aria-label="reader options">
<label class="chapter-field">
<label class="chapter-field desktop-control">
<span class="visually-hidden">Jump to chapter</span>
<select
class="chapter-select"
@@ -478,7 +591,18 @@
</select>
</label>
<div class="mode-toggle" role="radiogroup" aria-label="layout">
<button
type="button"
class="chapter-jump-btn mobile-control"
onclick={() => (chapterJumpOpen = true)}
aria-label="Jump to chapter"
data-testid="reader-chapter-jump"
>
<BookOpen size={16} aria-hidden="true" />
<span class="chapter-jump-label">{chapterLabel(chapter)}</span>
</button>
<div class="mode-toggle desktop-control" role="radiogroup" aria-label="layout">
<button
type="button"
class="seg"
@@ -506,7 +630,7 @@
</div>
{#if mode === 'continuous'}
<label class="gap-field">
<label class="gap-field desktop-control">
<span class="visually-hidden">Page gap</span>
<select
value={preferences.readerPageGap}
@@ -522,6 +646,16 @@
</label>
{/if}
<button
type="button"
class="reader-settings-btn mobile-control"
onclick={() => (settingsOpen = true)}
aria-label="Reader settings"
data-testid="reader-settings-btn"
>
<Settings size={16} aria-hidden="true" />
</button>
</div>
<span class="indicator" data-testid="page-indicator">
@@ -702,6 +836,124 @@
</div>
{/if}
<!-- Brightness dim overlay. Always rendered — opacity is driven by the
CSS var the script publishes, so the cost of leaving it in the
DOM is one zero-opacity div when brightness is at max. -->
<div class="brightness-overlay" aria-hidden="true"></div>
<!-- Tap zones — mobile + single mode only. Continuous mode owns native
scroll so left/right would steal panning. Tap left/right advances
within the chapter (falling through to adjacent chapters at the
boundaries via the existing prev/next helpers); tap center toggles
the focus-mode chrome and restarts the idle timer. -->
{#if isMobileViewport && mode === 'single' && pages.length > 0}
<TapZone onPrev={prev} onNext={next} onToggle={toggleChrome} testid="reader-tap" />
{/if}
<!-- Bottom scrubber — mobile + single + multi-page only. Lives above
the home-indicator inset and slides off with the rest of the
chrome when focus mode hides everything. -->
{#if isMobileViewport && mode === 'single' && pages.length > 1}
<div class="mobile-scrubber" data-testid="reader-scrubber-bar">
<input
type="range"
class="scrubber"
min="0"
max={pages.length - 1}
value={index}
oninput={(e) => {
index = Number((e.currentTarget as HTMLInputElement).value);
}}
aria-label="Page"
data-testid="reader-scrubber"
/>
<span class="scrubber-count">{index + 1} / {pages.length}</span>
</div>
{/if}
<Sheet
open={chapterJumpOpen}
title="Jump to chapter"
onClose={() => (chapterJumpOpen = false)}
testid="chapter-jump-sheet"
>
<ul class="chapter-jump-list">
{#each chapters as c (c.id)}
<li>
<a
href="/manga/{manga.id}/chapter/{c.id}"
class="chapter-jump-item"
class:current={c.id === chapter.id}
onclick={() => (chapterJumpOpen = false)}
data-testid="chapter-jump-{c.id}"
>
{chapterLabel(c)}
{#if c.id === chapter.id}<span class="chapter-jump-current-mark">· current</span>{/if}
</a>
</li>
{/each}
</ul>
</Sheet>
<Sheet
open={settingsOpen}
title="Reader settings"
onClose={() => (settingsOpen = false)}
testid="reader-settings-sheet"
>
<div class="settings-group">
<span class="settings-label">Mode</span>
<SegmentedControl
ariaLabel="Reader mode"
value={mode}
options={[
{ label: 'Single', value: 'single' as const },
{ label: 'Continuous', value: 'continuous' as const }
]}
onchange={(v) => preferences.setMode(v)}
testid="settings-mode"
/>
</div>
{#if mode === 'continuous'}
<div class="settings-group">
<span class="settings-label">Page gap</span>
<SegmentedControl
ariaLabel="Page gap"
value={preferences.readerPageGap}
options={[
{ label: 'None', value: 'none' as const },
{ label: 'Small', value: 'small' as const },
{ label: 'Medium', value: 'medium' as const },
{ label: 'Large', value: 'large' as const }
]}
onchange={(v) => preferences.setGap(v)}
testid="settings-gap"
/>
</div>
{/if}
<div class="settings-group">
<span class="settings-label">
<Sun size={16} aria-hidden="true" />
Brightness
</span>
<input
type="range"
class="brightness-slider"
min="0.3"
max="1"
step="0.05"
value={brightness}
oninput={(e) => {
brightness = Number((e.currentTarget as HTMLInputElement).value);
}}
aria-label="Brightness"
data-testid="settings-brightness"
/>
</div>
</Sheet>
<style>
/* Pinned to the viewport directly below the (also fixed) layout
header. `position: fixed` rather than `sticky` because the
@@ -1106,15 +1358,205 @@
}
@media (max-width: 640px) {
.page-wrap {
grid-template-columns: 1fr;
}
.nav {
grid-column: 1;
justify-self: center;
}
.seg span {
display: none;
}
}
/* ===== Phase 4 — Mobile reader chrome ===== */
.mobile-control {
display: none;
}
.brightness-overlay {
position: fixed;
inset: 0;
background: #000;
opacity: var(--reader-dim, 0);
pointer-events: none;
z-index: 4;
transition: opacity var(--transition);
}
.chapter-jump-list {
list-style: none;
margin: 0;
padding: 0;
max-height: 60vh;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.chapter-jump-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-3) var(--space-1);
color: var(--text);
text-decoration: none;
border-bottom: 1px solid var(--border);
min-height: 48px;
font-size: var(--font-base);
}
.chapter-jump-item:last-child {
border-bottom: 0;
}
.chapter-jump-item:hover {
background: var(--surface-elevated);
text-decoration: none;
}
.chapter-jump-item.current {
color: var(--primary);
font-weight: var(--weight-semibold);
}
.chapter-jump-current-mark {
color: var(--text-muted);
font-size: var(--font-xs);
font-weight: var(--weight-regular);
}
.settings-group {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.settings-group:last-child {
margin-bottom: 0;
}
.settings-label {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-muted);
font-size: var(--font-sm);
font-weight: var(--weight-medium);
}
.brightness-slider {
width: 100%;
}
@media (max-width: 640px) {
/* Reader-nav sits at the viewport top — the global mobile
chrome is hidden on this route so there's no app bar above
it. Top reservation comes from main being padding-top: 0
via data-mobile-full-bleed. */
.reader-nav {
top: 0;
padding: var(--space-2) var(--space-3);
gap: var(--space-2);
padding-top: calc(var(--space-2) + var(--safe-top));
}
/* The desktop chapter <select>, mode toggle, and gap selector
move into the chapter-jump and reader-settings sheets — the
native controls would be cramped at this width. */
.desktop-control {
display: none;
}
.mobile-control {
display: inline-flex;
}
.chapter-jump-btn {
display: inline-flex;
align-items: center;
gap: var(--space-2);
max-width: 60vw;
height: 36px;
padding: 0 var(--space-3);
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
font-size: var(--font-sm);
font-weight: var(--weight-medium);
cursor: pointer;
}
.chapter-jump-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.reader-settings-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
cursor: pointer;
}
/* Single-mode chevrons step aside on mobile — the TapZone
overlay carries prev/next/toggle. Keeping the buttons would
double up the click surface and clutter the screen. */
.page-wrap .nav {
display: none;
}
.page-wrap {
display: block;
padding: 0;
padding-top: var(--reader-nav-h);
}
.page-image {
/* Drop the desktop 90vh cap on mobile so each page fills
the available height naturally; the scrubber + chrome
eat their own room. */
max-height: none;
}
/* Scrubber bar: fixed bottom, mirrors the desktop chapter-bar's
slide-off behavior in focus mode. */
.mobile-scrubber {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-sticky);
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
padding-bottom: calc(var(--space-2) + var(--safe-bottom));
background: var(--surface);
border-top: 1px solid var(--border);
transform: translateY(0);
transition: transform 220ms ease-out;
}
:global(html[data-reader-fullscreen='true']) .mobile-scrubber {
transform: translateY(100%);
}
.scrubber {
flex: 1;
min-width: 0;
}
.scrubber-count {
color: var(--text-muted);
font-size: var(--font-sm);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
}
</style>