HentaiNexus Infinite Reader

Ultimate gallery-page overlay reader for HentaiNexus with strict full-res image resolving from reader pages, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, standalone close button, cleaned compact theme picker, and low-memory loading.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==UserScript==
// @name         HentaiNexus Infinite Reader
// @name:ja      HentaiNexus 無限スクロールリーダー
// @name:zh-CN   HentaiNexus 无限滚动阅读器
// @namespace    https://hentainexus.com/
// @version      2.5.2
// @author       L1Z4RD
// @license      MIT
// @match        https://hentainexus.com/view/*
// @run-at       document-idle
// @grant        none
// @description  Ultimate gallery-page overlay reader for HentaiNexus with strict full-res image resolving from reader pages, webtoon and manga modes, zoom control, page gap options, theme picker, page jump, standalone close button, cleaned compact theme picker, and low-memory loading.
// @description:ja HentaiNexus向けのギャラリーページ用オーバーレイリーダー。リーダーページから厳密にフル解像度画像を取得、縦スクロール・漫画表示、ズーム調整、ページ間隔、テーマカラー、ページ移動、低メモリ読み込みに対応。
// @description:zh-CN HentaiNexus 画廊页覆盖式阅读器,可从阅读页严格解析完整分辨率图片,支持条漫/漫画模式、缩放控制、页面间距、主题颜色选择、跳转页面和低内存加载。
// ==/UserScript==

