<script lang="ts">
    import { onMount, onDestroy } from 'svelte';
    import { browser } from '$app/environment';
    import { afterNavigate, goto, invalidateAll } from '$app/navigation';
    import { fileUrl, ApiError } from '$lib/api/client';
    import { GAP_PX, type ReaderPageGap } from '$lib/api/preferences';
    import { preferences } from '$lib/preferences.svelte';
    import { updateReadProgress } from '$lib/api/read_progress';
    import { chapterLabel, getChapterPages } from '$lib/api/chapters';
    import { resyncChapter, analyzePage } 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 AdaptiveDialog from '$lib/components/AdaptiveDialog.svelte';
    import SegmentedControl from '$lib/components/SegmentedControl.svelte';
    import TapZone from '$lib/components/TapZone.svelte';
    import PageContextMenu from '$lib/components/PageContextMenu.svelte';
    import AddTagsSheet from '$lib/components/AddTagsSheet.svelte';
    import AddToCollectionModal from '$lib/components/AddToCollectionModal.svelte';
    import { getMyCollectionsContainingPage } from '$lib/api/page_collections';
    import { getMyTagsForPage } from '$lib/api/page_tags';
    import FolderPlus from '@lucide/svelte/icons/folder-plus';
    import Tag from '@lucide/svelte/icons/tag';
    import Download from '@lucide/svelte/icons/download';
    import Link2 from '@lucide/svelte/icons/link-2';
    import ChevronLeft from '@lucide/svelte/icons/chevron-left';
    import ChevronRight from '@lucide/svelte/icons/chevron-right';
    import ArrowLeft from '@lucide/svelte/icons/arrow-left';
    import BookImage from '@lucide/svelte/icons/book-image';
    import FileText from '@lucide/svelte/icons/file-text';
    import ScrollText from '@lucide/svelte/icons/scroll-text';
    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);
    const chapter = $derived(data.chapter);
    const pages = $derived(data.pages);
    const chapters = $derived(data.chapters);

    const mode = $derived(preferences.readerMode);
    const gapPx = $derived(GAP_PX[preferences.readerPageGap]);

    const pageTitle = $derived(
        `Mangalord | ${manga.title} · ${chapterLabel(chapter)}`
    );

    // Prev/next chapter computed from the chapter list. listChapters
    // returns chapters in display order (reversed source-site order, so
    // oldest first — see backend repo::chapter::list_for_manga), and
    // prev/next walks that order positionally. Resolving the current
    // index via `find` rather than `chapter.number - 1` matters because
    // numbers aren't a reliable index: variants share numbers, non-
    // numeric entries pin to 0, and uploads can sparse-fill.
    const currentIdx = $derived(
        chapters.findIndex((c) => c.id === chapter.id)
    );
    const prevChapter = $derived(
        currentIdx > 0 ? chapters[currentIdx - 1] : null
    );
    const nextChapter = $derived(
        currentIdx >= 0 && currentIdx < chapters.length - 1
            ? chapters[currentIdx + 1]
            : null
    );

    // Initial page index from `?page=`. Numeric values are 1-indexed
    // and clamped to the chapter's page count; the sentinel `last`
    // lands on the final page (used by the prev-chapter chevron when
    // going backwards through the series).
    //
    // `$derived` so it tracks `data.requestedPage` / `data.pages`
    // across chapter navigation — SvelteKit reuses the component on
    // same-route nav (the chevrons at `jumpToPrevChapter` /
    // `jumpToNextChapter` below call `goto(...)` to navigate between
    // chapters), and a `const` would freeze initialIndex at the
    // first chapter's value.
    const initialIndex = $derived.by(() => {
        const req = data.requestedPage;
        if (req === 'last') return Math.max(0, data.pages.length - 1);
        if (typeof req === 'number') {
            return Math.min(Math.max(0, req - 1), Math.max(0, data.pages.length - 1));
        }
        return 0;
    });
    // svelte-ignore state_referenced_locally
    let index = $state(initialIndex);
    let continuousPageEls: HTMLImageElement[] = $state([]);

    // ---- Continuous-mode eager window ----
    //
    // Eager-load a leading window of pages, lazy-load the rest. A chapter can
    // hold up to 2000 pages; making every one `loading="eager"` (the previous
    // behaviour) fired the whole chapter's requests up front and kept every
    // decoded image resident — enough to OOM a mobile tab on a long chapter.
    //
    // The window is `[0 ..= eagerThrough]`. It always covers `0..=initialIndex`
    // so the cold-load scroll-to-`?page=N` effect still has settled heights for
    // the target and everything above it (the whole reason pages were eager),
    // plus a small LEAD so the next pages are warm, and never fewer than
    // EAGER_MIN so short chapters stay fully eager. Pages beyond the window are
    // `loading="lazy"` and the browser fetches them as the reader scrolls near;
    // unloaded lazy pages reserve height via CSS (`.page-image[data-reserve]`)
    // so they stay distinct scroll targets for the progress IntersectionObserver.
    const EAGER_LEAD = 3;
    const EAGER_MIN = 5;
    const eagerThrough = $derived(Math.max(initialIndex + EAGER_LEAD, EAGER_MIN));

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

    // ---- Page context menu / tag + collection actions ----
    //
    // Desktop: right-click on a `.page-image` opens the floating
    // `PageContextMenu`. Mobile: long-press (via TapZone in single
    // mode, or per-image timer in continuous mode) opens an action
    // sheet that funnels into the same modals. Unauthenticated users
    // see neither — there's nothing for them to act on.
    let shortcutsOpen = $state(false);
    let contextMenuOpen = $state(false);
    let contextMenuAnchor = $state<{ x: number; y: number }>({ x: 0, y: 0 });
    let activePageId = $state<string | null>(null);
    let activePageCollectionCount = $state<number | null>(null);
    let activePageTags = $state<string[]>([]);
    let actionSheetOpen = $state(false);
    let collectionsModalOpen = $state(false);
    let tagsModalOpen = $state(false);
    // Admin-only "Queue for analysis" action feedback, reset per page.
    let analyzeState = $state<'idle' | 'busy' | 'done' | 'error'>('idle');

    // Monotonically-increasing token so a slow loadPageSummary for
    // page A can't clobber a fresh load for page B. The user right-
    // clicking one image then another on a slow network is the
    // scenario; without the token the older request's resolution
    // wins because it lands last.
    let loadPageSummaryToken = 0;

    async function loadPageSummary(pageId: string) {
        const token = ++loadPageSummaryToken;
        activePageCollectionCount = null;
        activePageTags = [];
        try {
            const [ids, tags] = await Promise.all([
                getMyCollectionsContainingPage(pageId),
                getMyTagsForPage(pageId)
            ]);
            if (token !== loadPageSummaryToken) return;
            activePageCollectionCount = ids.length;
            activePageTags = tags;
        } catch {
            // Soft-fail — context lines just stay empty / loading.
        }
    }

    function openContextMenu(anchor: { x: number; y: number }, pageId: string) {
        if (!session.user) return;
        activePageId = pageId;
        contextMenuAnchor = anchor;
        contextMenuOpen = true;
        analyzeState = 'idle';
        void loadPageSummary(pageId);
    }

    async function handleAnalyzePage() {
        const pageId = activePageId;
        if (!pageId || !session.user?.is_admin || analyzeState === 'busy') return;
        analyzeState = 'busy';
        try {
            await analyzePage(pageId);
            analyzeState = 'done';
        } catch {
            analyzeState = 'error';
        }
    }

    function openActionSheet(pageId: string) {
        if (!session.user) return;
        activePageId = pageId;
        actionSheetOpen = true;
        analyzeState = 'idle';
        void loadPageSummary(pageId);
    }

    function onPageContextMenu(e: MouseEvent, pageId: string) {
        if (!session.user) return;
        // Power-user escape hatch — Shift + right-click drops to the
        // native browser context menu (image save, copy, inspect).
        if (e.shiftKey) return;
        e.preventDefault();
        openContextMenu({ x: e.clientX, y: e.clientY }, pageId);
    }

    function handleAddToCollection() {
        contextMenuOpen = false;
        actionSheetOpen = false;
        collectionsModalOpen = true;
    }

    function handleAddTag() {
        contextMenuOpen = false;
        actionSheetOpen = false;
        tagsModalOpen = true;
    }

    function activePageNumber(): number | null {
        if (!activePageId) return null;
        const i = pages.findIndex((p) => p.id === activePageId);
        return i >= 0 ? i + 1 : null;
    }

    function activeStorageKey(): string | null {
        if (!activePageId) return null;
        return pages.find((p) => p.id === activePageId)?.storage_key ?? null;
    }

    function handleSaveImage() {
        const key = activeStorageKey();
        contextMenuOpen = false;
        actionSheetOpen = false;
        if (!key) return;
        // Open in a new tab — the browser surfaces its native "Save
        // image as" / long-press save from there. `download` on an
        // anchor would force the disk-save dialog, which is more
        // direct but loses the in-tab preview some users prefer.
        window.open(fileUrl(key), '_blank', 'noopener');
    }

    // Two-state pill: either "Link copied" (clipboard write OK) or a
    // selectable URL pill (clipboard unavailable — insecure context,
    // permissions denied). Auto-clears via timer; the failure pill
    // sticks around for 10s (vs. 1.6s for success) so the user has
    // time to long-press → Copy on mobile without the pill vanishing
    // mid-gesture. The timer is torn down on chapter navigation so
    // a stale fire doesn't try to write post-unmount state.
    let linkCopiedState = $state<
        { kind: 'hidden' } | { kind: 'ok' } | { kind: 'manual'; url: string }
    >({ kind: 'hidden' });
    let linkCopiedTimer: ReturnType<typeof setTimeout> | null = null;

    function clearLinkCopiedTimer() {
        if (linkCopiedTimer != null) {
            clearTimeout(linkCopiedTimer);
            linkCopiedTimer = null;
        }
    }

    async function handleCopyLink() {
        const n = activePageNumber();
        contextMenuOpen = false;
        actionSheetOpen = false;
        if (n == null) return;
        const url = `${window.location.origin}/manga/${manga.id}/chapter/${chapter.id}?page=${n}`;
        clearLinkCopiedTimer();
        try {
            await navigator.clipboard.writeText(url);
            linkCopiedState = { kind: 'ok' };
            linkCopiedTimer = setTimeout(() => {
                linkCopiedState = { kind: 'hidden' };
                linkCopiedTimer = null;
            }, 1600);
        } catch {
            // Insecure context (HTTP on a LAN demo), missing
            // permission, or some other API unavailability. Surface
            // the URL in the pill so the user can copy it manually
            // instead of silently swallowing — clipboard failures are
            // systemic, not transient, and the silent path leaves the
            // user pressing the button again with no feedback.
            linkCopiedState = { kind: 'manual', url };
            linkCopiedTimer = setTimeout(() => {
                linkCopiedState = { kind: 'hidden' };
                linkCopiedTimer = null;
            }, 10000);
        }
    }

    // Continuous mode lacks TapZone, so long-press lives per-image.
    // Each in-flight press is keyed by `PointerEvent.pointerId` so a
    // two-finger multitouch (one finger on each of two images) tracks
    // both independently — a single shared timer would let the later
    // press clobber the earlier one and fire the wrong page's sheet.
    const LONG_PRESS_MS = 450;
    const MOVE_TOLERANCE = 8;
    type Press = {
        start: { x: number; y: number };
        timer: ReturnType<typeof setTimeout>;
    };
    const presses = new Map<number, Press>();

    function clearPress(pointerId: number) {
        const p = presses.get(pointerId);
        if (!p) return;
        clearTimeout(p.timer);
        presses.delete(pointerId);
    }

    function clearAllPresses() {
        for (const p of presses.values()) clearTimeout(p.timer);
        presses.clear();
    }

    function onPagePointerDown(e: PointerEvent, pageId: string) {
        if (e.pointerType !== 'touch') return;
        if (!session.user) return;
        clearPress(e.pointerId);
        const start = { x: e.clientX, y: e.clientY };
        const pointerId = e.pointerId;
        const timer = setTimeout(() => {
            presses.delete(pointerId);
            openActionSheet(pageId);
        }, LONG_PRESS_MS);
        presses.set(pointerId, { start, timer });
    }

    function onPagePointerMove(e: PointerEvent) {
        const p = presses.get(e.pointerId);
        if (!p) return;
        const dx = e.clientX - p.start.x;
        const dy = e.clientY - p.start.y;
        if (Math.hypot(dx, dy) > MOVE_TOLERANCE) clearPress(e.pointerId);
    }

    function onPagePointerUp(e: PointerEvent) {
        clearPress(e.pointerId);
    }

    // Scroll-pan typically fires pointercancel on the underlying touch,
    // but not always — explicitly cancel every in-flight press on any
    // scroll event so a long hold during a vertical pan never fires
    // the action sheet for a page the user is already past. Matches
    // the TapZone-level cancel-on-scroll rule.
    $effect(() => {
        if (!browser) return;
        window.addEventListener('scroll', clearAllPresses, true);
        return () => {
            window.removeEventListener('scroll', clearAllPresses, true);
            clearAllPresses();
        };
    });

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

    // Continuous mode previously ignored `?page=N` — the single-mode
    // `initialIndex` logic above wires it into `let index = $state(...)`
    // but continuous lets the user scroll naturally, so nothing
    // jumped to the requested page on load. Search-result click-
    // through (and any shared link) now relies on it.
    //
    // Two subtleties this guards against:
    //   1. Lazy-load layout shift: pages with `loading="lazy"` have
    //      0×0 placeholder height until they load. `scrollIntoView`
    //      runs on whatever the current layout says — if prior pages
    //      haven't loaded, the target appears far higher than its
    //      final position, and as the images load the target gets
    //      pushed past the viewport. The template eager-loads pages
    //      `0..=initialIndex` so they have heights, and the effect
    //      waits for each of them to fire `load` (or `error`) before
    //      scrolling.
    //   2. Mode hydration race: `mode` derives from `preferences.readerMode`,
    //      which initialises to 'single' and is hydrated async on
    //      cold caches. An `onMount` would bail before the flip; an
    //      `$effect` re-fires when mode changes. A one-shot sentinel
    //      keeps us from re-scrolling on later toggles.
    //   3. In-reader chapter navigation: SvelteKit reuses this
    //      component on same-route goto(...) (see chevron handlers
    //      below). The sentinel + `index` get reset by the
    //      chapter-change effect below so the scroll fires again for
    //      the new chapter's `?page=N`.
    let initialScrollDone = $state(false);

    // Re-seed chapter-scoped state when the chevrons (or any in-
    // reader link) navigate to a sibling chapter. SvelteKit reuses
    // the component on same-route nav, so without this the previous
    // chapter's mutable state bleeds into the new one:
    //   - `index` would keep the old chapter's value (possibly out
    //     of range for the new chapter's page count).
    //   - `initialScrollDone` would stay true, so `?page=N` deep-
    //     link scrolls would never re-fire.
    //   - `progressPage` would keep the old chapter's high-water
    //     mark, and a pending `progressTimer` would flush it against
    //     the new `chapter.id` — poisoning the new chapter's stored
    //     read progress.
    //
    // `lastChapterIdSeen` is a plain `let` rather than `$state` on
    // purpose — it's only read inside this effect's body and never
    // drives reactivity. Making it `$state` would not change behavior
    // but would suggest to a future reader that the value matters
    // elsewhere.
    // Two things are load-bearing here:
    //
    //   1. `$derived(data.chapter.id)` makes the dependency explicit.
    //      Reading `data.chapter.id` directly inside an effect does
    //      not reliably re-fire when SvelteKit hands the page a new
    //      `data` prop with a swapped nested chapter object —
    //      verified by an E2E that stayed stuck on the previous
    //      chapter's index across the full polling window. The
    //      `$derived` exists for tracking, not memoization.
    //
    //   2. `$effect.pre` (vs. `$effect`) lands the reset BEFORE the
    //      DOM mutation rather than after. The page-indicator and
    //      everything else that reads `index` / `pages.length` then
    //      render once, with both values from the new chapter. With
    //      a plain `$effect` the indicator rendered the previous
    //      chapter's `index` against the new chapter's
    //      `pages.length` ("Page 5 / 4") — the very symptom the
    //      regression test pins.
    const currentChapterId = $derived(data.chapter.id);
    let lastChapterIdSeen: string | null = null;
    $effect.pre(() => {
        const cid = currentChapterId;
        if (lastChapterIdSeen !== null && lastChapterIdSeen !== cid) {
            index = initialIndex;
            initialScrollDone = false;
            progressPage = initialProgressPage;
            if (progressTimer) {
                clearTimeout(progressTimer);
                progressTimer = null;
            }
        }
        lastChapterIdSeen = cid;
    });
    $effect(() => {
        if (initialScrollDone) return;
        if (!browser) return;
        if (mode !== 'continuous') return;
        if (initialIndex === 0) {
            initialScrollDone = true;
            return;
        }
        const target = continuousPageEls[initialIndex];
        // continuousPageEls is populated as the {#each} mounts; the
        // effect re-runs once it's bound.
        if (!target) return;

        initialScrollDone = true;

        const above = continuousPageEls
            .slice(0, initialIndex + 1)
            .filter((el): el is HTMLImageElement => el != null);
        const pending = above.filter((el) => !el.complete);
        if (pending.length === 0) {
            target.scrollIntoView({ block: 'start' });
            return;
        }
        let remaining = pending.length;
        const onResolved = () => {
            remaining -= 1;
            if (remaining === 0) target.scrollIntoView({ block: 'start' });
        };
        for (const el of pending) {
            // `error` counts as resolved — a broken image's height is
            // settled at its alt-text height, and we shouldn't hang.
            el.addEventListener('load', onResolved, { once: true });
            el.addEventListener('error', onResolved, { once: true });
        }
    });

    // 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),
    // and only docked against the header once the user scrolled
    // enough to consume that gap. The fix is to lift the bar out of
    // document flow entirely (position: fixed) and reserve space in
    // the chapter content via `--reader-nav-h`.
    $effect(() => {
        if (!readerNavEl) return;
        const publish = () => {
            document.documentElement.style.setProperty(
                '--reader-nav-h',
                `${readerNavEl!.offsetHeight}px`
            );
        };
        publish();
        const ro = new ResizeObserver(publish);
        ro.observe(readerNavEl);
        return () => {
            ro.disconnect();
            document.documentElement.style.removeProperty('--reader-nav-h');
        };
    });

    // Publish the bottom chapter-bar's actual measured height so the
    // continuous container's `padding-bottom` exactly matches it. Same
    // rationale as the layout header's measurement: the bar's height
    // changes with font size, padding, browser zoom, and content
    // wrap on narrow viewports — a single hard-coded number is wrong.
    // Only present in continuous mode, so the effect only runs there
    // and cleans up when the bar unmounts on mode toggle.
    $effect(() => {
        if (mode !== 'continuous' || !chapterBarEl) return;
        const publish = () => {
            document.documentElement.style.setProperty(
                '--reader-bar-h',
                `${chapterBarEl!.offsetHeight}px`
            );
        };
        publish();
        const ro = new ResizeObserver(publish);
        ro.observe(chapterBarEl);
        return () => {
            ro.disconnect();
            // Clear so other pages (single mode, /upload, etc.) don't
            // inherit a stale reservation.
            document.documentElement.style.removeProperty('--reader-bar-h');
        };
    });

    // ---- 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
    // to the adjacent chapter at the boundaries.
    // In continuous mode: chevrons / arrow keys jump straight to the
    // adjacent chapter — there is no in-page concept when everything
    // is stacked.

    function jumpToPrevChapter() {
        if (!prevChapter) return;
        // Land on the LAST page of the previous chapter so the back-
        // navigation feels continuous in single mode. Harmless in
        // continuous mode (the reader just shows everything).
        const target = mode === 'single' ? `?page=last` : '';
        void goto(`/manga/${manga.id}/chapter/${prevChapter.id}${target}`);
    }
    function jumpToNextChapter() {
        if (!nextChapter) return;
        void goto(`/manga/${manga.id}/chapter/${nextChapter.id}`);
    }

    function next() {
        if (mode === 'single') {
            if (index < pages.length - 1) {
                index += 1;
                return;
            }
            jumpToNextChapter();
        } else {
            jumpToNextChapter();
        }
    }
    function prev() {
        if (mode === 'single') {
            if (index > 0) {
                index -= 1;
                return;
            }
            jumpToPrevChapter();
        } else {
            jumpToPrevChapter();
        }
    }
    function first() {
        index = 0;
    }
    function last() {
        index = pages.length - 1;
    }

    // Whether the prev/next chevron should be enabled. Always render
    // the button — disabling rather than hiding gives the boundary a
    // shape so users can tell they're at the edge.
    const canPrev = $derived(
        mode === 'single' ? index > 0 || prevChapter !== null : prevChapter !== null
    );
    const canNext = $derived(
        mode === 'single'
            ? index < pages.length - 1 || nextChapter !== null
            : nextChapter !== null
    );

    function onKeydown(e: KeyboardEvent) {
        // Don't hijack keys while the user is typing in an input.
        const target = e.target as HTMLElement | null;
        if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) return;
        // `?` opens the keyboard-shortcut help overlay. While it's open,
        // swallow every key here so paging can't happen behind it — the
        // Sheet owns its own Escape/close.
        if (e.key === '?') {
            e.preventDefault();
            shortcutsOpen = true;
            return;
        }
        if (shortcutsOpen) return;
        // Esc always exits fullscreen if active — applies in both
        // modes, including when the bars are hidden. Handled before
        // the per-mode switches so it doesn't get shadowed.
        if (e.key === 'Escape' && readerFullscreen.value) {
            e.preventDefault();
            readerFullscreen.value = false;
            return;
        }
        // In continuous mode, native scrolling handles Space / PageDown /
        // Up / Down; Left + Right (and the vim j/k aliases) jump
        // chapters because there's no in-page concept here. Home / End
        // still scroll to chapter bounds via scrollIntoView.
        if (mode === 'continuous') {
            switch (e.key) {
                case 'ArrowLeft':
                case 'k':
                    e.preventDefault();
                    jumpToPrevChapter();
                    break;
                case 'ArrowRight':
                case 'j':
                    e.preventDefault();
                    jumpToNextChapter();
                    break;
                case 'Home':
                    e.preventDefault();
                    continuousPageEls[0]?.scrollIntoView({ block: 'start' });
                    break;
                case 'End':
                    e.preventDefault();
                    continuousPageEls[
                        continuousPageEls.length - 1
                    ]?.scrollIntoView({ block: 'end' });
                    break;
            }
            return;
        }
        // Space is deliberately *not* bound — on viewports where a page
        // image overflows (portrait phones, narrow desktop windows),
        // users expect Space to scroll the page, and stealing it for
        // next-page would skip past unread content. ArrowRight + j
        // are the discoverable bindings.
        switch (e.key) {
            case 'ArrowRight':
            case 'j':
                e.preventDefault();
                next();
                break;
            case 'ArrowLeft':
            case 'k':
                e.preventDefault();
                prev();
                break;
            case 'Home':
                e.preventDefault();
                first();
                break;
            case 'End':
                e.preventDefault();
                last();
                break;
        }
    }

    onMount(() => window.addEventListener('keydown', onKeydown));
    onDestroy(() => {
        if (typeof window !== 'undefined') window.removeEventListener('keydown', onKeydown);
        // Tear down the copy-pill timer so a fire-after-unmount
        // doesn't try to write to a stale $state rune. Cheap on
        // SvelteKit chapter navigation, which remounts this page.
        clearLinkCopiedTimer();
    });

    // ---- Admin force resync (current chapter) ----
    let resyncBusy = $state(false);
    let resyncMessage = $state<{ kind: 'ok' | 'err'; text: string } | null>(null);
    async function forceResync() {
        if (!session.user?.is_admin || resyncBusy) return;
        resyncBusy = true;
        resyncMessage = null;
        try {
            const r = await resyncChapter(chapter.id);
            if (r.outcome === 'fetched') {
                resyncMessage = {
                    kind: 'ok',
                    text: `Refetched ${r.pages} page${r.pages === 1 ? '' : 's'}. Reloading…`
                };
                // Re-run all loaders for this route so the reader picks
                // up the freshly-downloaded pages. The page.ts loader
                // doesn't `depends()` on anything explicitly, so
                // invalidateAll is the right brush here.
                await invalidateAll();
            } else {
                resyncMessage = { kind: 'ok', text: 'No new pages — source had nothing fresh.' };
            }
        } catch (e) {
            const msg = e instanceof ApiError ? e.message : (e as Error).message;
            resyncMessage = { kind: 'err', text: msg };
        } finally {
            resyncBusy = false;
        }
    }

    // ---- Reading progress tracking ----
    //
    // High-water mark seeded from the server: progress only ever moves
    // forward within a session, so a quick scroll-up doesn't rewind
    // the saved position. Critically, when the user re-opens a chapter
    // they were previously reading we seed from `data.readProgress.page`
    // so the first flush is a no-op (or forward-only) rather than a
    // reset to page 1 that would clobber the persisted position.
    //
    // Writes are debounced and fire-and-forget — the reader never
    // blocks on the network, and a failed write just means the user's
    // history is slightly stale (acceptable).
    //
    // `$derived` so the seed recomputes on in-reader chapter
    // navigation (SvelteKit reuses the component on same-route nav).
    // The chapter-change effect above re-seeds `progressPage` from
    // this — without it, the previous chapter's high-water mark
    // carries over and gets written against the new `chapter.id`.
    const initialProgressPage = $derived.by(() =>
        data.readProgress && data.readProgress.chapter_id === chapter.id
            ? Math.max(1, data.readProgress.page)
            : 1
    );
    // svelte-ignore state_referenced_locally
    let progressPage = $state(initialProgressPage);
    let progressTimer: ReturnType<typeof setTimeout> | null = null;
    let observer: IntersectionObserver | null = null;

    function noteProgress(page: number) {
        if (page > progressPage) progressPage = page;
    }

    async function flushProgress() {
        if (!session.user) return;
        try {
            await updateReadProgress({
                manga_id: manga.id,
                chapter_id: chapter.id,
                page: progressPage
            });
        } catch {
            // Best-effort; nothing the user can do about a transient
            // hiccup and we don't want to nag them.
        }
    }

    function scheduleFlush() {
        if (progressTimer) clearTimeout(progressTimer);
        progressTimer = setTimeout(flushProgress, 1500);
    }

    // Two-part reader back-control:
    //   - The arrow is always a "go back" action: pops browser
    //     history if there's something to pop, falls back to the
    //     detail page on a cold tab.
    //   - The cover+title is smart: if the user got to the reader
    //     FROM this manga's detail page they go back (pop, no dup
    //     history entry), otherwise they land on the detail page
    //     (push). Stops the "click cover/title and end up in search
    //     results" regression that the naked back-everywhere code
    //     used to have.
    //
    // `lastInternalPath` is captured by `afterNavigate({ from })`,
    // which SvelteKit fires after every client-side navigation. It's
    // the only reliable signal — `document.referrer` doesn't update
    // across SPA navs (the chrome-resync at line 718 doesn't help
    // either since it doesn't write referrer).
    let lastInternalPath: string | null = null;
    afterNavigate(({ from }) => {
        lastInternalPath = from?.url?.pathname ?? null;
    });

    const detailPath = $derived(`/manga/${manga.id}`);

    function onArrowClick(e: MouseEvent) {
        if (!browser) return;
        if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
        e.preventDefault();
        if (window.history.length > 1) {
            window.history.back();
        } else {
            // Cold tab / deep-link: nothing to pop. Push the detail
            // page as the sane "back" destination.
            void goto(detailPath);
        }
    }

    function onCoverTitleClick(e: MouseEvent) {
        if (!browser) return;
        // Modifier / middle / non-left clicks fall through to the
        // native <a> so "open in new tab", "copy link", etc. all work.
        if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
        if (lastInternalPath === detailPath) {
            // Came from THIS manga's detail page — pop so we don't
            // accumulate a duplicate /manga/:id entry. preventDefault
            // here stops SvelteKit's own click handler: its listener
            // is on the app container in the bubble phase and bails on
            // `event.defaultPrevented` (see @sveltejs/kit client.js),
            // and this element-level handler runs first at the target
            // phase, so the pop wins cleanly.
            e.preventDefault();
            window.history.back();
        }
        // Anywhere else (search, library, another manga's detail, cold
        // tab) — do nothing here and let the <a href> navigate
        // natively, which PUSHES the detail page so browser-back
        // returns to the reader.
    }

    // Single-mode: every page change moves the high-water mark.
    // Intentionally NOT depending on `mode` — toggling layout doesn't
    // change the read position, and re-running this effect on a mode
    // toggle would re-fire `noteProgress(index + 1)` (= 1 in
    // continuous mode where index never moves) and schedule a flush
    // that's at best a no-op and at worst a spurious write.
    $effect(() => {
        noteProgress(index + 1);
        scheduleFlush();
    });

    // Initial open: record that the user is in this chapter now so the
    // history-sort timestamp moves to "now" — without regressing the
    // page number (initialProgressPage already encodes the persisted
    // value when the chapter matches).
    onMount(() => {
        if (session.user) void flushProgress();
    });

    // Continuous mode: observe each page image and track the highest
    // index that's been visible. IntersectionObserver is re-created
    // whenever the page list rebinds (chapter change).
    $effect(() => {
        if (mode !== 'continuous') {
            observer?.disconnect();
            observer = null;
            return;
        }
        const els = continuousPageEls.filter(Boolean);
        if (els.length === 0) return;
        observer?.disconnect();
        observer = new IntersectionObserver(
            (entries) => {
                for (const e of entries) {
                    if (!e.isIntersecting) continue;
                    const idx = els.indexOf(e.target as HTMLImageElement);
                    if (idx >= 0) noteProgress(idx + 1);
                }
                scheduleFlush();
            },
            { rootMargin: '0px', threshold: 0.5 }
        );
        for (const el of els) observer.observe(el);
        return () => observer?.disconnect();
    });

    /**
     * Flush read-progress as the tab is closing. A plain `fetch()`
     * during `pagehide` / `beforeunload` is cancelled by every
     * browser; `fetch(..., { keepalive: true })` is the supported
     * escape hatch and survives the close.
     *
     * `sendBeacon` would be the textbook alternative, but it's
     * POST-only and `/me/read-progress` takes PUT — so a beacon
     * always 405s, adds server-log noise, then falls through to this
     * same keepalive path anyway. The beacon was dropped; the
     * keepalive fetch is the only path.
     */
    function flushFinalProgress() {
        if (!session.user) return;
        const body = JSON.stringify({
            manga_id: manga.id,
            chapter_id: chapter.id,
            page: progressPage
        });
        try {
            void fetch('/api/v1/me/read-progress', {
                method: 'PUT',
                headers: { 'content-type': 'application/json' },
                body,
                keepalive: true,
                credentials: 'include'
            });
        } catch {
            // keepalive fetch was rejected (very old Firefox etc.);
            // the in-app onDestroy flush below catches the SPA-
            // navigation case, which is the common one anyway.
        }
    }

    // ---- Next-chapter image preloading ----
    // When the reader nears the end of the current chapter, warm the next
    // chapter's first few page images so flipping over is instant. Works in
    // both modes off the furthest page reached (single: `index`; continuous:
    // the IntersectionObserver high-water `progressPage`).
    const PRELOAD_WITHIN_PAGES = 2;
    const NEXT_PRELOAD_COUNT = 3;
    let nextPreloadUrls = $state<string[]>([]);
    let preloadedForChapterId: string | null = null;

    const furthestPage = $derived(mode === 'single' ? index + 1 : progressPage);

    $effect(() => {
        const nc = nextChapter;
        // Drop a previous chapter's warmed images once the next chapter
        // changes (e.g. after navigating), so stale <img>s don't linger.
        if (preloadedForChapterId !== null && (!nc || preloadedForChapterId !== nc.id)) {
            preloadedForChapterId = null;
            nextPreloadUrls = [];
        }
        if (!nc || pages.length === 0) return;
        if (pages.length - furthestPage > PRELOAD_WITHIN_PAGES) return;
        if (preloadedForChapterId === nc.id) return;
        // Marked before the await and NOT reset on failure, so a broken
        // endpoint can't re-fetch on every page flip near the end — the
        // guard clears only when the next chapter itself changes (above).
        preloadedForChapterId = nc.id;
        getChapterPages(manga.id, nc.id)
            .then((ps) => {
                if (preloadedForChapterId !== nc.id) return; // chapter changed mid-flight
                nextPreloadUrls = ps
                    .slice(0, NEXT_PRELOAD_COUNT)
                    .map((p) => fileUrl(p.storage_key));
            })
            .catch(() => {
                // Best-effort prefetch — swallow and don't retry this chapter.
            });
    });

    onMount(() => {
        window.addEventListener('pagehide', flushFinalProgress);
    });

    onDestroy(() => {
        observer?.disconnect();
        if (progressTimer) clearTimeout(progressTimer);
        if (typeof window !== 'undefined') {
            window.removeEventListener('pagehide', flushFinalProgress);
        }
        // Don't let the fullscreen flag leak to non-reader pages —
        // otherwise the layout header would stay slid-off on /upload
        // or /profile after navigating away.
        readerFullscreen.reset();
        // For SPA navigation (e.g., clicking the back-link) the page
        // doesn't unload, so `pagehide` won't fire — flush via a
        // normal fetch. Tab-close paths land on the beacon above.
        void flushProgress();
    });
