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:
70
frontend/src/lib/components/TapZone.svelte
Normal file
70
frontend/src/lib/components/TapZone.svelte
Normal 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>
|
||||
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