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