</script>

<svelte:head>
    <title>{pageTitle}</title>
</svelte:head>

<nav class="reader-nav" aria-label="reader" bind:this={readerNavEl}>
    <div class="back-group">
        <button
            type="button"
            class="back-arrow"
            aria-label="Back"
            onclick={onArrowClick}
            data-testid="reader-back-arrow"
        >
            <ArrowLeft size={18} aria-hidden="true" />
        </button>
        <!--
            Cover + title is a real <a href> to the detail page so
            cmd/middle-click "open in new tab" and "copy link" work
            natively. The onclick only intercepts the one case where
            we want to POP instead of push (arrived from this manga's
            own detail page) — see onCoverTitleClick. Every other
            plain-left-click falls through to SvelteKit's default <a>
            navigation, which pushes the detail page.
        -->
        <a
            href={detailPath}
            class="back"
            onclick={onCoverTitleClick}
            data-testid="back-to-manga"
        >
            {#if manga.cover_image_path}
                <img
                    src={fileUrl(manga.cover_image_path)}
                    alt=""
                    class="back-cover"
                    loading="lazy"
                />
            {:else}
                <span class="back-cover back-cover-placeholder" aria-hidden="true">
                    <BookImage size={14} aria-hidden="true" />
                </span>
            {/if}
            <span class="back-text">{manga.title}</span>
        </a>
    </div>

    <div class="controls" role="group" aria-label="reader options">
        <label class="chapter-field desktop-control">
            <span class="visually-hidden">Jump to chapter</span>
            <select
                class="chapter-select"
                value={chapter.id}
                onchange={(e) => {
                    const target = (e.currentTarget as HTMLSelectElement).value;
                    if (target && target !== chapter.id) {
                        void goto(`/manga/${manga.id}/chapter/${target}`);
                    }
                }}
                data-testid="reader-chapter-select"
            >
                {#each chapters as c (c.id)}
                    <option value={c.id}>
                        {chapterLabel(c)}
                    </option>
                {/each}
            </select>
        </label>

        <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"
                class:active={mode === 'single'}
                onclick={() => preferences.setMode('single')}
                aria-pressed={mode === 'single'}
                title="Single page"
                data-testid="reader-mode-single"
            >
                <FileText size={16} aria-hidden="true" />
                <span>Single</span>
            </button>
            <button
                type="button"
                class="seg"
                class:active={mode === 'continuous'}
                onclick={() => preferences.setMode('continuous')}
                aria-pressed={mode === 'continuous'}
                title="Continuous (scroll)"
                data-testid="reader-mode-continuous"
            >
                <ScrollText size={16} aria-hidden="true" />
                <span>Continuous</span>
            </button>
        </div>

        {#if mode === 'continuous'}
            <label class="gap-field desktop-control">
                <span class="visually-hidden">Page gap</span>
                <select
                    value={preferences.readerPageGap}
                    onchange={(e) =>
                        preferences.setGap((e.currentTarget as HTMLSelectElement).value as ReaderPageGap)}
                    data-testid="reader-gap"
                >
                    <option value="none">No gap</option>
                    <option value="small">Small gap</option>
                    <option value="medium">Medium gap</option>
                    <option value="large">Large gap</option>
                </select>
            </label>

        {/if}

        <!-- Desktop brightness. On mobile this control lives in the reader
             settings sheet; desktop has no sheet, so it sits inline here.
             Shares the `brightness` state and the same `--reader-dim`
             effect, so a value set on either form factor stays in sync. -->
        <label class="brightness-field desktop-control">
            <Sun size={16} aria-hidden="true" />
            <span class="visually-hidden">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="reader-brightness-desktop"
            />
        </label>

        <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">
        {#if mode === 'single'}
            Page {index + 1} / {pages.length}
        {:else}
            {pages.length} {pages.length === 1 ? 'page' : 'pages'}
        {/if}
    </span>

    {#if session.user?.is_admin}
        <button
            type="button"
            class="reader-resync"
            onclick={forceResync}
            disabled={resyncBusy}
            title={resyncMessage?.kind === 'err'
                ? resyncMessage.text
                : 'Force refetch this chapter from the crawler source'}
            aria-label="Force resync chapter"
            data-testid="force-resync-chapter"
        >
            <RefreshCw size={16} aria-hidden="true" class={resyncBusy ? 'spin' : ''} />
            <span>{resyncBusy ? 'Resyncing…' : 'Force resync'}</span>
        </button>
    {/if}

    <button
        type="button"
        class="fullscreen-toggle"
        onclick={() => readerFullscreen.toggle()}
        aria-label="Enter focus mode (hide top + bottom bars)"
        title="Focus mode"
        data-testid="reader-fullscreen-toggle"
    >
        <Maximize2 size={16} aria-hidden="true" />
        <span>Focus</span>
    </button>
</nav>

{#if resyncMessage}
    <p
        class="resync-toast"
        class:err={resyncMessage.kind === 'err'}
        role="status"
        data-testid="force-resync-message"
    >
        {resyncMessage.text}
    </p>
{/if}

<!--
    Floating exit affordance — only rendered while focus mode is on.
    Lives in the top-right corner with a low resting opacity so it
    doesn't distract from the page, but is reachable with a quick
    glance + click. Esc also exits.
-->
{#if readerFullscreen.value}
    <button
        type="button"
        class="fullscreen-exit"
        onclick={() => (readerFullscreen.value = false)}
        aria-label="Exit focus mode (or press Esc)"
        title="Exit focus mode (Esc)"
        data-testid="reader-fullscreen-exit"
    >
        <Minimize2 size={16} aria-hidden="true" />
    </button>
{/if}

{#if pages.length === 0}
    <p class="empty" data-testid="reader-empty">This chapter has no pages yet.</p>
{:else if mode === 'single'}
    <div class="page-wrap">
        <button
            type="button"
            class="nav prev"
            onclick={prev}
            disabled={!canPrev}
            aria-label={index === 0
                ? prevChapter
                    ? `Previous chapter (${prevChapter.number})`
                    : 'Previous chapter'
                : 'Previous page'}
            data-testid="reader-prev"
        >
            <ChevronLeft size={22} aria-hidden="true" />
        </button>

        <img
            src={fileUrl(pages[index].storage_key)}
            alt={`${manga.title} chapter ${chapter.number} page ${index + 1}`}
            class="page-image"
            loading="eager"
            oncontextmenu={(e) => onPageContextMenu(e, pages[index].id)}
            data-testid="reader-page"
        />

        <button
            type="button"
            class="nav next"
            onclick={next}
            disabled={!canNext}
            aria-label={index === pages.length - 1
                ? nextChapter
                    ? `Next chapter (${nextChapter.number})`
                    : 'Next chapter'
                : 'Next page'}
            data-testid="reader-next"
        >
            <ChevronRight size={22} aria-hidden="true" />
        </button>

        <!-- Preload the next page in a hidden img so it's already cached
             when the user advances. -->
        {#if index < pages.length - 1}
            <img
                src={fileUrl(pages[index + 1].storage_key)}
                alt=""
                aria-hidden="true"
                class="preload"
                loading="lazy"
                data-testid="reader-preload"
            />
        {/if}
    </div>
{:else}
    <div class="continuous" style:gap="{gapPx}px" data-testid="reader-continuous">
        {#each pages as p, i (p.id)}
            <!-- Pages in the leading eager window (`i <= eagerThrough`) load
                 up front — this settles every page's real height at/above the
                 cold-load scroll-to-`?page=N` target before that effect fires,
                 so `scrollIntoView(target)` can't land while prior pages are
                 still 0×0 placeholders. Pages beyond the window are lazy: the
                 browser fetches them as the reader scrolls near, so a long
                 chapter doesn't fire every request / decode every image at
                 once. Unloaded lazy pages carry `data-reserve` so CSS gives
                 them a min-height — keeping them distinct scroll targets for
                 the progress observer; `onload` clears it once real height
                 lands. -->
            <img
                src={fileUrl(p.storage_key)}
                alt={`${manga.title} chapter ${chapter.number} page ${i + 1}`}
                class="page-image"
                loading={i <= eagerThrough ? 'eager' : 'lazy'}
                data-reserve={i > eagerThrough ? '' : undefined}
                onload={(e) => e.currentTarget.removeAttribute('data-reserve')}
                oncontextmenu={(e) => onPageContextMenu(e, p.id)}
                onpointerdown={(e) => onPagePointerDown(e, p.id)}
                onpointermove={onPagePointerMove}
                onpointerup={onPagePointerUp}
                onpointercancel={onPagePointerUp}
                data-testid={`reader-page-${i + 1}`}
                bind:this={continuousPageEls[i]}
            />
        {/each}
    </div>

    <!-- Sticky bottom bar — always visible at the foot of the viewport
         in continuous mode. Slides off when full-screen is toggled. -->
    <div
        class="chapter-bar"
        bind:this={chapterBarEl}
        data-testid="chevrons-inline-bottom"
    >
        <button
            type="button"
            class="inline-btn"
            onclick={jumpToPrevChapter}
            disabled={!prevChapter}
            data-testid="chapter-bar-prev"
        >
            <ChevronLeft size={16} aria-hidden="true" />
            <span>
                {prevChapter
                    ? `Previous chapter (Ch. ${prevChapter.number})`
                    : 'No previous chapter'}
            </span>
        </button>
        <span class="chapter-bar-current" aria-hidden="true">
            {chapterLabel(chapter)}
        </span>
        <button
            type="button"
            class="inline-btn"
            onclick={jumpToNextChapter}
            disabled={!nextChapter}
            data-testid="chapter-bar-next"
        >
            <span>
                {nextChapter
                    ? `Next chapter (Ch. ${nextChapter.number})`
                    : 'No next chapter'}
            </span>
            <ChevronRight size={16} aria-hidden="true" />
        </button>
    </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>

{#if linkCopiedState.kind === 'ok'}
    <div
        class="link-copied"
        role="status"
        aria-live="polite"
        data-testid="reader-link-copied"
    >
        Link copied
    </div>
{:else if linkCopiedState.kind === 'manual'}
    <div
        class="link-copied link-copied-manual"
        role="status"
        aria-live="polite"
        data-testid="reader-link-copied-manual"
    >
        <span class="link-copied-label">
            Couldn't copy automatically — press ⌘C / long-press to copy:
        </span>
        <!--
            `readonly` keeps the underlying page state untouchable.
            `inputmode="none"` suppresses the iOS / Android soft
            keyboard that would otherwise pop up under a tap, since
            the user is here to copy the URL, not edit it. Focus
            auto-selects so the desktop user can immediately ⌘C.
        -->
        <input
            type="text"
            readonly
            inputmode="none"
            value={linkCopiedState.url}
            onfocus={(e) => e.currentTarget.select()}
            data-testid="reader-link-copied-manual-input"
        />
    </div>
{/if}

<!-- Warm the next chapter's first images once the reader nears the end of
     this one. Hidden (display:none still fetches) — a pure cache primer so
     flipping to the next chapter shows pages immediately. -->
{#each nextPreloadUrls as u (u)}
    <img src={u} alt="" aria-hidden="true" class="preload" loading="eager" data-testid="reader-next-preload" />
{/each}

<!-- 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}
        onLongPress={(_anchor) => openActionSheet(pages[index].id)}
        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={shortcutsOpen}
    title="Keyboard shortcuts"
    onClose={() => (shortcutsOpen = false)}
    testid="reader-shortcuts"
>
    <dl class="shortcut-list">
        <div class="shortcut"><dt><kbd>→</kbd> / <kbd>J</kbd></dt><dd>Next page</dd></div>
        <div class="shortcut"><dt><kbd>←</kbd> / <kbd>K</kbd></dt><dd>Previous page</dd></div>
        <div class="shortcut"><dt><kbd>Home</kbd></dt><dd>First page</dd></div>
        <div class="shortcut"><dt><kbd>End</kbd></dt><dd>Last page</dd></div>
        <div class="shortcut"><dt><kbd>→</kbd> / <kbd>←</kbd></dt><dd>Next / previous chapter (continuous mode)</dd></div>
        <div class="shortcut"><dt><kbd>Esc</kbd></dt><dd>Exit full-screen / close this overlay</dd></div>
        <div class="shortcut"><dt><kbd>?</kbd></dt><dd>Show this help</dd></div>
    </dl>
</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>

<!-- Page context menu (desktop right-click) and the modals/sheet it
     funnels into. Rendered for authenticated users only — there's
     nothing for a guest to act on. -->
{#if session.user}
    <PageContextMenu
        open={contextMenuOpen}
        anchor={contextMenuAnchor}
        onClose={() => (contextMenuOpen = false)}
        onAddToCollection={handleAddToCollection}
        onAddTag={handleAddTag}
        onSaveImage={handleSaveImage}
        onCopyLink={handleCopyLink}
        collectionsCount={activePageCollectionCount}
        tags={activePageTags}
        canAnalyze={!!session.user?.is_admin}
        onAnalyzePage={handleAnalyzePage}
        {analyzeState}
    />

    <!-- Mobile action sheet: same two actions, larger touch targets. -->
    <Sheet
        open={actionSheetOpen}
        title="Page actions"
        onClose={() => (actionSheetOpen = false)}
        testid="page-action-sheet"
    >
        <ul class="action-list">
            <li>
                <button
                    type="button"
                    class="action-row"
                    onclick={handleAddToCollection}
                    data-testid="page-action-add-to-collection"
                >
                    <FolderPlus size={18} aria-hidden="true" />
                    <span>Add to collection</span>
                </button>
            </li>
            <li>
                <button
                    type="button"
                    class="action-row"
                    onclick={handleAddTag}
                    data-testid="page-action-add-tag"
                >
                    <Tag size={18} aria-hidden="true" />
                    <span>Add tag</span>
                </button>
            </li>
            <li>
                <button
                    type="button"
                    class="action-row"
                    onclick={handleSaveImage}
                    data-testid="page-action-save-image"
                >
                    <Download size={18} aria-hidden="true" />
                    <span>Save image</span>
                </button>
            </li>
            <li>
                <button
                    type="button"
                    class="action-row"
                    onclick={handleCopyLink}
                    data-testid="page-action-copy-link"
                >
                    <Link2 size={18} aria-hidden="true" />
                    <span>Copy page link</span>
                </button>
            </li>
        </ul>
        <p class="action-hint" data-testid="page-action-collections-line">
            {#if activePageCollectionCount == null}
                Loading…
            {:else if activePageCollectionCount === 0}
                Not in any collection
            {:else}
                In {activePageCollectionCount} collection{activePageCollectionCount === 1
                    ? ''
                    : 's'}
            {/if}
        </p>
        <p class="action-hint" data-testid="page-action-tags-line">
            {activePageTags.length === 0
                ? 'No tags yet'
                : `Tagged: ${activePageTags.join(', ')}`}
        </p>
    </Sheet>

    {#if activePageId}
        <AddToCollectionModal
            open={collectionsModalOpen}
            target={{ kind: 'page', id: activePageId }}
            onClose={() => {
                collectionsModalOpen = false;
                if (activePageId) void loadPageSummary(activePageId);
            }}
        />
        <AdaptiveDialog
            open={tagsModalOpen}
            title="Tag this page"
            onClose={() => (tagsModalOpen = false)}
            size="md"
            testid="add-tags-modal"
        >
            <AddTagsSheet
                pageId={activePageId}
                onChange={(tags) => {
                    activePageTags = tags;
                }}
            />
        </AdaptiveDialog>
    {/if}
{/if}

<style>
    /* Pinned to the viewport directly below the (also fixed) layout
       header. `position: fixed` rather than `sticky` because the
       latter would have a "settle on scroll" period until its natural
       position consumes main's padding-top. Chapter content reserves
       room for this bar via `--reader-nav-h` (measured at runtime by
       a ResizeObserver in onMount). Focus mode slides it up
       off-screen via transform — the fixed origin keeps the slide
       distance equal to the bar's height regardless of scroll. */
    .reader-nav {
        position: fixed;
        top: var(--app-header-h);
        left: 0;
        right: 0;
        z-index: 10;
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: var(--space-2) var(--space-4);
        border-bottom: 1px solid var(--border);
        background: var(--bg);
        gap: var(--space-3);
        flex-wrap: wrap;
        transition:
            transform 220ms ease-out,
            opacity 220ms ease-out;
    }

    :global(html[data-reader-fullscreen='true']) .reader-nav {
        transform: translateY(-100%);
        opacity: 0;
        pointer-events: none;
    }

    .back-group {
        display: flex;
        align-items: center;
        gap: var(--space-1);
        min-width: 0;
    }

    .back-arrow {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        background: transparent;
        color: var(--text);
        border: 0;
        padding: var(--space-1);
        cursor: pointer;
        border-radius: var(--radius-sm);
        flex-shrink: 0;
    }

    .back-arrow:hover {
        color: var(--primary);
        background: var(--surface-elevated);
    }

    .back {
        display: flex;
        align-items: center;
        gap: var(--space-2);
        min-width: 0;
        color: var(--text);
    }

    .back:hover {
        color: var(--primary);
        text-decoration: none;
    }

    .back-cover {
        width: 28px;
        height: 42px;
        object-fit: cover;
        border-radius: var(--radius-sm);
        background: var(--surface);
        flex-shrink: 0;
    }

    .back-cover-placeholder {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        color: var(--text-muted);
    }

    .back-text {
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        font-weight: var(--weight-medium);
    }

    .controls {
        display: flex;
        align-items: center;
        gap: var(--space-2);
        flex-wrap: wrap;
    }

    .mode-toggle {
        display: inline-flex;
        border: 1px solid var(--border-strong);
        border-radius: var(--radius-md);
        overflow: hidden;
        background: var(--surface);
    }

    .seg {
        display: inline-flex;
        align-items: center;
        gap: var(--space-1);
        padding: var(--space-1) var(--space-3);
        background: transparent;
        color: var(--text);
        border: none;
        font-size: var(--font-sm);
        cursor: pointer;
        transition:
            background var(--transition),
            color var(--transition);
    }

    .seg + .seg {
        border-left: 1px solid var(--border-strong);
    }

    .seg:hover:not(.active) {
        background: var(--surface-elevated);
    }

    .seg.active {
        background: var(--primary-soft-bg);
        color: var(--text);
    }

    .seg:focus-visible {
        outline: 2px solid var(--focus-ring);
        outline-offset: -2px;
    }

    .gap-field select,
    .chapter-select {
        height: 32px;
        padding: 0 var(--space-2);
        background: var(--surface);
        color: var(--text);
        border: 1px solid var(--border-strong);
        border-radius: var(--radius-md);
        font-size: var(--font-sm);
    }

    /* Cap the chapter dropdown's resting width so long titles don't
       push the rest of the nav off-screen; the native control's
       expanded menu still shows full option text on focus. */
    .chapter-select {
        max-width: 16rem;
    }

    .visually-hidden {
        position: absolute;
        width: 1px;
        height: 1px;
        padding: 0;
        margin: -1px;
        overflow: hidden;
        clip: rect(0, 0, 0, 0);
        white-space: nowrap;
        border: 0;
    }

    .indicator {
        color: var(--text-muted);
        flex-shrink: 0;
        font-size: var(--font-sm);
    }

    .empty {
        color: var(--text-muted);
    }

    /* Reserve room for the fixed reader-nav above so the first page
       image (single mode) and the top of the chapter stack
       (continuous mode) aren't hidden behind it. The variable is
       written by the ResizeObserver in onMount so the reservation
       always matches actual rendered height. Focus mode collapses
       the reservation in lockstep with the bar's slide-out. */
    /* Sizing is width-driven: every page renders at this consistent width
       (a capped reading column on desktop, full width on narrow screens)
       regardless of its intrinsic dimensions. Tall pages therefore extend
       downward and scroll instead of shrinking to fit the viewport height —
       which is what used to turn long vertical pages into thin stripes. */
    .page-wrap,
    .continuous {
        --reader-page-width: min(100%, 700px);
    }

    .page-wrap {
        display: grid;
        grid-template-columns: auto 1fr auto;
        gap: var(--space-2);
        /* start (not center): a tall page's top must sit under the reader
           nav so the reader begins at the top and scrolls down. */
        align-items: start;
        padding-top: var(--reader-nav-h);
        transition: padding-top 220ms ease-out;
    }

    .continuous {
        display: flex;
        flex-direction: column;
        align-items: center;
        padding-top: var(--reader-nav-h);
        transition:
            padding-top 220ms ease-out,
            padding-bottom 220ms ease-out;
    }

    :global(html[data-reader-fullscreen='true']) .page-wrap,
    :global(html[data-reader-fullscreen='true']) .continuous {
        padding-top: 0;
    }
    :global(html[data-reader-fullscreen='true']) .continuous {
        padding-bottom: 0;
    }

    .page-image {
        width: var(--reader-page-width);
        max-width: 100%;
        height: auto;
        margin: 0 auto;
        display: block;
        /* Suppress the native mobile Safari long-press image callout
           ("Save Image", "Copy") and the long-press text selection so
           the in-app long-press → action sheet path is the only
           outcome. Cost: users lose the OS-level Save Image affordance
           in the reader. Save image stays available via the in-app
           action sheet.
           Desktop right-click is handled separately by oncontextmenu
           — holding Shift bypasses our handler for the native menu. */
        -webkit-touch-callout: none;
        user-select: none;
        -webkit-user-select: none;
    }

    /* A lazy page that hasn't loaded yet has no intrinsic height, so without
       a reservation the whole not-yet-loaded tail would collapse to 0 and
       pile up at one scroll offset — which both looks broken and gives the
       progress IntersectionObserver overlapping 0-height targets. Reserve a
       typical page height (page width × ~1.4) so each stays a distinct target;
       the `onload` handler drops `data-reserve` once the real height lands. */
    .page-image[data-reserve] {
        min-height: calc(var(--reader-page-width) * 1.4);
    }

    /* Continuous mode inherits the width-driven sizing above: every page is
       the same width and takes its natural (uncapped) height, so there are
       no scroll dead-zones inside a single page and widths stay consistent
       between a narrow scan and a wide one. */

    .nav {
        display: inline-flex;
        align-items: center;
        justify-content: center;
        width: 44px;
        height: 44px;
        padding: 0;
        background: var(--surface);
        color: var(--text);
        border: 1px solid var(--border-strong);
        border-radius: var(--radius-md);
        cursor: pointer;
        transition:
            background var(--transition),
            border-color var(--transition);
        /* Now that the page-wrap grid is top-aligned, a tall page makes its
           row much taller than the viewport. Stick the prev/next chevrons to
           the vertical middle of the viewport so they stay reachable while
           the reader scrolls down a long page. */
        position: sticky;
        top: calc(50vh - 22px);
    }

    /* ===== Continuous-mode chapter bar (sticky bottom) =====
       Fixed at the viewport bottom so chapter-jump controls stay one
       click away no matter how far the user has scrolled. Slides
       offscreen in focus mode via the same translate trick as the
       reader nav above. */
    .chapter-bar {
        position: fixed;
        bottom: 0;
        left: 0;
        right: 0;
        z-index: var(--z-sticky);
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: var(--space-2);
        padding: var(--space-2) var(--space-4);
        background: var(--surface);
        border-top: 1px solid var(--border);
        min-height: var(--reader-bar-h);
        transform: translateY(0);
        transition:
            transform 220ms ease-out,
            opacity 220ms ease-out;
    }

    :global(html[data-reader-fullscreen='true']) .chapter-bar {
        transform: translateY(100%);
        opacity: 0;
        pointer-events: none;
    }

    .chapter-bar-current {
        color: var(--text-muted);
        font-size: var(--font-sm);
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
        min-width: 0;
        flex-shrink: 1;
    }

    .inline-btn {
        display: inline-flex;
        align-items: center;
        gap: var(--space-2);
        padding: var(--space-2) var(--space-3);
        background: transparent;
        color: var(--text);
        border: 1px solid var(--border-strong);
        border-radius: var(--radius-md);
        cursor: pointer;
        font-size: var(--font-sm);
        min-height: 36px;
        white-space: nowrap;
    }

    .inline-btn:hover:not(:disabled) {
        background: var(--surface-elevated);
        border-color: var(--primary);
    }

    .inline-btn:disabled {
        opacity: 0.4;
        cursor: not-allowed;
    }

    /* Leave room above the fixed bottom bar so the last image isn't
       hidden when the user scrolls to the chapter end. */
    .continuous {
        padding-bottom: calc(var(--reader-bar-h) + var(--space-3));
    }

    /* ===== Focus-mode controls ===== */
    .fullscreen-toggle,
    .reader-resync {
        display: inline-flex;
        align-items: center;
        gap: var(--space-1);
        padding: 0 var(--space-2);
        height: 32px;
        background: var(--surface);
        color: var(--text-muted);
        border: 1px solid var(--border-strong);
        border-radius: var(--radius-sm);
        cursor: pointer;
        font-size: var(--font-xs);
    }

    .fullscreen-toggle:hover,
    .reader-resync:hover:not(:disabled) {
        background: var(--surface-elevated);
        color: var(--text);
        border-color: var(--primary);
    }

    .reader-resync:disabled {
        opacity: 0.7;
        cursor: progress;
    }

    .resync-toast {
        position: fixed;
        top: calc(var(--app-header-h) + var(--reader-nav-h, 48px) + var(--space-2));
        right: var(--space-3);
        z-index: 11;
        margin: 0;
        padding: var(--space-2) var(--space-3);
        max-width: min(420px, calc(100vw - 2 * var(--space-3)));
        background: var(--surface);
        color: var(--text);
        border: 1px solid var(--primary);
        border-radius: var(--radius-md);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
        font-size: var(--font-sm);
    }

    .resync-toast.err {
        border-color: var(--danger);
        color: var(--danger);
    }

    :global(.spin) {
        animation: spin 0.9s linear infinite;
    }

    @keyframes spin {
        from {
            transform: rotate(0deg);
        }
        to {
            transform: rotate(360deg);
        }
    }

    /* Small floating exit affordance — corner-pinned, low resting
       opacity so it doesn't sit on the chapter image too aggressively
       but is still findable without hover. */
    .fullscreen-exit {
        position: fixed;
        top: var(--space-3);
        right: var(--space-3);
        z-index: var(--z-modal);
        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: 50%;
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
        opacity: 0.4;
        cursor: pointer;
        transition:
            opacity var(--transition),
            background var(--transition);
    }

    .fullscreen-exit:hover,
    .fullscreen-exit:focus-visible {
        opacity: 1;
        background: var(--surface-elevated);
    }

    .nav:hover:not(:disabled) {
        background: var(--surface-elevated);
        border-color: var(--primary);
    }

    .nav:disabled {
        opacity: 0.4;
        cursor: not-allowed;
    }

    .preload {
        display: none;
    }

    @media (max-width: 640px) {
        .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;
    }

    .shortcut-list {
        margin: 0;
        display: flex;
        flex-direction: column;
        gap: var(--space-2);
    }

    .shortcut {
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: var(--space-3);
    }

    .shortcut dt {
        flex-shrink: 0;
    }

    .shortcut dd {
        margin: 0;
        color: var(--text-muted);
        text-align: right;
    }

    .shortcut kbd {
        font-family: var(--font-mono, monospace);
        font-size: var(--font-xs);
        padding: 1px 6px;
        border: 1px solid var(--border-strong);
        border-radius: var(--radius-sm);
        background: var(--surface);
    }

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

    .action-list {
        list-style: none;
        margin: 0 0 var(--space-3);
        padding: 0;
    }

    .action-row {
        display: flex;
        align-items: center;
        gap: var(--space-3);
        width: 100%;
        padding: var(--space-3);
        background: transparent;
        color: var(--text);
        border: 0;
        border-bottom: 1px solid var(--border);
        font-size: var(--font-base);
        cursor: pointer;
        text-align: left;
        min-height: 48px;
    }

    .action-row:hover {
        background: var(--surface-elevated);
    }

    .action-hint {
        margin: var(--space-1) 0 0;
        padding: 0 var(--space-3);
        color: var(--text-muted);
        font-size: var(--font-xs);
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
    }

    .link-copied {
        position: fixed;
        top: calc(var(--app-header-h) + var(--space-3));
        left: 50%;
        transform: translateX(-50%);
        z-index: var(--z-modal);
        background: var(--surface);
        color: var(--text);
        border: 1px solid var(--border);
        border-radius: var(--radius-pill);
        padding: var(--space-1) var(--space-3);
        font-size: var(--font-sm);
        box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
        pointer-events: none;
    }

    .link-copied-manual {
        /* Manual fallback needs to be interactive so the user can
           select / copy the URL — override the success pill's
           pointer-events: none. */
        pointer-events: auto;
        display: flex;
        align-items: center;
        gap: var(--space-2);
        max-width: min(90vw, 32rem);
        border-radius: var(--radius-md);
        padding: var(--space-2) var(--space-3);
    }

    .link-copied-label {
        color: var(--text-muted);
        font-size: var(--font-xs);
        white-space: nowrap;
    }

    .link-copied-manual input {
        flex: 1;
        min-width: 0;
        font-family: inherit;
        font-size: var(--font-xs);
        background: var(--surface-elevated);
        color: var(--text);
        border: 1px solid var(--border);
        border-radius: var(--radius-sm);
        padding: 2px var(--space-2);
    }

    .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%;
    }

    /* Inline desktop brightness control in the reader nav. The slider is
       full-width inside the mobile settings sheet; here it's capped so it
       sits neatly beside the mode toggle. */
    .brightness-field {
        display: inline-flex;
        align-items: center;
        gap: var(--space-2);
        color: var(--text-muted);
    }

    .brightness-field .brightness-slider {
        width: 120px;
    }

    @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>
