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

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>