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:
52
frontend/src/lib/components/TapZone.svelte.test.ts
Normal file
52
frontend/src/lib/components/TapZone.svelte.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user