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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user