(function () {
    'use strict';

    const CONFIG = {
        fullMaxWidth: 1800,
        loadBuffer: 6,
        unloadBuffer: 10,
        rootMargin: '1200px',
        maxRetries: 3,
        menuCollapseMs: 5000,
        longPressMs: 450,
        wheelLockMs: 360,
        defaultThemeColor: '#353a45',
        defaultReadingMode: 'webtoon',
        defaultGap: 5,
        defaultZoom: 0
    };

    const IDS = {
        style: 'hnir-overlay-style',
        launcher: 'hnir-open-book-launcher',
        overlay: 'hnir-overlay',
        reader: 'hnir-overlay-reader',
        uiRoot: 'hnir-overlay-ui-root',
        progressBar: 'hnir-progress-bar',
        pageIndicator: 'hnir-page-indicator'
    };

    const STORAGE = {
        settings: 'hnir-overlay-settings-v25'
    };

    const ICONS = {
        openBook: '<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true"><path d="M4 5.5c2.8 0 5 .7 8 2.5v12c-3-1.8-5.2-2.5-8-2.5z"></path><path d="M20 5.5c-2.8 0-5 .7-8 2.5v12c3-1.8 5.2-2.5 8-2.5z"></path><path d="M12 8v12"></path></svg>',
        closedBook: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6.5 4.5h10A2.5 2.5 0 0 1 19 7v13H8a3 3 0 0 1-3-3V6a1.5 1.5 0 0 1 1.5-1.5z"></path><path d="M8 4.5V20"></path><path d="M8 17h11"></path></svg>',
        menu: '<svg viewBox="0 0 24 24" width="21" height="21" aria-hidden="true"><path d="M5 7h14"></path><path d="M5 12h14"></path><path d="M5 17h14"></path></svg>',
        up: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 19V5"></path><path d="M5 12l7-7 7 7"></path></svg>',
        mode: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M4 5h16"></path><path d="M4 12h16"></path><path d="M4 19h16"></path><path d="M8 3v18"></path><path d="M16 3v18"></path></svg>',
        fit: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M9 4H4v5"></path><path d="M15 4h5v5"></path><path d="M9 20H4v-5"></path><path d="M15 20h5v-5"></path></svg>',
        gap: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M6 5h12"></path><path d="M6 12h12"></path><path d="M6 19h12"></path><path d="M12 7v3"></path><path d="M12 14v3"></path></svg>',
        reader: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M7 4.5h6.75A3.25 3.25 0 0 1 17 7.75V19a2.5 2.5 0 0 0-2.5-2.5H7z"></path></svg>',
        theme: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><circle cx="12" cy="12" r="4.5"></circle><path d="M12 2.5v2.5"></path><path d="M12 19v2.5"></path><path d="M4.9 4.9l1.8 1.8"></path><path d="M17.3 17.3l1.8 1.8"></path><path d="M2.5 12H5"></path><path d="M19 12h2.5"></path><path d="M4.9 19.1l1.8-1.8"></path><path d="M17.3 6.7l1.8-1.8"></path></svg>'
    };

    let imagePages = [];
    let totalPages = 0;
    let scrollTicking = false;
    let state = loadSettings();

    const wait = setInterval(() => {
        refreshGalleryImagePages();

        if (!totalPages) return;

        clearInterval(wait);
        ensureStyle();
        mountLauncher();
    }, 100);

    setTimeout(() => {
        if (!totalPages) {
            clearInterval(wait);
        }
    }, 15000);

    function refreshGalleryImagePages() {
        imagePages = extractGalleryImagePages(document);
        totalPages = imagePages.length;
    }

    function extractGalleryImagePages(root) {
        const pagesByKey = new Map();
        const anchors = Array.from(root.querySelectorAll('a[href*="/read/"]'));

        anchors.forEach((anchor, index) => {
            const parsed = parseReadPageLink(anchor.getAttribute('href'));
            if (!parsed) return;

            const img = anchor.querySelector('img');
            const thumb = getBestImageSource(img, location.href);

            const key = `${parsed.galleryId}:${parsed.pageLabel}`;

            if (pagesByKey.has(key)) return;

            pagesByKey.set(key, {
                image: '',
                thumb,
                readUrl: parsed.href,
                url_label: parsed.pageLabel,
                pageNo: parsed.pageNo,
                originalIndex: index,
                fullImageResolved: false,
                fullImageResolvePromise: null
            });
        });

        return Array.from(pagesByKey.values())
            .sort((a, b) => {
                if (a.pageNo !== b.pageNo) return a.pageNo - b.pageNo;
                return a.originalIndex - b.originalIndex;
            })
            .map((page, originalIndex) => ({ ...page, originalIndex }));
    }

    function parseReadPageLink(href) {
        if (!href) return null;

        let url;

        try {
            url = new URL(href, location.origin);
        } catch {
            return null;
        }

        const match = url.pathname.match(/^\/read\/(\d+)\/([^/?#]+)\/?$/i);

        if (!match) return null;

        const galleryId = match[1];
        const pageLabel = decodeURIComponent(match[2]);
        const pageNo = parsePageNoFromText(pageLabel);

        if (!pageNo) return null;

        return {
            href: url.href,
            galleryId,
            pageLabel,
            pageNo
        };
    }

    function getBestImageSource(img, baseUrl = location.href) {
        if (!img) return '';

        const candidates = [
            img.getAttribute('data-src'),
            img.getAttribute('data-original'),
            img.getAttribute('data-lazy-src'),
            img.currentSrc,
            img.getAttribute('src')
        ].filter(Boolean);

        const srcset = img.getAttribute('srcset') || img.getAttribute('data-srcset') || '';

        if (srcset) {
            srcset.split(',').forEach(part => {
                const candidate = part.trim().split(/\s+/)[0];
                if (candidate) candidates.push(candidate);
            });
        }

        const picked = candidates.find(Boolean) || '';

        try {
            return picked ? new URL(picked, baseUrl).href : '';
        } catch {
            return picked;
        }
    }

    function toAbsoluteUrl(value, base = location.href) {
        if (!value) return '';

        try {
            return new URL(value, base).href;
        } catch {
            return String(value || '').trim();
        }
    }

    function stripUrlNoise(value, base = location.href) {
        const absolute = toAbsoluteUrl(value, base);

        if (!absolute) return '';

        try {
            const url = new URL(absolute);
            url.hash = '';
            url.search = '';
            return url.href;
        } catch {
            return absolute.replace(/[?#].*$/, '');
        }
    }

    function isThumbnailUrl(src) {
        return /\.thumb\.(?:jpg|jpeg|png|webp)(?:[?#].*)?$/i.test(String(src || '')) || /\.thumb\./i.test(String(src || ''));
    }

    function deriveFullImageUrlFromThumb(src) {
        if (!src) return '';

        const clean = toAbsoluteUrl(src, location.href).trim();

        if (!clean || !isThumbnailUrl(clean)) return '';

        return clean.replace(/\.thumb\.(?:jpg|jpeg|png|webp)(?=([?#]|$))/i, '');
    }

    function getUrlFilename(src, base = location.href) {
        const clean = stripUrlNoise(src, base);

        if (!clean) return '';

        try {
            const url = new URL(clean, base);
            return decodeURIComponent((url.pathname.split('/').pop() || '').trim());
        } catch {
            const fallback = String(clean).split('/').pop() || '';
            return decodeURIComponent(fallback.replace(/[?#].*$/, '').trim());
        }
    }

    function imageFilenameMatchesPage(src, page, base = location.href) {
        const filename = getUrlFilename(src, base).replace(/\.thumb\.(?:jpg|jpeg|png|webp)$/i, '');

        if (!filename) return false;

        const stem = filename.replace(/\.(?:png|jpe?g|webp|gif)$/i, '');
        const labels = pageLabelCandidates(page)
            .filter(label => /^\d+$/.test(label))
            .sort((a, b) => b.length - a.length);

        return labels.some(label => {
            const pattern = new RegExp(`(^|\\D)${escapeRegExp(label)}(\\D|$)`);
            return pattern.test(stem);
        });
    }

    function escapeRegExp(value) {
        return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    }


    function selectorEscape(value) {
        // Tiny CSS attribute-selector escape for numeric/page labels.
        return String(value || '').replace(/[\\"\]]/g, '\\$&');
    }
    function pageLabelCandidates(page) {
        const labels = new Set();
        const rawLabel = String(page?.url_label || '').trim();
        const numeric = Number.parseInt(page?.pageNo || rawLabel, 10);

        if (rawLabel) labels.add(rawLabel);

        if (Number.isFinite(numeric) && numeric >= 1) {
            labels.add(String(numeric));

            for (const length of [2, 3, 4, 5]) {
                labels.add(String(numeric).padStart(length, '0'));
            }
        }

        return Array.from(labels).filter(Boolean);
    }

    function pageFilePattern(page) {
        const labels = pageLabelCandidates(page).map(escapeRegExp);

        if (!labels.length) {
            return /\.(?:png|jpe?g|webp|gif)(?:[?#]|$)/i;
        }

        return new RegExp(`/(?:${labels.join('|')})\\.(?:png|jpe?g|webp|gif)(?:[?#]|$)`, 'i');
    }

    function isImageUrl(src) {
        return /\.(?:png|jpe?g|webp|gif)(?:[?#]|$)/i.test(String(src || ''));
    }

    function isReaderImageCdnUrl(src) {
        return /images\.hentainexus\.com\/v2\//i.test(String(src || ''));
    }

    function isUsableFullImageUrl(src, page, options = {}) {
        if (!src) return false;

        const clean = stripUrlNoise(src, page?.readUrl || location.href);
        const thumb = stripUrlNoise(page?.thumb || '', location.href);

        if (!clean) return false;
        if (isThumbnailUrl(clean)) return false;
        if (thumb && clean === thumb) return false;
        if (!isImageUrl(clean)) return false;

        if (pageFilePattern(page).test(clean) || imageFilenameMatchesPage(clean, page)) {
            return true;
        }

        // The native reader's main #reader_image is authoritative. Some galleries use padded
        // file names differently from their /read/{gallery}/{page} URL, so allow a non-thumb
        // image CDN URL from that exact reader slot even when numeric label formatting differs.
        return Boolean(options.fromReaderSlot && isReaderImageCdnUrl(clean));
    }

    function pickBestFullImageCandidate(candidates, page, baseUrl) {
        const seen = new Set();
        const usable = [];

        candidates.forEach(candidate => {
            const absolute = toAbsoluteUrl(candidate, baseUrl || page?.readUrl || location.href);
            const clean = stripUrlNoise(absolute, baseUrl || page?.readUrl || location.href);

            if (!clean || seen.has(clean)) return;
            seen.add(clean);

            if (isUsableFullImageUrl(absolute, page)) {
                usable.push(absolute);
            }
        });

        if (!usable.length) return '';

        return (
            usable.find(src => /images\.hentainexus\.com\/v2\//i.test(src) && pageFilePattern(page).test(src)) ||
            usable.find(src => /images\.hentainexus\.com\/v2\//i.test(src) && imageFilenameMatchesPage(src, page, baseUrl)) ||
            usable.find(src => pageFilePattern(page).test(src)) ||
            usable.find(src => imageFilenameMatchesPage(src, page, baseUrl)) ||
            usable[0]
        );
    }

    function extractFullImageSrcFromReaderDocument(doc, baseUrl, page) {
        if (!doc) return '';

        const readerSlotCandidates = [];

        doc.querySelectorAll('#reader_image img, figure#reader_image img, #nextLink #reader_image img, #nextLink img').forEach(img => {
            const picked = getBestImageSource(img, baseUrl);
            if (picked) readerSlotCandidates.push(picked);
        });

        const directReaderSlot = readerSlotCandidates
            .map(src => toAbsoluteUrl(src, baseUrl))
            .find(src => isUsableFullImageUrl(src, page, { fromReaderSlot: true }));

        if (directReaderSlot) return directReaderSlot;

        const candidates = [...readerSlotCandidates];
        const selectors = [
            ...pageLabelCandidates(page).map(label => `img[src*="/${selectorEscape(label)}."]`),
            'img[src*="images.hentainexus.com/v2/"]'
        ];

        selectors.forEach(selector => {
            doc.querySelectorAll(selector).forEach(img => {
                const picked = getBestImageSource(img, baseUrl);
                if (picked) candidates.push(picked);
            });
        });

        return pickBestFullImageCandidate(candidates, page, baseUrl);
    }

    function extractFullImageSrcFromReaderHtml(html, baseUrl, page) {
        if (!html) return '';

        const candidates = [];

        try {
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const fromDoc = extractFullImageSrcFromReaderDocument(doc, baseUrl, page);
            if (fromDoc) candidates.push(fromDoc);
        } catch (error) {
            console.warn('[HNIR] DOMParser failed while resolving reader image:', error);
        }

        const imgSrcRegex = /<img\b[^>]*\bsrc\s*=\s*(["'])(.*?)\1/gi;
        let match;

        while ((match = imgSrcRegex.exec(html))) {
            candidates.push(match[2]);
        }

        const escapedCdnRegex = /https?:\\?\/\\?\/images\.hentainexus\.com\\?\/v2\\?\/[^"'<>\s]+/gi;

        while ((match = escapedCdnRegex.exec(html))) {
            candidates.push(match[0].replace(/\\\//g, '/'));
        }

        return pickBestFullImageCandidate(candidates, page, baseUrl);
    }

    async function resolveFullImageByFetch(readUrl, page) {
        const response = await fetch(readUrl, {
            credentials: 'include',
            cache: 'no-store',
            headers: {
                Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
            }
        });

        if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
        }

        const html = await response.text();
        return extractFullImageSrcFromReaderHtml(html, readUrl, page);
    }

    function resolveFullImageByIframe(readUrl, page) {
        return new Promise((resolve, reject) => {
            const iframe = document.createElement('iframe');
            let finished = false;
            let observer = null;

            const cleanup = () => {
                if (observer) {
                    observer.disconnect();
                    observer = null;
                }

                iframe.onload = null;
                iframe.onerror = null;

                if (iframe.isConnected) {
                    iframe.remove();
                }
            };

            const finish = value => {
                if (finished) return;
                finished = true;
                cleanup();
                resolve(value || '');
            };

            const fail = error => {
                if (finished) return;
                finished = true;
                cleanup();
                reject(error);
            };

            const tryExtract = () => {
                try {
                    const doc = iframe.contentDocument || iframe.contentWindow?.document;
                    const src = extractFullImageSrcFromReaderDocument(doc, readUrl, page);

                    if (src) {
                        finish(src);
                        return true;
                    }
                } catch (error) {
                    fail(error);
                    return true;
                }

                return false;
            };

            iframe.style.cssText = [
                'position:fixed',
                'left:-10000px',
                'top:-10000px',
                'width:1px',
                'height:1px',
                'opacity:0',
                'pointer-events:none',
                'border:0'
            ].join(';');
            iframe.className = 'hnir-resolver-frame';
            iframe.tabIndex = -1;
            iframe.setAttribute('aria-hidden', 'true');

            iframe.onload = () => {
                if (tryExtract()) return;

                try {
                    const doc = iframe.contentDocument || iframe.contentWindow?.document;

                    observer = new MutationObserver(() => {
                        tryExtract();
                    });

                    observer.observe(doc.documentElement || doc.body, {
                        childList: true,
                        subtree: true,
                        attributes: true,
                        attributeFilter: ['src', 'data-src', 'data-original', 'data-lazy-src']
                    });

                    setTimeout(tryExtract, 250);
                    setTimeout(tryExtract, 700);
                    setTimeout(tryExtract, 1400);
                } catch (error) {
                    fail(error);
                }
            };

            iframe.onerror = () => fail(new Error('Reader iframe failed to load'));

            setTimeout(() => {
                if (!finished) {
                    fail(new Error('Reader iframe full-res resolve timed out'));
                }
            }, 7000);

            document.body.appendChild(iframe);
            iframe.src = readUrl;
        });
    }

    async function resolveFullImageSrcForPage(pageNo, force = false) {
        const page = imagePages[clampPage(pageNo) - 1];

        if (!page) return '';

        if (!force && page.fullImageResolved && isUsableFullImageUrl(page.image, page)) {
            return page.image;
        }

        if (!force && page.fullImageResolvePromise) {
            return page.fullImageResolvePromise;
        }

        const readUrl = page.readUrl;

        if (!readUrl) {
            console.warn('[HNIR] Missing reader URL for page:', pageNo, page);
            return '';
        }

        page.fullImageResolvePromise = (async () => {
            const errors = [];
            const resolvers = [
                ['fetch', () => resolveFullImageByFetch(readUrl, page)],
                ['iframe', () => resolveFullImageByIframe(readUrl, page)],
                ['thumb-derived', () => Promise.resolve(deriveFullImageUrlFromThumb(page.thumb))]
            ];

            for (const [name, resolver] of resolvers) {
                try {
                    const src = await resolver();

                    if (isUsableFullImageUrl(src, page)) {
                        page.image = toAbsoluteUrl(src, readUrl);
                        page.fullImageResolved = true;
                        return page.image;
                    }

                    errors.push(`${name}: no usable full-res src`);
                } catch (error) {
                    errors.push(`${name}: ${error?.message || error}`);
                }
            }

            page.fullImageResolved = false;
            console.warn('[HNIR] Could not resolve full-res image. Thumbnail-derived full URL also failed.', {
                pageNo,
                readUrl,
                thumb: page.thumb,
                errors
            });
            return '';
        })();

        try {
            return await page.fullImageResolvePromise;
        } finally {
            page.fullImageResolvePromise = null;
        }
    }

    function parsePageNoFromText(value) {
        if (value === null || value === undefined) return null;

        const text = String(value).trim();

        if (!text) return null;

        const exact = parseInt(text, 10);

        if (Number.isFinite(exact) && exact >= 1) {
            return exact;
        }

        const match = text.match(/(?:^|\/|#)(\d{1,5})(?:\.|[/?#]|$)/);

        if (!match) return null;

        const num = parseInt(match[1], 10);

        if (Number.isFinite(num) && num >= 1) {
            return num;
        }

        return null;
    }

    function loadSettings() {
        const fallback = {
            themeColor: CONFIG.defaultThemeColor,
            readingMode: CONFIG.defaultReadingMode,
            gap: CONFIG.defaultGap,
            zoom: CONFIG.defaultZoom,

            overlay: null,
            reader: null,
            ui: null,
            observer: null,

            currentPageNo: 1,
            suppressPageTrackingUntil: 0,

            menuOpen: false,
            openPanelName: null,
            pointerInsideUI: false,
            menuCollapseTimer: 0,

            wheelLocked: false,
            wheelUnlockTimer: 0,

            lastOverlayScrollTop: 0,
            uiAutoHidden: false,

            oldBodyOverflow: '',
            oldHtmlOverflow: '',
            oldWindowScrollX: 0,
            oldWindowScrollY: 0,
            hiddenNativeNodes: [],

            picker: null
        };

        try {
            return Object.assign(fallback, JSON.parse(localStorage.getItem(STORAGE.settings) || '{}'));
        } catch {
            return fallback;
        }
    }

    function saveSettings() {
        localStorage.setItem(STORAGE.settings, JSON.stringify({
            themeColor: state.themeColor,
            readingMode: state.readingMode,
            gap: state.gap,
            zoom: state.zoom
        }));
    }

    function mountLauncher() {
        if (document.getElementById(IDS.launcher)) return;

        const button = document.createElement('button');
        button.id = IDS.launcher;
        button.type = 'button';
        button.innerHTML = ICONS.openBook;
        button.title = 'Open overlay reader';
        button.setAttribute('aria-label', 'Open overlay reader');

        button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            openOverlayFromGallery();
        });

        document.body.appendChild(button);
    }

    function openOverlayFromGallery() {
        refreshGalleryImagePages();

        if (!totalPages) {
            alert('HNIR: Could not find gallery page thumbnails on this view page.');
            return;
        }

        openOverlay(1);
    }

    function openOverlay(startPageNo) {
        if (state.overlay?.isConnected) return;

        state.currentPageNo = clampPage(startPageNo || 1);
        state.lastOverlayScrollTop = 0;
        state.uiAutoHidden = false;
        state.oldWindowScrollX = window.scrollX || 0;
        state.oldWindowScrollY = window.scrollY || 0;

        state.oldBodyOverflow = document.body.style.overflow;
        state.oldHtmlOverflow = document.documentElement.style.overflow;
        state.hiddenNativeNodes = hideNativePageForDocumentReader();

        document.body.classList.add('hnir-document-reader-open');
        document.documentElement.classList.add('hnir-document-reader-open');
        document.body.style.overflowX = 'hidden';
        document.documentElement.style.overflowX = 'hidden';

        const launcher = document.getElementById(IDS.launcher);
        if (launcher) launcher.style.display = 'none';

        const overlay = document.createElement('div');
        overlay.id = IDS.overlay;

        const reader = document.createElement('div');
        reader.id = IDS.reader;
        reader.className = 'hnir-reader';

        overlay.appendChild(reader);
        document.body.appendChild(overlay);

        state.overlay = overlay;
        state.reader = reader;

        createProgressBar(overlay);
        createPageIndicator(overlay);
        mountOverlayUI(overlay);

        applyAllSettings();
        renderReader(state.currentPageNo);
        setupOverlayEvents();

        scrollReaderTo(0, 'auto');
        stabilizedJumpToPage(state.currentPageNo, 'auto');
    }

    function closeOverlay() {
        if (!state.overlay) return;

        clearMenuCollapse();
        clearTimeout(state.wheelUnlockTimer);

        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        removeOverlayEvents();

        if (state.overlay.isConnected) {
            state.overlay.remove();
        }

        restoreNativePageAfterDocumentReader();

        state.overlay = null;
        state.reader = null;
        state.ui = null;
        state.menuOpen = false;
        state.openPanelName = null;
        state.pointerInsideUI = false;
        state.wheelLocked = false;
        state.uiAutoHidden = false;
        state.lastOverlayScrollTop = 0;

        document.body.classList.remove('hnir-document-reader-open');
        document.documentElement.classList.remove('hnir-document-reader-open');
        document.body.style.overflow = state.oldBodyOverflow || '';
        document.documentElement.style.overflow = state.oldHtmlOverflow || '';

        const launcher = document.getElementById(IDS.launcher);
        if (launcher) launcher.style.display = '';

        window.scrollTo({
            left: state.oldWindowScrollX || 0,
            top: state.oldWindowScrollY || 0,
            behavior: 'auto'
        });
    }


    function hideNativePageForDocumentReader() {
        const hidden = [];

        Array.from(document.body.children).forEach(node => {
            if (!node || node.id === IDS.overlay || node.id === IDS.launcher) return;
            if (node.classList?.contains('hnir-resolver-frame')) return;

            hidden.push({ node, display: node.style.display });
            node.style.display = 'none';
        });

        return hidden;
    }

    function restoreNativePageAfterDocumentReader() {
        const hidden = Array.isArray(state.hiddenNativeNodes) ? state.hiddenNativeNodes : [];

        hidden.forEach(entry => {
            if (entry?.node?.style) {
                entry.node.style.display = entry.display || '';
            }
        });

        state.hiddenNativeNodes = [];
    }

    function getReaderScrollTop() {
        return window.scrollY || document.documentElement.scrollTop || document.body.scrollTop || 0;
    }

    function getReaderViewportHeight() {
        return window.innerHeight || document.documentElement.clientHeight || 0;
    }

    function getReaderScrollHeight() {
        if (state.overlay) {
            return Math.max(state.overlay.scrollHeight, document.documentElement.scrollHeight, document.body.scrollHeight);
        }

        return Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);
    }

    function getReaderMaxScrollTop() {
        return Math.max(0, getReaderScrollHeight() - getReaderViewportHeight());
    }

    function scrollReaderTo(top, behavior = 'auto') {
        window.scrollTo({
            top: Math.max(0, Math.min(getReaderMaxScrollTop(), Number(top) || 0)),
            behavior
        });
    }

    function renderReader(targetPageNo = state.currentPageNo) {
        if (!state.reader) return;

        if (state.observer) {
            state.observer.disconnect();
            state.observer = null;
        }

        state.reader.innerHTML = '';
        state.reader.className = `hnir-reader hnir-mode-${state.readingMode}`;

        if (state.readingMode === 'manga') {
            renderMangaReader();
        } else {
            renderWebtoonReader();
        }

        applyGap();
        applyZoom();
        setupObserver();

        stabilizedJumpToPage(targetPageNo, 'auto');
    }

    function renderWebtoonReader() {
        imagePages.forEach((page, index) => {
            const pageNo = index + 1;

            const wrapper = document.createElement('div');
            wrapper.className = 'hnir-page';
            wrapper.dataset.pageNo = pageNo;
            wrapper.dataset.index = page.originalIndex;
            wrapper.dataset.observePage = pageNo;

            const img = createImage(page, pageNo);
            const errorBox = createErrorBox(img);

            wrapper.append(img, errorBox);
            state.reader.appendChild(wrapper);
        });
    }

    function renderMangaReader() {
        for (let i = 0; i < imagePages.length; i += 2) {
            const firstPageNo = i + 1;
            const secondPageNo = i + 2;

            const spread = document.createElement('div');
            spread.className = 'hnir-spread';
            spread.dataset.spreadFirst = firstPageNo;
            spread.dataset.observePage = firstPageNo;

            const leftSlot = createMangaSlot(secondPageNo);
            const rightSlot = createMangaSlot(firstPageNo);

            spread.append(leftSlot, rightSlot);
            state.reader.appendChild(spread);
        }
    }

    function createMangaSlot(pageNo) {
        const slot = document.createElement('div');
        slot.className = 'hnir-manga-slot';

        if (pageNo > totalPages) {
            slot.classList.add('is-empty');
            return slot;
        }

        const page = imagePages[pageNo - 1];
        const img = createImage(page, pageNo);
        const errorBox = createErrorBox(img);

        slot.dataset.pageNo = pageNo;
        slot.append(img, errorBox);

        return slot;
    }

    function createImage(page, pageNo) {
        const img = document.createElement('img');
        img.className = 'hnir-image';
        img.dataset.readUrl = page.readUrl || '';
        img.dataset.retryCount = '0';
        img.dataset.pageNo = pageNo;
        img.alt = `Page ${pageNo}`;
        img.decoding = 'async';
        img.loading = 'lazy';
        img.crossOrigin = 'anonymous';
        return img;
    }

    function createErrorBox(img) {
        const errorBox = document.createElement('div');
        errorBox.className = 'hnir-error';
        errorBox.innerHTML = `
            <div>Full-res image failed to load.</div>
            <button type="button">Retry</button>
        `;

        errorBox.querySelector('button').addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            img.dataset.retryCount = '0';
            errorBox.classList.remove('visible');
            loadImage(img, true);
        });

        return errorBox;
    }

    function setupObserver() {
        if (!state.overlay || !state.reader) return;

        state.observer = new IntersectionObserver(entries => {
            entries.forEach(entry => {
                if (!entry.isIntersecting) return;

                const target = entry.target;

                target.querySelectorAll('img[data-page-no]').forEach(img => {
                    loadImage(img);
                });

                const pageNo = parseInt(
                    target.dataset.pageNo ||
                    target.dataset.spreadFirst ||
                    target.dataset.observePage ||
                    state.currentPageNo,
                    10
                );

                maintainMemory(pageNo);
            });
        }, {
            root: null,
            rootMargin: CONFIG.rootMargin
        });

        state.reader.querySelectorAll('[data-observe-page]').forEach(el => {
            state.observer.observe(el);
        });
    }

    function setupOverlayEvents() {
        if (!state.overlay) return;

        window.__HNIR_KEY_HANDLER__ = handleKeydown;
        window.__HNIR_RESIZE_HANDLER__ = handleResize;
        window.__HNIR_SCROLL_HANDLER__ = handleOverlayScroll;
        state.overlay.__HNIR_WHEEL_HANDLER__ = handleOverlayWheel;

        window.addEventListener('keydown', window.__HNIR_KEY_HANDLER__);
        window.addEventListener('resize', window.__HNIR_RESIZE_HANDLER__, { passive: true });
        window.addEventListener('scroll', window.__HNIR_SCROLL_HANDLER__, { passive: true });
        state.overlay.addEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__, { passive: false });
    }

    function removeOverlayEvents() {
        if (window.__HNIR_KEY_HANDLER__) {
            window.removeEventListener('keydown', window.__HNIR_KEY_HANDLER__);
            window.__HNIR_KEY_HANDLER__ = null;
        }

        if (window.__HNIR_RESIZE_HANDLER__) {
            window.removeEventListener('resize', window.__HNIR_RESIZE_HANDLER__);
            window.__HNIR_RESIZE_HANDLER__ = null;
        }

        if (window.__HNIR_SCROLL_HANDLER__) {
            window.removeEventListener('scroll', window.__HNIR_SCROLL_HANDLER__);
            window.__HNIR_SCROLL_HANDLER__ = null;
        }

        if (state.overlay?.__HNIR_WHEEL_HANDLER__) {
            state.overlay.removeEventListener('wheel', state.overlay.__HNIR_WHEEL_HANDLER__);
        }
    }

    function handleOverlayScroll() {
        if (!state.overlay) return;

        const scrollTop = getReaderScrollTop();
        const delta = scrollTop - (state.lastOverlayScrollTop || 0);
        state.lastOverlayScrollTop = scrollTop;

        updateAutoHiddenUiFromScroll(delta);

        if (scrollTicking) return;

        scrollTicking = true;

        requestAnimationFrame(() => {
            updateCurrentPage();
            updateProgressBar();
            scrollTicking = false;
        });
    }

    function updateAutoHiddenUiFromScroll(delta) {
        if (!state.overlay || !state.ui) return;
        if (!isMobileViewport()) return;
        if (state.menuOpen || state.pointerInsideUI) {
            setOverlayUiAutoHidden(false);
            return;
        }

        if (Math.abs(delta) < 6) return;

        // Reading forward on mobile should get the controls out of the way.
        // Scrolling back brings them back, similar to mobile browser chrome.
        setOverlayUiAutoHidden(delta > 0);
    }

    function setOverlayUiAutoHidden(hidden) {
        if (!state.overlay) return;

        const shouldHide = Boolean(hidden);

        if (state.uiAutoHidden === shouldHide) return;

        state.uiAutoHidden = shouldHide;
        state.overlay.classList.toggle('hnir-ui-auto-hidden', shouldHide);
    }

    function isMobileViewport() {
        return window.matchMedia?.('(max-width: 700px)').matches || window.innerWidth <= 700;
    }

    function handleOverlayWheel(event) {
        if (!state.overlay) return;
        if (state.zoom !== 0) return;

        event.preventDefault();

        if (state.wheelLocked) return;

        state.wheelLocked = true;

        clearTimeout(state.wheelUnlockTimer);
        state.wheelUnlockTimer = setTimeout(() => {
            state.wheelLocked = false;
        }, CONFIG.wheelLockMs);

        if (event.deltaY > 0) {
            goNextUnit();
        } else if (event.deltaY < 0) {
            goPreviousUnit();
        }
    }

    function handleKeydown(event) {
        if (!state.overlay) return;

        if (event.key === 'Escape') {
            event.preventDefault();
            closeOverlay();
            return;
        }

        const tag = document.activeElement?.tagName?.toLowerCase();

        if (tag === 'input' || tag === 'select' || tag === 'textarea') {
            return;
        }

        if (event.key.toLowerCase() === 't') {
            event.preventDefault();
            openPanel('theme');
            return;
        }

        if (event.key.toLowerCase() === 'f') {
            event.preventDefault();
            updateZoom(state.zoom === 0 ? 100 : 0, true);
            return;
        }

        if (event.key === '+' || event.key === '=') {
            event.preventDefault();
            updateZoom(state.zoom + 5, true);
            return;
        }

        if (event.key === '-' || event.key === '_') {
            event.preventDefault();
            updateZoom(state.zoom - 5, true);
            return;
        }

        if (event.key === '0') {
            event.preventDefault();
            updateZoom(0, true);
            return;
        }

        if (state.zoom !== 0) return;

        if (state.readingMode === 'manga') {
            if (event.key === 'ArrowLeft' || event.key === 'PageDown' || event.key === ' ') {
                event.preventDefault();
                goNextUnit();
            }

            if (event.key === 'ArrowRight' || event.key === 'PageUp') {
                event.preventDefault();
                goPreviousUnit();
            }

            return;
        }

        if (event.key === 'ArrowDown' || event.key === 'PageDown' || event.key === ' ') {
            event.preventDefault();
            goNextUnit();
        }

        if (event.key === 'ArrowUp' || event.key === 'PageUp') {
            event.preventDefault();
            goPreviousUnit();
        }
    }

    function handleResize() {
        applyZoom();
        updateProgressBar();
    }

    function goNextUnit() {
        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
            jumpToPage(Math.min(totalPages, spreadFirst + 2), 'smooth');
        } else {
            jumpToPage(Math.min(totalPages, state.currentPageNo + 1), 'smooth');
        }
    }

    function goPreviousUnit() {
        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(state.currentPageNo);
            jumpToPage(Math.max(1, spreadFirst - 2), 'smooth');
        } else {
            jumpToPage(Math.max(1, state.currentPageNo - 1), 'smooth');
        }
    }

    function getSpreadFirstFromPage(pageNo) {
        const safe = clampPage(pageNo);
        return safe % 2 === 0 ? safe - 1 : safe;
    }

    function preloadImagesAroundPage(pageNo) {
        if (!state.reader) return;

        const safePageNo = clampPage(pageNo);
        const start = Math.max(1, safePageNo - 2);
        const end = Math.min(totalPages, safePageNo + 2);

        state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
            const imgPageNo = parseInt(img.dataset.pageNo, 10);

            if (imgPageNo >= start && imgPageNo <= end) {
                loadImage(img);
            }
        });
    }

    function stabilizedJumpToPage(pageNo, behavior = 'auto') {
        const safePageNo = clampPage(pageNo);

        state.suppressPageTrackingUntil = Date.now() + 2600;

        preloadImagesAroundPage(safePageNo);
        maintainMemory(safePageNo);

        requestAnimationFrame(() => {
            forceJumpToPage(safePageNo, behavior);

            requestAnimationFrame(() => {
                forceJumpToPage(safePageNo, 'auto');
            });
        });

        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 120);
        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 350);
        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 800);
        setTimeout(() => forceJumpToPage(safePageNo, 'auto'), 1400);

        setTimeout(() => {
            updatePageIndicator();
            updatePageInput();
            updateProgressBar();
            maintainMemory(safePageNo);
        }, 1700);
    }

    function forceJumpToPage(pageNo, behavior = 'auto') {
        if (!state.reader || !state.overlay) return;

        const safePageNo = clampPage(pageNo);
        let target;

        if (state.readingMode === 'manga') {
            const spreadFirst = getSpreadFirstFromPage(safePageNo);
            target = state.reader.querySelector(`.hnir-spread[data-spread-first="${spreadFirst}"]`);
            state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
        } else {
            target = state.reader.querySelector(`.hnir-page[data-page-no="${safePageNo}"]`);
            state.currentPageNo = safePageNo;
        }

        if (!target) return;

        state.suppressPageTrackingUntil = Math.max(
            state.suppressPageTrackingUntil || 0,
            Date.now() + 900
        );

        const targetRect = target.getBoundingClientRect();
        const targetTop = getReaderScrollTop() + targetRect.top;

        scrollReaderTo(targetTop, behavior);

        maintainMemory(state.currentPageNo);
        updatePageIndicator();
        updatePageInput();
    }

    function jumpToPage(pageNo, behavior = 'smooth') {
        forceJumpToPage(pageNo, behavior);
    }

    function updateCurrentPage() {
        if (!state.overlay || !state.reader) return;

        if (state.suppressPageTrackingUntil && Date.now() < state.suppressPageTrackingUntil) {
            return;
        }

        const selector = state.readingMode === 'manga' ? '.hnir-spread' : '.hnir-page';
        const elements = state.reader.querySelectorAll(selector);
        const viewportCenter = window.innerHeight / 2;

        let closest = null;
        let closestDistance = Infinity;

        elements.forEach(el => {
            const rect = el.getBoundingClientRect();
            const center = rect.top + rect.height / 2;
            const distance = Math.abs(viewportCenter - center);

            if (distance < closestDistance) {
                closestDistance = distance;
                closest = el;
            }
        });

        if (!closest) return;

        if (state.readingMode === 'manga') {
            const spreadFirst = parseInt(closest.dataset.spreadFirst, 10);
            state.currentPageNo = Math.min(totalPages, spreadFirst + 1);
        } else {
            state.currentPageNo = parseInt(closest.dataset.pageNo, 10);
        }

        updatePageIndicator();
        updatePageInput();
        maintainMemory(state.currentPageNo);
    }

    function maintainMemory(currentPage) {
        if (!state.reader) return;

        const safeCurrent = clampPage(currentPage);

        state.reader.querySelectorAll('img[data-page-no]').forEach(img => {
            const pageNo = parseInt(img.dataset.pageNo, 10);
            const distance = Math.abs(pageNo - safeCurrent);

            if (distance <= CONFIG.loadBuffer) {
                loadImage(img);
            }

            if (distance > CONFIG.unloadBuffer) {
                unloadImage(img);
            }
        });
    }

    async function loadImage(img, force = false) {
        if (!img || (!force && (img.src || img.dataset.loading === '1'))) return;

        const pageNo = parseInt(img.dataset.pageNo || '0', 10);
        const errorBox = img.parentElement?.querySelector('.hnir-error');

        if (force) {
            img.classList.remove('loaded');
            img.closest('.hnir-page, .hnir-manga-slot')?.classList.remove('hnir-page-loaded');
            img.removeAttribute('src');
        }

        errorBox?.classList.remove('visible');
        img.dataset.loading = '1';

        const src = await resolveFullImageSrcForPage(pageNo, force);

        if (!src) {
            img.dataset.loading = '0';
            img.dataset.resolveError = 'full-res-src-not-resolved';
            errorBox?.classList.add('visible');
            return;
        }

        delete img.dataset.resolveError;


        if (!force && img.src === src) {
            img.dataset.loading = '0';
            return;
        }

        img.onload = () => {
            img.classList.add('loaded');
            img.closest('.hnir-page, .hnir-manga-slot')?.classList.add('hnir-page-loaded');
            img.dataset.retryCount = '0';
            img.dataset.loading = '0';

            if (img.naturalWidth) {
                img.style.setProperty('--hnir-natural-width', `${img.naturalWidth}px`);
            }

            errorBox?.classList.remove('visible');
        };

        img.onerror = () => {
            img.classList.remove('loaded');
            img.closest('.hnir-page, .hnir-manga-slot')?.classList.remove('hnir-page-loaded');
            img.dataset.loading = '0';

            let retryCount = parseInt(img.dataset.retryCount || '0', 10);
            retryCount += 1;
            img.dataset.retryCount = String(retryCount);

            img.removeAttribute('src');

            if (retryCount <= CONFIG.maxRetries) {
                setTimeout(() => {
                    loadImage(img, true);
                }, 1000 * retryCount);
            } else {
                errorBox?.classList.add('visible');
            }
        };

        img.src = src;
    }

    function unloadImage(img) {
        if (!img || !img.src) return;

        img.classList.remove('loaded');
        img.closest('.hnir-page, .hnir-manga-slot')?.classList.remove('hnir-page-loaded');

        setTimeout(() => {
            img.removeAttribute('src');
        }, 160);
    }

    function mountOverlayUI(overlay) {
        const root = document.createElement('div');
        root.id = IDS.uiRoot;

        const topButtonWrap = document.createElement('div');
        topButtonWrap.className = 'hnir-top-button';

        const topButton = document.createElement('button');
        topButton.type = 'button';
        topButton.className = 'hnir-btn';
        topButton.innerHTML = ICONS.up;
        topButton.title = 'Click: top. Hold: bottom.';
        topButton.setAttribute('aria-label', 'Click to scroll top. Hold to scroll bottom.');

        topButtonWrap.appendChild(topButton);

        const actionStack = document.createElement('div');
        actionStack.className = 'hnir-action-stack';

        const modeItem = createStackItem('mode', ICONS.mode, 'Reading mode');
        const fitItem = createStackItem('fit', ICONS.fit, 'Fit and zoom');
        const gapItem = createStackItem('gap', ICONS.gap, 'Page gap');
        const pageItem = createStackItem('page', ICONS.reader, 'Jump to page');
        const themeItem = createStackItem('theme', ICONS.theme, 'Theme color');

        actionStack.append(
            modeItem.item,
            fitItem.item,
            gapItem.item,
            pageItem.item,
            themeItem.item
        );

        const menuButtonWrap = document.createElement('div');
        menuButtonWrap.className = 'hnir-menu-button';

        const menuButton = document.createElement('button');
        menuButton.type = 'button';
        menuButton.className = 'hnir-btn';
        menuButton.innerHTML = ICONS.menu;
        menuButton.title = 'Menu';
        menuButton.setAttribute('aria-label', 'Menu');

        menuButtonWrap.appendChild(menuButton);

        const closeButtonWrap = document.createElement('div');
        closeButtonWrap.className = 'hnir-close-button';

        const closeButton = document.createElement('button');
        closeButton.type = 'button';
        closeButton.className = 'hnir-btn hnir-close-btn';
        closeButton.innerHTML = ICONS.closedBook;
        closeButton.title = 'Close reader';
        closeButton.setAttribute('aria-label', 'Close reader');

        closeButtonWrap.appendChild(closeButton);

        const bottomControls = document.createElement('div');
        bottomControls.className = 'hnir-bottom-controls';
        bottomControls.append(closeButtonWrap, menuButtonWrap);

        root.append(topButtonWrap, actionStack, bottomControls);
        overlay.appendChild(root);

        const modeToggles = createToggleGroup([
            {
                label: 'Webtoon',
                active: state.readingMode === 'webtoon',
                onSelect() {
                    updateReadingMode('webtoon');
                    armMenuCollapse();
                }
            },
            {
                label: 'Manga',
                active: state.readingMode === 'manga',
                onSelect() {
                    updateReadingMode('manga');
                    armMenuCollapse();
                }
            }
        ]);

        modeItem.panel.appendChild(modeToggles.root);

        fitItem.panel.classList.add('hnir-panel-vertical');

        const fitToggles = createToggleGroup([
            {
                label: 'Fit',
                active: state.zoom === 0,
                onSelect() {
                    updateZoom(0, true);
                    armMenuCollapse();
                }
            },
            {
                label: 'Fullscreen',
                active: state.zoom === 100,
                onSelect() {
                    updateZoom(100, true);
                    armMenuCollapse();
                }
            }
        ]);

        const zoomWrap = document.createElement('div');
        zoomWrap.className = 'hnir-slider-wrap';

        const zoomTop = document.createElement('div');
        zoomTop.className = 'hnir-slider-top';

        const zoomLabel = document.createElement('span');
        zoomLabel.textContent = 'Zoom';

        const zoomValue = document.createElement('span');
        zoomValue.className = 'hnir-slider-value';
        zoomValue.textContent = `${state.zoom}%`;

        zoomTop.append(zoomLabel, zoomValue);

        const zoomRow = document.createElement('div');
        zoomRow.className = 'hnir-slider-row';

        const zoomSlider = document.createElement('input');
        zoomSlider.type = 'range';
        zoomSlider.min = '0';
        zoomSlider.max = '100';
        zoomSlider.step = '1';
        zoomSlider.value = String(state.zoom);

        const zoomReset = document.createElement('button');
        zoomReset.type = 'button';
        zoomReset.textContent = 'Fit';

        zoomRow.append(zoomSlider, zoomReset);
        zoomWrap.append(zoomTop, zoomRow);

        fitItem.panel.append(fitToggles.root, zoomWrap);

        const gapToggles = createToggleGroup([
            {
                label: '0px',
                active: state.gap === 0,
                onSelect() {
                    updateGap(0);
                    armMenuCollapse();
                }
            },
            {
                label: '5px',
                active: state.gap === 5,
                onSelect() {
                    updateGap(5);
                    armMenuCollapse();
                }
            },
            {
                label: '10px',
                active: state.gap === 10,
                onSelect() {
                    updateGap(10);
                    armMenuCollapse();
                }
            },
            {
                label: '15px',
                active: state.gap === 15,
                onSelect() {
                    updateGap(15);
                    armMenuCollapse();
                }
            }
        ]);

        gapItem.panel.appendChild(gapToggles.root);

        const pagePanel = document.createElement('div');
        pagePanel.className = 'hnir-jump';

        const pageInput = document.createElement('input');
        pageInput.type = 'number';
        pageInput.min = '1';
        pageInput.max = String(totalPages);
        pageInput.placeholder = `1-${totalPages}`;

        const pageButton = document.createElement('button');
        pageButton.type = 'button';
        pageButton.textContent = 'Go';

        pagePanel.append(pageInput, pageButton);
        pageItem.panel.appendChild(pagePanel);

        themeItem.panel.classList.add('hnir-panel-vertical');

        const colorPicker = createCustomColorPicker();
        themeItem.panel.appendChild(colorPicker.root);

        root.addEventListener('pointerenter', () => {
            state.pointerInsideUI = true;
            clearMenuCollapse();
        });

        root.addEventListener('pointerleave', () => {
            state.pointerInsideUI = false;
            armMenuCollapse();
        });

        menuButton.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            if (state.menuOpen) {
                closeMenu();
            } else {
                openMenu();
            }
        });

        closeButton.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();
            closeOverlay();
        });

        wirePanelToggle(modeItem, 'mode');
        wirePanelToggle(fitItem, 'fit');
        wirePanelToggle(gapItem, 'gap');
        wirePanelToggle(pageItem, 'page', { noAutoCollapse: true });
        wirePanelToggle(themeItem, 'theme');

        pageButton.addEventListener('click', () => {
            jumpToPage(pageInput.value, 'smooth');
        });

        pageInput.addEventListener('keydown', event => {
            if (event.key === 'Enter') {
                jumpToPage(pageInput.value, 'smooth');
            }
        });

        zoomSlider.addEventListener('input', () => {
            updateZoom(parseInt(zoomSlider.value, 10), false);
        });

        zoomSlider.addEventListener('change', () => {
            saveSettings();
            armMenuCollapse();
        });

        zoomReset.addEventListener('click', () => {
            updateZoom(0, true);
            armMenuCollapse();
        });

        setupTopButton(topButton);

        state.ui = {
            root,
            menuButton,
            topButton,
            items: {
                mode: modeItem,
                fit: fitItem,
                gap: gapItem,
                page: pageItem,
                theme: themeItem
            },
            closeButton,
            modeButtons: modeToggles.buttons,
            fitButtons: fitToggles.buttons,
            gapButtons: gapToggles.buttons,
            pageInput,
            zoomSlider,
            zoomValue,
            color: colorPicker
        };

        updatePageInput();
        syncUI();
    }

    function createCustomColorPicker() {
        const root = document.createElement('div');
        root.className = 'hnir-color-picker';

        const square = document.createElement('div');
        square.className = 'hnir-color-square';

        const knob = document.createElement('div');
        knob.className = 'hnir-color-knob';
        square.appendChild(knob);

        const hueRow = document.createElement('div');
        hueRow.className = 'hnir-hue-row';

        const hueSlider = document.createElement('input');
        hueSlider.className = 'hnir-hue-slider';
        hueSlider.type = 'range';
        hueSlider.min = '0';
        hueSlider.max = '360';
        hueSlider.step = '1';

        const resetButton = document.createElement('button');
        resetButton.type = 'button';
        resetButton.textContent = 'Reset';

        hueRow.append(hueSlider, resetButton);

        root.append(square, hueRow);

        state.picker = hexToHsv(state.themeColor);

        const updateSquareFromPointer = event => {
            const rect = square.getBoundingClientRect();
            const x = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
            const y = Math.max(0, Math.min(rect.height, event.clientY - rect.top));

            state.picker.s = x / rect.width;
            state.picker.v = 1 - (y / rect.height);

            updateThemeColorFromPicker();
        };

        square.addEventListener('pointerdown', event => {
            event.preventDefault();
            square.setPointerCapture(event.pointerId);
            updateSquareFromPointer(event);
        });

        square.addEventListener('pointermove', event => {
            if (event.buttons !== 1) return;
            updateSquareFromPointer(event);
        });

        square.addEventListener('pointerup', () => {
            saveSettings();
        });

        hueSlider.addEventListener('input', () => {
            state.picker.h = parseInt(hueSlider.value, 10);
            updateThemeColorFromPicker();
        });

        hueSlider.addEventListener('change', () => {
            saveSettings();
            armMenuCollapse();
        });

        resetButton.addEventListener('click', () => {
            state.themeColor = CONFIG.defaultThemeColor;
            state.picker = hexToHsv(state.themeColor);
            applyThemeColor();
            updateColorPickerUI();
            saveSettings();
            armMenuCollapse();
        });

        return {
            root,
            square,
            knob,
            hueSlider
        };
    }

    function updateThemeColorFromPicker() {
        state.themeColor = hsvToHex(state.picker.h, state.picker.s, state.picker.v);
        applyThemeColor();
        updateColorPickerUI();
    }

    function updateColorPickerUI() {
        if (!state.ui?.color || !state.picker) return;

        const { square, knob, hueSlider } = state.ui.color;

        hueSlider.value = String(Math.round(state.picker.h));
        square.style.setProperty('--hnir-picker-hue', String(Math.round(state.picker.h)));

        knob.style.left = `${state.picker.s * 100}%`;
        knob.style.top = `${(1 - state.picker.v) * 100}%`;
    }

    function setupTopButton(topButton) {
        let navPressTimer = 0;
        let navLongPressFired = false;

        const clearNavTimer = () => {
            if (navPressTimer) {
                clearTimeout(navPressTimer);
                navPressTimer = 0;
            }
        };

        topButton.addEventListener('pointerdown', event => {
            event.preventDefault();
            event.stopPropagation();

            navLongPressFired = false;
            clearNavTimer();

            navPressTimer = window.setTimeout(() => {
                navLongPressFired = true;

                scrollReaderTo(getReaderMaxScrollTop(), 'smooth');

                navPressTimer = 0;
            }, CONFIG.longPressMs);
        });

        const releaseNav = event => {
            event.preventDefault();
            event.stopPropagation();

            if (navPressTimer) {
                clearNavTimer();

                if (!navLongPressFired) {
                    scrollReaderTo(0, 'smooth');
                }
            }
        };

        topButton.addEventListener('pointerup', releaseNav);
        topButton.addEventListener('pointerleave', clearNavTimer);
        topButton.addEventListener('pointercancel', clearNavTimer);
    }

    function createStackItem(name, icon, label) {
        const item = document.createElement('div');
        item.className = 'hnir-stack-item';
        item.dataset.item = name;

        const panel = document.createElement('div');
        panel.className = 'hnir-panel';

        const button = document.createElement('button');
        button.type = 'button';
        button.className = 'hnir-btn';
        button.innerHTML = icon;
        button.setAttribute('aria-label', label);
        button.title = label;

        item.append(panel, button);

        return { item, panel, button };
    }

    function createToggleGroup(items) {
        const root = document.createElement('div');
        root.className = 'hnir-segmented';

        const buttons = items.map(item => {
            const button = document.createElement('button');
            button.type = 'button';
            button.className = `hnir-chip${item.active ? ' is-active' : ''}`;
            button.textContent = item.label;
            button.addEventListener('click', item.onSelect);
            root.appendChild(button);
            return button;
        });

        return { root, buttons };
    }

    function wirePanelToggle(entry, name, options = {}) {
        entry.button.addEventListener('click', event => {
            event.preventDefault();
            event.stopPropagation();

            const isOpen = entry.item.classList.contains('is-open');

            if (isOpen) {
                closePanels();

                if (!options.noAutoCollapse) {
                    armMenuCollapse();
                }

                return;
            }

            openPanel(name, options);
        });
    }

    function openMenu() {
        if (!state.ui) return;

        setOverlayUiAutoHidden(false);

        state.menuOpen = true;
        state.ui.root.classList.add('is-menu-open');

        armMenuCollapse();
    }

    function closeMenu() {
        if (!state.ui) return;

        clearMenuCollapse();

        state.menuOpen = false;
        state.openPanelName = null;

        state.ui.root.classList.remove('is-menu-open');
        closePanels();
    }

    function openPanel(name, options = {}) {
        if (!state.ui) return;

        setOverlayUiAutoHidden(false);

        state.menuOpen = true;
        state.openPanelName = name;
        state.ui.root.classList.add('is-menu-open');

        Object.entries(state.ui.items).forEach(([key, item]) => {
            item.item.classList.toggle('is-open', key === name);
        });

        if (name === 'theme') {
            updateColorPickerUI();
        }

        if (options.noAutoCollapse) {
            clearMenuCollapse();
            return;
        }

        armMenuCollapse();
    }

    function closePanels() {
        if (!state.ui) return;

        Object.values(state.ui.items).forEach(item => {
            item.item.classList.remove('is-open');
        });

        state.openPanelName = null;
    }

    function clearMenuCollapse() {
        clearTimeout(state.menuCollapseTimer);
        state.menuCollapseTimer = 0;
    }

    function armMenuCollapse() {
        clearMenuCollapse();

        if (!state.menuOpen) return;
        if (state.pointerInsideUI) return;
        if (state.openPanelName === 'page') return;

        state.menuCollapseTimer = window.setTimeout(() => {
            closeMenu();
        }, CONFIG.menuCollapseMs);
    }

    function updateReadingMode(mode) {
        if (state.readingMode === mode) return;

        const current = state.currentPageNo;

        state.readingMode = mode;
        saveSettings();
        renderReader(current);
        syncUI();
    }

    function updateGap(gap) {
        state.gap = gap;
        saveSettings();
        applyGap();
        syncUI();
    }

    function updateZoom(zoom, shouldSave) {
        state.zoom = Math.max(0, Math.min(100, zoom));

        applyZoom();
        syncUI();

        if (shouldSave) {
            saveSettings();
        }
    }

    function applyAllSettings() {
        applyThemeColor();
        applyGap();
        applyZoom();
        syncUI();
    }

    function applyThemeColor() {
        document.documentElement.style.setProperty('--hnir-theme-bg', state.themeColor);
    }

    function applyGap() {
        if (!state.reader) return;
        state.reader.style.gap = `${state.gap}px`;
        document.documentElement.style.setProperty('--hnir-reader-gap', `${state.gap}px`);
    }

    function applyZoom() {
        const viewportBase = Math.max(240, window.innerWidth - 32);
        const minFactor = 0.55;
        const factor = minFactor + (state.zoom / 100) * (1 - minFactor);

        const zoomWidth = Math.round(viewportBase * factor);
        const mangaPageWidth = Math.max(160, Math.floor((zoomWidth - state.gap - 24) / 2));

        document.documentElement.style.setProperty('--hnir-zoom-width', `${zoomWidth}px`);
        document.documentElement.style.setProperty('--hnir-manga-page-width', `${mangaPageWidth}px`);

        if (state.overlay) {
            state.overlay.classList.toggle('hnir-zoom-over', state.zoom > 0);
            state.overlay.classList.toggle('hnir-fit-mode', state.zoom === 0);
        }
    }

    function syncUI() {
        if (!state.ui) return;

        state.ui.modeButtons[0].classList.toggle('is-active', state.readingMode === 'webtoon');
        state.ui.modeButtons[1].classList.toggle('is-active', state.readingMode === 'manga');

        state.ui.fitButtons[0].classList.toggle('is-active', state.zoom === 0);
        state.ui.fitButtons[1].classList.toggle('is-active', state.zoom === 100);

        state.ui.gapButtons[0].classList.toggle('is-active', state.gap === 0);
        state.ui.gapButtons[1].classList.toggle('is-active', state.gap === 5);
        state.ui.gapButtons[2].classList.toggle('is-active', state.gap === 10);
        state.ui.gapButtons[3].classList.toggle('is-active', state.gap === 15);

        state.ui.zoomSlider.value = String(state.zoom);
        state.ui.zoomValue.textContent = `${state.zoom}%`;

        updateColorPickerUI();
        updatePageInput();
    }

    function createProgressBar(parent) {
        const bar = document.createElement('div');
        bar.id = IDS.progressBar;
        parent.appendChild(bar);
    }

    function createPageIndicator(parent) {
        const indicator = document.createElement('div');
        indicator.id = IDS.pageIndicator;
        indicator.textContent = `Page 1 / ${totalPages}`;
        parent.appendChild(indicator);
    }

    function updateProgressBar() {
        const bar = document.getElementById(IDS.progressBar);
        if (!bar || !state.overlay) return;

        const maxScroll = getReaderMaxScrollTop();
        const progress = maxScroll <= 0 ? 0 : (getReaderScrollTop() / maxScroll) * 100;

        bar.style.width = `${Math.max(0, Math.min(100, progress))}%`;
    }

    function updatePageIndicator() {
        const indicator = document.getElementById(IDS.pageIndicator);
        if (!indicator) return;

        if (state.readingMode === 'manga') {
            const first = getSpreadFirstFromPage(state.currentPageNo);
            const second = Math.min(totalPages, first + 1);
            indicator.textContent = `Pages ${first}${second !== first ? '–' + second : ''} / ${totalPages}`;
            return;
        }

        indicator.textContent = `Page ${state.currentPageNo} / ${totalPages}`;
    }

    function updatePageInput() {
        if (!state.ui?.pageInput) return;

        if (document.activeElement !== state.ui.pageInput) {
            state.ui.pageInput.value = String(state.currentPageNo);
        }
    }

    function ensureStyle() {
        if (document.getElementById(IDS.style)) return;

        const style = document.createElement('style');
        style.id = IDS.style;

        style.textContent = `
            :root {
                --hnir-theme-bg: ${state.themeColor};
                --hnir-ui-surface: rgba(245, 241, 232, 0.96);
                --hnir-ui-stroke: rgba(0, 0, 0, 0.08);
                --hnir-ui-text: #2d3137;
                --hnir-ui-track: rgba(0, 0, 0, 0.12);
                --hnir-ui-fill: #303843;
                --hnir-reader-gap: 5px;
                --hnir-zoom-width: calc(100vw - 32px);
                --hnir-manga-page-width: calc((100vw - 48px) / 2);
            }

            #${IDS.launcher} {
                position: fixed;
                right: 20px;
                bottom: 20px;
                z-index: 2147483646;
                width: 50px;
                height: 50px;
                border: 1px solid var(--hnir-ui-stroke);
                border-radius: 50%;
                background: var(--hnir-ui-surface);
                color: var(--hnir-ui-text);
                display: inline-flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition: transform 150ms ease, background-color 150ms ease, color 150ms ease;
            }

            #${IDS.launcher}:hover {
                transform: scale(1.05);
            }

            #${IDS.launcher} svg,
            .hnir-btn svg {
                display: block;
                fill: none;
                stroke: currentColor;
                stroke-width: 2.2;
                stroke-linecap: round;
                stroke-linejoin: round;
                flex: none;
            }

            html.hnir-document-reader-open,
            body.hnir-document-reader-open {
                min-height: 100%;
                overflow-x: hidden !important;
                background: var(--hnir-theme-bg);
            }

            #${IDS.overlay} {
                position: relative;
                width: 100%;
                min-height: 100vh;
                z-index: 2147483647;
                overflow: visible;
                background: var(--hnir-theme-bg);
                color: white;
                transition: background-color 180ms ease;
                overscroll-behavior: contain;
            }

            .hnir-reader {
                min-height: 100vh;
                display: flex;
                flex-direction: column;
                align-items: center;
                gap: var(--hnir-reader-gap);
                padding: 14px 0 120px;
                box-sizing: border-box;
            }

            .hnir-page {
                width: min(100%, ${CONFIG.fullMaxWidth}px);
                min-height: 300px;
                display: flex;
                justify-content: center;
                align-items: center;
                position: relative;
                scroll-margin-top: 10px;
                scroll-margin-bottom: 10px;
            }

            #${IDS.overlay}.hnir-fit-mode .hnir-mode-webtoon .hnir-page {
                min-height: calc(100vh - 32px);
            }

            #${IDS.overlay} .hnir-mode-webtoon .hnir-page.hnir-page-loaded,
            #${IDS.overlay}.hnir-fit-mode .hnir-mode-webtoon .hnir-page.hnir-page-loaded {
                min-height: 0;
                align-items: flex-start;
                scroll-margin-top: 0;
                scroll-margin-bottom: 0;
            }

            .hnir-image {
                display: block;
                width: auto;
                height: auto;
                max-width: min(calc(100vw - 32px), ${CONFIG.fullMaxWidth}px);
                max-height: calc(100vh - 32px);
                border-radius: 10px;
                background: rgba(0, 0, 0, 0.07);
                box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
                object-fit: contain;
                image-rendering: auto;
                opacity: 0;
                transition:
                    opacity 220ms ease,
                    width 180ms ease,
                    max-width 180ms ease,
                    max-height 180ms ease,
                    border-radius 180ms ease,
                    box-shadow 180ms ease;
            }

            .hnir-image.loaded {
                opacity: 1;
            }

            #${IDS.overlay}.hnir-zoom-over .hnir-mode-webtoon .hnir-image {
                width: min(var(--hnir-zoom-width), ${CONFIG.fullMaxWidth}px);
                max-width: none;
                max-height: none;
                object-fit: initial;
            }

            .hnir-mode-manga {
                justify-content: flex-start;
            }

            .hnir-spread {
                min-height: calc(100vh - 32px);
                width: 100%;
                display: flex;
                flex-direction: row;
                justify-content: center;
                align-items: center;
                gap: var(--hnir-reader-gap);
                position: relative;
                scroll-margin-top: 12px;
                scroll-margin-bottom: 12px;
                box-sizing: border-box;
            }

            .hnir-manga-slot {
                position: relative;
                display: flex;
                justify-content: center;
                align-items: center;
                min-width: 0;
            }

            .hnir-manga-slot.is-empty {
                width: min(calc((100vw - 48px) / 2), 820px);
                min-height: 60vh;
            }

            .hnir-mode-manga .hnir-image {
                max-width: min(calc((100vw - 48px) / 2), 900px);
                max-height: calc(100vh - 48px);
            }

            #${IDS.overlay}.hnir-zoom-over .hnir-mode-manga .hnir-image {
                width: var(--hnir-manga-page-width);
                max-width: none;
                max-height: none;
                object-fit: initial;
            }

            .hnir-error {
                display: none;
                position: absolute;
                inset: 0;
                min-height: 220px;
                align-items: center;
                justify-content: center;
                flex-direction: column;
                gap: 12px;
                background: rgba(30, 30, 30, 0.82);
                color: white;
                font-family: "Segoe UI", Arial, sans-serif;
                font-size: 14px;
                text-align: center;
                border-radius: 10px;
            }

            .hnir-error.visible {
                display: flex;
            }

            .hnir-error button,
            .hnir-slider-row button,
            .hnir-jump button,
            .hnir-color-picker button {
                min-width: 58px;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: #303843;
                color: #f7f8fa;
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
            }

            #${IDS.uiRoot} {
                position: fixed;
                right: 20px;
                bottom: 20px;
                z-index: 2147483647;
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 12px;
                pointer-events: none;
                font-family: "Segoe UI", sans-serif;
                user-select: none;
                opacity: 1;
                transform: translateY(0);
                transition: opacity 180ms ease, transform 180ms ease;
            }

            .hnir-top-button,
            .hnir-menu-button,
            .hnir-close-button {
                pointer-events: auto;
            }

            .hnir-bottom-controls {
                display: flex;
                flex-direction: row;
                align-items: center;
                justify-content: flex-end;
                gap: 10px;
                pointer-events: auto;
            }

            .hnir-action-stack {
                display: flex;
                flex-direction: column;
                align-items: flex-end;
                gap: 12px;
                max-height: 0;
                opacity: 0;
                overflow: visible;
                pointer-events: none;
                transition:
                    max-height 260ms cubic-bezier(0.2, 0.8, 0.2, 1),
                    opacity 180ms ease;
            }

            #${IDS.uiRoot}.is-menu-open .hnir-action-stack {
                max-height: 440px;
                opacity: 1;
                pointer-events: auto;
            }

            .hnir-stack-item {
                position: relative;
                display: flex;
                align-items: center;
                justify-content: flex-end;
                min-height: 48px;
                pointer-events: none;
                opacity: 0;
                transform: translate3d(0, 14px, 0) scale(0.96);
                transition:
                    opacity 180ms ease,
                    transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
            }

            #${IDS.uiRoot}.is-menu-open .hnir-stack-item {
                opacity: 1;
                transform: translate3d(0, 0, 0) scale(1);
                pointer-events: auto;
            }

            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(1) { transition-delay: 20ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(2) { transition-delay: 45ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(3) { transition-delay: 70ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(4) { transition-delay: 95ms; }
            #${IDS.uiRoot}.is-menu-open .hnir-stack-item:nth-child(5) { transition-delay: 120ms; }

            .hnir-panel {
                position: absolute;
                right: 56px;
                top: 50%;
                transform: translate3d(12px, -50%, 0);
                display: flex;
                align-items: center;
                gap: 10px;
                height: 46px;
                padding: 0;
                max-width: 0;
                overflow: hidden;
                white-space: nowrap;
                opacity: 0;
                pointer-events: none;
                border: 1px solid var(--hnir-ui-stroke);
                border-radius: 999px;
                background: var(--hnir-ui-surface);
                backdrop-filter: blur(14px);
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition:
                    max-width 220ms ease,
                    opacity 180ms ease,
                    transform 220ms ease,
                    padding 180ms ease;
            }

            .hnir-panel.hnir-panel-vertical {
                height: auto;
                min-height: 46px;
                flex-direction: column;
                align-items: stretch;
                justify-content: center;
                border-radius: 24px;
                white-space: normal;
            }

            .hnir-stack-item.is-open .hnir-panel {
                max-width: 440px;
                padding: 0 12px 0 16px;
                opacity: 1;
                transform: translate3d(0, -50%, 0);
                pointer-events: auto;
            }

            .hnir-stack-item.is-open .hnir-panel.hnir-panel-vertical {
                padding: 12px 14px;
                min-width: 270px;
            }

            .hnir-btn {
                width: 46px;
                height: 46px;
                border: 1px solid var(--hnir-ui-stroke);
                border-radius: 50%;
                background: var(--hnir-ui-surface);
                color: var(--hnir-ui-text);
                display: inline-flex;
                align-items: center;
                justify-content: center;
                cursor: pointer;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                transition:
                    transform 150ms ease,
                    background-color 150ms ease,
                    color 150ms ease;
            }

            .hnir-btn:hover {
                transform: scale(1.04);
            }

            .hnir-close-btn:hover {
                transform: scale(1.06);
            }

            #${IDS.uiRoot}.is-menu-open .hnir-menu-button .hnir-btn {
                transform: rotate(90deg);
            }

            #${IDS.uiRoot}.is-menu-open .hnir-menu-button .hnir-btn:hover {
                transform: rotate(90deg) scale(1.04);
            }

            .hnir-segmented {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-chip {
                min-width: 74px;
                height: 32px;
                padding: 0 12px;
                border: 0;
                border-radius: 999px;
                background: transparent;
                color: rgba(45, 49, 55, 0.72);
                cursor: pointer;
                font-size: 13px;
                font-weight: 700;
                transition: background-color 150ms ease, color 150ms ease;
            }

            .hnir-chip.is-active {
                background: #303843;
                color: #f7f8fa;
            }

            .hnir-jump {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-jump input {
                width: 82px;
                height: 32px;
                padding: 0 10px;
                border: 0;
                border-radius: 999px;
                background: rgba(255, 255, 255, 0.72);
                color: #2d3137;
                font-size: 13px;
                font-weight: 700;
                outline: none;
                box-sizing: border-box;
            }

            .hnir-slider-wrap {
                display: flex;
                flex-direction: column;
                gap: 8px;
                width: 100%;
                min-width: 250px;
            }

            .hnir-slider-top {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 12px;
                color: var(--hnir-ui-text);
                font-size: 12px;
                font-weight: 700;
            }

            .hnir-slider-value {
                min-width: 48px;
                text-align: right;
                color: rgba(45, 49, 55, 0.76);
            }

            .hnir-slider-row {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-slider-row input[type="range"] {
                flex: 1;
                accent-color: #303843;
                cursor: pointer;
            }

            .hnir-stack-item[data-item="theme"].is-open .hnir-panel.hnir-panel-vertical {
                min-width: 244px;
                padding: 10px;
                border-radius: 22px;
            }

            .hnir-color-picker {
                width: 244px;
                display: flex;
                flex-direction: column;
                gap: 8px;
            }

            .hnir-color-square {
    --hnir-picker-hue: 0;
    position: relative;
    height: 138px;
    border-radius: 18px;
    overflow: hidden;
    background: hsl(var(--hnir-picker-hue), 100%, 50%);
    cursor: pointer;
    box-shadow:
        inset 0 0 0 1px rgba(0, 0, 0, 0.12),
        0 8px 20px rgba(0, 0, 0, 0.12);
}

.hnir-color-square::before {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
}

.hnir-color-square::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(to bottom, rgba(0, 0, 0, 0), #000);
}

            .hnir-color-knob {
                position: absolute;
                z-index: 2;
                width: 14px;
                height: 14px;
                border: 2px solid white;
                border-radius: 50%;
                box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.45);
                transform: translate(-50%, -50%);
                pointer-events: none;
            }

            .hnir-hue-row {
                display: flex;
                align-items: center;
                gap: 8px;
            }

            .hnir-hue-slider {
    flex: 1;
    height: 12px;
    border-radius: 999px;
    outline: none;
    cursor: pointer;
    appearance: none;
    -webkit-appearance: none;
    background: linear-gradient(
        to right,
        #ff0000 0%,
        #ffff00 16.6%,
        #00ff00 33.3%,
        #00ffff 50%,
        #0000ff 66.6%,
        #ff00ff 83.3%,
        #ff0000 100%
    );
    box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.16);
}

.hnir-hue-slider::-webkit-slider-runnable-track {
    height: 12px;
    border-radius: 999px;
    background: linear-gradient(
        to right,
        #ff0000 0%,
        #ffff00 16.6%,
        #00ff00 33.3%,
        #00ffff 50%,
        #0000ff 66.6%,
        #ff00ff 83.3%,
        #ff0000 100%
    );
}

.hnir-hue-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 18px;
    height: 18px;
    margin-top: -3px;
    border-radius: 50%;
    border: 2px solid white;
    background: #303843;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.35);
}

.hnir-hue-slider::-moz-range-track {
    height: 12px;
    border-radius: 999px;
    background: linear-gradient(
        to right,
        #ff0000 0%,
        #ffff00 16.6%,
        #00ff00 33.3%,
        #00ffff 50%,
        #0000ff 66.6%,
        #ff00ff 83.3%,
        #ff0000 100%
    );
}

.hnir-hue-slider::-moz-range-thumb {
    width: 18px;
    height: 18px;
    border-radius: 50%;
    border: 2px solid white;
    background: #303843;
    box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.35);
}

            #${IDS.progressBar} {
                position: fixed;
                top: 0;
                left: 0;
                height: 4px;
                width: 0%;
                z-index: 2147483647;
                background: var(--hnir-ui-fill);
                transition: width 80ms linear;
            }

            #${IDS.pageIndicator} {
                position: fixed;
                left: 20px;
                bottom: 20px;
                z-index: 2147483647;
                padding: 8px 12px;
                border-radius: 999px;
                border: 1px solid var(--hnir-ui-stroke);
                background: var(--hnir-ui-surface);
                color: var(--hnir-ui-text);
                font-family: "Segoe UI", sans-serif;
                font-size: 13px;
                font-weight: 700;
                box-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
                pointer-events: none;
                user-select: none;
                opacity: 1;
                transform: translateY(0);
                transition: opacity 180ms ease, transform 180ms ease;
            }

            @media (max-width: 700px) {
                #${IDS.launcher},
                #${IDS.uiRoot} {
                    right: 12px;
                    bottom: 12px;
                }

                #${IDS.pageIndicator} {
                    left: 12px;
                    bottom: 12px;
                }

                #${IDS.overlay}.hnir-ui-auto-hidden #${IDS.uiRoot}:not(.is-menu-open),
                #${IDS.overlay}.hnir-ui-auto-hidden #${IDS.pageIndicator} {
                    opacity: 0;
                    transform: translateY(20px);
                    pointer-events: none;
                }

                .hnir-stack-item.is-open .hnir-panel {
                    max-width: calc(100vw - 88px);
                }

                .hnir-stack-item.is-open .hnir-panel.hnir-panel-vertical {
                    min-width: min(270px, calc(100vw - 102px));
                }

                .hnir-chip {
                    min-width: 58px;
                }

                #${IDS.overlay} .hnir-mode-webtoon .hnir-image.loaded {
                    max-width: 100vw;
                    max-height: none;
                    border-radius: 0;
                    box-shadow: none;
                }

                .hnir-mode-manga .hnir-image {
                    max-width: calc((100vw - 36px) / 2);
                }
            }
        `;

        document.head.appendChild(style);
    }

    function clampPage(pageNo) {
        const parsed = parseInt(pageNo, 10);

        if (!Number.isFinite(parsed)) return 1;

        return Math.max(1, Math.min(totalPages, parsed));
    }


    function normalizeHex(value) {
        let hex = String(value || '').trim();

        if (!hex) return null;

        if (!hex.startsWith('#')) {
            hex = `#${hex}`;
        }

        if (/^#[0-9a-f]{3}$/i.test(hex)) {
            hex = `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`;
        }

        if (!/^#[0-9a-f]{6}$/i.test(hex)) return null;

        return hex.toLowerCase();
    }

    function hexToHsv(hex) {
        const fixed = normalizeHex(hex) || CONFIG.defaultThemeColor;

        const r = parseInt(fixed.slice(1, 3), 16) / 255;
        const g = parseInt(fixed.slice(3, 5), 16) / 255;
        const b = parseInt(fixed.slice(5, 7), 16) / 255;

        const max = Math.max(r, g, b);
        const min = Math.min(r, g, b);
        const delta = max - min;

        let h = 0;

        if (delta !== 0) {
            if (max === r) {
                h = 60 * (((g - b) / delta) % 6);
            } else if (max === g) {
                h = 60 * (((b - r) / delta) + 2);
            } else {
                h = 60 * (((r - g) / delta) + 4);
            }
        }

        if (h < 0) h += 360;

        const s = max === 0 ? 0 : delta / max;
        const v = max;

        return { h, s, v };
    }

    function hsvToHex(h, s, v) {
        const c = v * s;
        const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
        const m = v - c;

        let r1 = 0;
        let g1 = 0;
        let b1 = 0;

        if (h >= 0 && h < 60) {
            r1 = c; g1 = x; b1 = 0;
        } else if (h >= 60 && h < 120) {
            r1 = x; g1 = c; b1 = 0;
        } else if (h >= 120 && h < 180) {
            r1 = 0; g1 = c; b1 = x;
        } else if (h >= 180 && h < 240) {
            r1 = 0; g1 = x; b1 = c;
        } else if (h >= 240 && h < 300) {
            r1 = x; g1 = 0; b1 = c;
        } else {
            r1 = c; g1 = 0; b1 = x;
        }

        const toHex = value => {
            const num = Math.round((value + m) * 255);
            return num.toString(16).padStart(2, '0');
        };

        return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
    }

})();