Sleazy Fork is available in English.

Pawchive Comfy View

Enhances Pawchive post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==UserScript==
// @name        Pawchive Comfy View
// @version     Kemono-Comfy-View-Port-1.0
// @description Enhances Pawchive post pages with full-resolution images, smooth reader navigation, zoom, themes, and ZIP download support.
// @author      L1Z4RD
// @match       https://pawchive.st/*/user/*/post/*
// @match       https://pawchive.pw/*/user/*/post/*
// @grant       GM_xmlhttpRequest
// @connect     file.pawchive.st
// @connect     file.pawchive.pw
// @license     MIT
// @require     https://unpkg.com/[email protected]/umd/index.js
// @run-at      document-end
// @namespace http://tampermonkey.net/
// ==/UserScript==

(function () {
    'use strict';

    if (window.__kcComfyViewInitialized) {
        return;
    }
    window.__kcComfyViewInitialized = true;

    const STORAGE_KEYS = {
        reader: 'kc_readerMode',
        fit: 'kc_fitToScreen',
        theme: 'kc_themeIndex'
    };

    const THEMES = ['#353a45', '#dbdbdb', '#cdc5be', '#a19f8e'];
    const UI_IDS = {
        style: 'kc-ui-style',
        root: 'kc-ui-root'
    };

    const TIMINGS = {
        collapseMs: 3000,
        clickMs: 220,
        longPressMs: 500,
        routePollMs: 500,
        refreshDebounceMs: 120,
        scrollCooldownMs: 280,
        downloadResetMs: 1800
    };

    const state = {
        readerMode: readBool(STORAGE_KEYS.reader, true),
        fitToScreen: readBool(STORAGE_KEYS.fit, true),
        themeIndex: readThemeIndex(),
        running: false,
        currentUrl: location.href,
        currentIndex: 0,
        listeners: [],
        mutationObserver: null,
        refreshTimer: 0,
        collapseTimer: 0,
        scrollUnlockTimer: 0,
        downloadResetTimer: 0,
        routePoller: 0,
        syncRaf: 0,
        scrollLocked: false,
        zoomedIndex: null,
        imageClickTimer: 0,
        lastEntrySignature: '',
        downloading: false,
        downloadController: null,

        loader: {
            queue: [],
            active: 0,
            maxConcurrent: 3,
            cache: new WeakMap(),
            scheduled: false,
            observer: null, aborters: new WeakMap()

        },

        ui: null
    };


    const ICONS = {
        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>',
        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>',
        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>',
        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>',
        download: '<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path d="M12 3v11"></path><path d="M7 10.5l5 5 5-5"></path><path d="M4 18.5v1a1.5 1.5 0 0 0 1.5 1.5h13A1.5 1.5 0 0 0 20 19.5v-1"></path></svg>'
    };

    const utils = {
        isValidPage(url = location.href) {
            return /^https:\/\/pawchive\.(st|pw)\/[^/]+\/user\/[^/]+\/post\/[^/?#]+/i.test(url);
        },
        getGalleryRoot() {
            return document.querySelector('.post__files');
        },
        getEntries() {
            return Array.from(document.querySelectorAll('.post__files a.fileThumb[href]'))
                .map((anchor, index) => {
                const img = anchor.querySelector('img');
                return img ? { anchor, img, index, url: anchor.href } : null;
            })
                .filter(Boolean);
        },
        getImageUrls() {
            return utils.getEntries().map((entry) => entry.url);
        },
        getMetaName() {
            const artist = document.querySelector('.post__user-name')?.textContent?.trim() || 'Unknown Artist';
            const title = document.querySelector('.post__title')?.textContent?.trim() || 'Untitled Post';
            return sanitizeFileName(`${artist} - ${title}`);
        },
        getZipFileName(url, index) {
            const parsed = new URL(url, location.href);
            const rawName = decodeURIComponent(
                parsed.searchParams.get("f") ||
                parsed.pathname.split("/").pop() ||
`image-${index + 1}`
            );
            const sanitized = sanitizeFileName(rawName);
            const hasExtension = /\.[A-Za-z0-9]{2,5}$/.test(sanitized);
            const ext = hasExtension ? '' : `.${getExtension(url)}`;
            return `${String(index + 1).padStart(3, '0')}_${sanitized}${ext}`;
        },
        isTypingTarget(target) {
            if (!target) {
                return false;
            }
            const element = target instanceof Element ? target : target.parentElement;
            if (!element) {
                return false;
            }
            const tagName = element.tagName;
            return element.isContentEditable || /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(tagName);
        }
    };

    const imageLoader = {

        enqueue(entry) {

            const existing = state.loader.cache.get(entry.img);

            if (existing &&
                (existing.status === "queued" ||
                 existing.status === "loading" ||
                 existing.status === "loaded")) {

                return;
            }

            state.loader.cache.set(entry.img, {
                status: "queued",
                retries: 0
            });

            state.loader.queue.push(entry);

            imageLoader.run();
        },

        run() {

            if (state.loader.scheduled)
                return;

            state.loader.scheduled = true;

            queueMicrotask(() => {

                state.loader.scheduled = false;

                while (
                    state.loader.active < state.loader.maxConcurrent &&
                    state.loader.queue.length
                ) {

                    imageLoader.load(state.loader.queue.shift());

                }

            });

        },

        async load(entry) {

            const { img, url } = entry;

            const info = state.loader.cache.get(img);

            if (!info)
                return;

            info.status = "loading";

            state.loader.active++;

            try {

                await imageLoader.fetch(entry);

                info.status = "loaded";

            }

            catch {

                if (info.retries < 2) {

                    info.retries++;

                    info.status = "queued";

                    state.loader.queue.push(entry);

                }
                else {

                    info.status = "failed";

                }

            }

            finally {

                state.loader.active--;

                imageLoader.run();

            }

        },
        observe(entry) {

            if (!state.loader.observer) {

                state.loader.observer = new IntersectionObserver(

                    (entries) => {

                        entries.forEach((item) => {

                            if (!item.isIntersecting)
                                return;

                            const entry = item.target.__kcEntry;

                            if (!entry)
                                return;

                            imageLoader.enqueue(entry);

                            imageLoader.preloadNearby(entry.index);

                            state.loader.observer.unobserve(item.target);

                        });

                    },

                    {
                        root: null,
                        rootMargin: '1000px',
                        threshold: 0.01
                    }

                );

            }

            entry.anchor.__kcEntry = entry;

            state.loader.observer.observe(entry.anchor);

        },
        preloadNearby(index) {

            const entries = utils.getEntries();

            for (let i = 1; i <= 3; i++) {

                const next = entries[index + i];

                if (!next)
                    continue;

                const cached = state.loader.cache.get(next.img);

                if (!cached) {

                    imageLoader.enqueue(next);

                }

            }

        },

        async fetch(entry) {

            const { img, url } = entry;
            const controller = new AbortController();

            state.loader.aborters.set(img, controller);

            return new Promise((resolve, reject) => {

                const preload = new Image();
                let finished = false;
                preload.onload = async () => {

                    if (controller.signal.aborted)
                        return;

                    finished = true;

                    try {

                        if (preload.decode)
                            await preload.decode();
                    }
                    catch {}

                    img.classList.add("kc-loading");

                    requestAnimationFrame(() => {

                        if (!document.contains(img))
                            return;

                        if (controller.signal.aborted)
                            return;

                        img.src = preload.src;

                        img.classList.remove('kc-loading');

                        cacheNaturalSize(img);

                        resolve();

                    });

                };

                preload.onerror = () => {

                    if (controller.signal.aborted)
                        return;

                    reject();

                };

                preload.src = url;
                controller.signal.addEventListener('abort', () => {

                    if (finished)
                        return;

                    preload.src = '';

                });
            });

        }

    };

    const gallery = {
        upgrade() {

            const entries = utils.getEntries();

            entries.forEach((entry) => {

                const {
                    anchor,
                    img,
                    index
                } = entry;

                anchor.classList.add('kc-entry');

                img.classList.add('kc-fullres');

                img.removeAttribute('srcset');
                img.removeAttribute('sizes');
                img.removeAttribute('fetchpriority');

                /*
            Keep the thumbnail.
            DO NOT replace img.src immediately anymore.
        */

                img.loading = 'lazy';
                img.decoding = 'async';

                if (!img.dataset.kcLoadBound) {

                    img.dataset.kcLoadBound = 'true';

                    img.addEventListener(
                        'load',
                        onImageLoad,
                        { passive: true }
                    );

                }

                if (!anchor.dataset.kcZoomBound) {

                    anchor.dataset.kcZoomBound = 'true';

                    anchor.addEventListener(
                        'click',
                        (event) => onImageClick(event, index)
                    );

                    anchor.addEventListener(
                        'dblclick',
                        (event) => onImageDoubleClick(event, index)
                    );

                }

                const cached = state.loader.cache.get(img);

                if (!cached || cached.status === 'idle' || cached.status === 'failed') {
                    imageLoader.observe(entry);
                }
            });

            syncZoomClasses();
        }
    };

    const navigation = {
        syncCurrentIndex() {
            const entries = utils.getEntries();
            if (!entries.length) {
                state.currentIndex = 0;
                return;
            }

            const viewportMiddle = window.innerHeight / 2;
            let bestIndex = state.currentIndex;
            let bestScore = -Infinity;

            entries.forEach((entry, index) => {
                const rect = entry.anchor.getBoundingClientRect();
                const visibleHeight = Math.max(0, Math.min(rect.bottom, window.innerHeight) - Math.max(rect.top, 0));
                const visibleRatio = visibleHeight / Math.max(rect.height, 1);
                const centerDistance = Math.abs(rect.top + rect.height / 2 - viewportMiddle);
                const score = visibleRatio * 1000 - centerDistance;

                if (score > bestScore) {
                    bestScore = score;
                    bestIndex = index;
                }
            });

            state.currentIndex = bestIndex;
        },
        scrollTo(index) {
            const entries = utils.getEntries();
            const entry = entries[index];
            if (!entry) {
                return;
            }

            state.currentIndex = index;
            const rect = entry.anchor.getBoundingClientRect();
            const tallImage = rect.height > window.innerHeight * 0.92 && !state.fitToScreen;

            entry.anchor.scrollIntoView({
                behavior: 'smooth',
                block: tallImage ? 'start' : 'center'
            });
        },
        move(step) {
            const entries = utils.getEntries();
            if (!entries.length) {
                return;
            }

            navigation.syncCurrentIndex();
            const nextIndex = clamp(state.currentIndex + step, 0, entries.length - 1);
            navigation.scrollTo(nextIndex);
        }
    };

    const ui = {
        mount() {
            if (!document.body || state.ui?.root?.isConnected) {
                return;
            }

            ensureStyle();

            const root = document.createElement('div');
            root.id = UI_IDS.root;

            const navItem = createStackItem('nav', ICONS.up, 'Scroll to top');
            const readerItem = createStackItem('reader', ICONS.reader, 'Reader mode');
            const fitItem = createStackItem('fit', ICONS.fit, 'Fit mode');
            const themeItem = createStackItem('theme', ICONS.theme, 'Theme');
            const downloadItem = createStackItem('download', ICONS.download, 'Download ZIP');

            root.append(navItem.item, readerItem.item, fitItem.item, themeItem.item, downloadItem.item);
            document.body.appendChild(root);

            const readerToggles = createToggleGroup([
                {
                    label: 'On',
                    active: state.readerMode,
                    onSelect() {
                        updateReaderMode(true);
                        ui.closeAll();
                    }
                },
                {
                    label: 'Off',
                    active: !state.readerMode,
                    onSelect() {
                        updateReaderMode(false);
                        ui.closeAll();
                    }
                }
            ]);

            const fitToggles = createToggleGroup([
                {
                    label: 'Fit',
                    active: state.fitToScreen,
                    onSelect() {
                        updateFitMode(true);
                        ui.closeAll();
                    }
                },
                {
                    label: 'Natural',
                    active: !state.fitToScreen,
                    onSelect() {
                        updateFitMode(false);
                        ui.closeAll();
                    }
                }
            ]);

            const themePanel = document.createElement('div');
            themePanel.className = 'kc-swatches';
            const themeButtons = THEMES.map((color, index) => {
                const button = document.createElement('button');
                button.type = 'button';
                button.className = 'kc-swatch';
                button.style.backgroundColor = color;
                button.setAttribute('aria-label', `Theme ${index + 1}`);
                button.addEventListener('click', () => {
                    updateTheme(index);
                    ui.closeAll();
                });
                themePanel.appendChild(button);
                return button;
            });

            const downloadPanel = document.createElement('div');
            downloadPanel.className = 'kc-progress';
            const downloadLabel = document.createElement('div');
            downloadLabel.className = 'kc-progress__label';
            downloadLabel.textContent = 'Download ZIP';
            const downloadTrack = document.createElement('div');
            downloadTrack.className = 'kc-progress__track';
            const downloadFill = document.createElement('div');
            downloadFill.className = 'kc-progress__fill';
            downloadTrack.appendChild(downloadFill);
            downloadPanel.append(downloadLabel, downloadTrack);

            readerItem.panel.appendChild(readerToggles.root);
            fitItem.panel.appendChild(fitToggles.root);
            themeItem.panel.appendChild(themePanel);
            downloadItem.panel.appendChild(downloadPanel);

            wireSimplePanelToggle(readerItem, 'reader');
            wireSimplePanelToggle(fitItem, 'fit');
            wireSimplePanelToggle(themeItem, 'theme');

            downloadItem.button.addEventListener('click', (event) => {
                event.preventDefault();
                event.stopPropagation();
                ui.open('download');
                if (!state.downloading) {
                    downloader.zipAll();
                } else {
                    ui.armIdleCollapse();
                }
            });

            let navPressTimer = 0;
            let navLongPressFired = false;

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

            navItem.button.addEventListener('pointerdown', (event) => {
                event.preventDefault();
                event.stopPropagation();
                navLongPressFired = false;
                clearNavTimer();
                navPressTimer = window.setTimeout(() => {
                    navLongPressFired = true;
                    window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' });
                    navPressTimer = 0;
                }, TIMINGS.longPressMs);
            });

            const releaseNav = (event) => {
                event.preventDefault();
                event.stopPropagation();
                if (navPressTimer) {
                    clearNavTimer();
                    if (!navLongPressFired) {
                        window.scrollTo({ top: 0, behavior: 'smooth' });
                    }
                }
            };

            navItem.button.addEventListener('pointerup', releaseNav);
            navItem.button.addEventListener('pointerleave', clearNavTimer);
            navItem.button.addEventListener('pointercancel', clearNavTimer);

            state.ui = {
                root,
                items: {
                    nav: navItem,
                    reader: readerItem,
                    fit: fitItem,
                    theme: themeItem,
                    download: downloadItem
                },
                readerButtons: readerToggles.buttons,
                fitButtons: fitToggles.buttons,
                themeButtons,
                downloadLabel,
                downloadFill
            };

            ui.sync();
            ui.resetDownload();
        },
        destroy() {
            clearTimeout(state.collapseTimer);
            state.collapseTimer = 0;
            if (state.ui?.root?.isConnected) {
                state.ui.root.remove();
            }
            state.ui = null;
        },
        open(name) {
            if (!state.ui) {
                return;
            }
            Object.entries(state.ui.items).forEach(([key, item]) => {
                item.item.classList.toggle('is-open', key === name);
            });
            if (name === 'download' || state.downloading) {
                clearTimeout(state.collapseTimer);
                state.collapseTimer = 0;
                return;
            }
            ui.armIdleCollapse();
        },
        closeAll(options = {}) {
            if (!state.ui) {
                return;
            }
            Object.entries(state.ui.items).forEach(([key, item]) => {
                if (options.keepDownload && key === 'download' && state.downloading) {
                    item.item.classList.add('is-open');
                    return;
                }
                item.item.classList.remove('is-open');
            });
        },
        armIdleCollapse() {
            clearTimeout(state.collapseTimer);
            state.collapseTimer = 0;
            if (state.downloading) {
                return;
            }
            state.collapseTimer = window.setTimeout(() => {
                ui.closeAll();
            }, TIMINGS.collapseMs);
        },
        sync() {
            if (!state.ui) {
                return;
            }

            state.ui.readerButtons[0].classList.toggle('is-active', state.readerMode);
            state.ui.readerButtons[1].classList.toggle('is-active', !state.readerMode);
            state.ui.fitButtons[0].classList.toggle('is-active', state.fitToScreen);
            state.ui.fitButtons[1].classList.toggle('is-active', !state.fitToScreen);

            state.ui.themeButtons.forEach((button, index) => {
                button.classList.toggle('is-active', index === state.themeIndex);
            });
        },
        updateProgress(done, total, text, status = 'idle') {
            if (!state.ui) {
                return;
            }

            const downloadItem = state.ui.items.download.item;
            const percent = total > 0 ? Math.min(100, (done / total) * 100) : 0;
            state.ui.downloadLabel.textContent = text;
            state.ui.downloadFill.style.width = `${percent}%`;
            downloadItem.classList.toggle('is-complete', status === 'complete');
            downloadItem.classList.toggle('is-error', status === 'error');
            if (status === 'loading') {
                downloadItem.classList.remove('is-complete', 'is-error');
            }
        },
        resetDownload() {
            clearTimeout(state.downloadResetTimer);
            state.downloadResetTimer = 0;
            ui.updateProgress(0, 1, 'Download ZIP', 'idle');
            ui.closeAll();
        },
        finishDownload(label, status) {
            ui.updateProgress(1, 1, label, status);
            state.downloadResetTimer = window.setTimeout(() => {
                ui.resetDownload();
            }, TIMINGS.downloadResetMs);
        }
    };

    const downloader = {
        async zipAll() {
            const urls = utils.getImageUrls();
            if (!urls.length || typeof fflate === 'undefined') {
                ui.finishDownload('No images found', 'error');
                return;
            }

            if (state.downloading) {
                return;
            }

            state.downloading = true;
            state.downloadController = new AbortController();
            ui.open('download');
            ui.updateProgress(0, urls.length, `0 / ${urls.length}`, 'loading');

            const zipFiles = {};
            const workerCount = Math.min(getDownloadConcurrency(), urls.length);
            let cursor = 0;
            let completed = 0;
            let failures = 0;

            const worker = async () => {
                while (cursor < urls.length) {
                    const index = cursor;
                    cursor += 1;

                    try {
                        const data = await new Promise((resolve,reject)=>{GM_xmlhttpRequest({method:'GET',url:urls[index],responseType:'arraybuffer',onload:r=>{if(r.status!==200)return reject(new Error('HTTP '+r.status)); if(!r.response||r.response.byteLength<100)return reject(new Error('Invalid response')); resolve(r.response);},onerror:reject,onabort:()=>reject(new DOMException('Aborted','AbortError'))});});
                        zipFiles[utils.getZipFileName(urls[index], index)] =
                            new Uint8Array(data);
                    } catch (error) {
                        if (error?.name === 'AbortError') {
                            throw error;
                        }
                        failures += 1;
                    } finally {
                        completed += 1;
                        ui.updateProgress(completed, urls.length, `${completed} / ${urls.length}`, 'loading');
                    }
                }
            };

            try {
                await Promise.all(Array.from({ length: workerCount }, () => worker()));
                ui.updateProgress(urls.length, urls.length, 'Packing ZIP...', 'loading');

                console.time('KC generate');
                const zipped = fflate.zipSync(zipFiles, {
                    level: 0
                });

                const archive = new Blob(
                    [zipped],
                    {
                        type: "application/zip"
                    }
                );
                console.timeEnd('KC generate');
                const downloadLink = document.createElement('a');
                downloadLink.href = URL.createObjectURL(archive);
                downloadLink.download = `${utils.getMetaName()}.zip`;
                document.body.appendChild(downloadLink);
                downloadLink.click();
                downloadLink.remove();
                window.setTimeout(() => URL.revokeObjectURL(downloadLink.href), 30000);

                if (failures > 0) {
                    ui.finishDownload(`Done with ${failures} failed`, 'error');
                } else {
                    ui.finishDownload(`Downloaded ${urls.length} images`, 'complete');
                }
            } catch (error) {
                if (error?.name === 'AbortError') {
                    ui.finishDownload('Download canceled', 'error');
                } else {
                    ui.finishDownload('Download failed', 'error');
                }
            } finally {
                state.downloading = false;
                state.downloadController = null;
            }
        }
    };

    const lifecycle = {
        start() {
            if (state.running || !utils.isValidPage() || !document.body) {
                return;
            }

            ensureStyle();
            state.running = true;
            state.currentIndex = 0;
            state.lastEntrySignature = '';

            applyModeClasses();
            ui.mount();
            bindRuntimeListeners();
            observeMutations();
            refreshPage(true);
        },
        stop() {
            if (!state.running) {
                return;
            }

            state.running = false;
            state.currentIndex = 0;
            state.lastEntrySignature = '';
            state.scrollLocked = false;
            state.zoomedIndex = null;
            state.downloading = false;

            clearTimeout(state.refreshTimer);
            clearTimeout(state.collapseTimer);
            clearTimeout(state.scrollUnlockTimer);
            clearTimeout(state.downloadResetTimer);
            clearTimeout(state.imageClickTimer);
            state.imageClickTimer = 0;

            if (state.syncRaf) {
                cancelAnimationFrame(state.syncRaf);
                state.syncRaf = 0;
            }

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

            if (state.downloadController) {
                state.downloadController.abort();
                state.downloadController = null;
                state.loader.aborters = new WeakMap();

                state.loader.queue.length = 0;

                state.loader.active = 0;
            }
            if (state.loader.observer) {

                state.loader.observer.disconnect();
                state.loader.observer = null;

            }
            state.listeners.forEach((remove) => remove());
            state.listeners.length = 0;

            ui.destroy();

            if (document.body) {
                document.body.classList.remove('kc-post-active', 'kc-reader-mode', 'kc-fit-mode');
            }
            document.documentElement.style.removeProperty('--kc-theme-bg');
        },
        syncWithRoute(force = false) {
            const urlChanged = location.href !== state.currentUrl;
            if (!force && !urlChanged) {
                return;
            }

            state.currentUrl = location.href;

            if (!utils.isValidPage()) {
                lifecycle.stop();
                return;
            }

            lifecycle.stop();
            window.setTimeout(() => lifecycle.start(), 0);
        }
    };

    function readBool(key, fallback) {
        const rawValue = localStorage.getItem(key);
        if (rawValue === null) {
            return fallback;
        }
        return rawValue === 'true';
    }

    function readThemeIndex() {
        const saved = Number(localStorage.getItem(STORAGE_KEYS.theme));
        return Number.isInteger(saved) && saved >= 0 && saved < THEMES.length ? saved : 0;
    }

    function clamp(value, min, max) {
        return Math.min(max, Math.max(min, value));
    }

    function sanitizeFileName(text) {
        return text.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim() || 'untitled';
    }

    function getExtension(url) {
        const parsed = new URL(url, location.href);
        const lastSegment = parsed.pathname.split('/').pop() || '';
        const match = lastSegment.match(/\.([A-Za-z0-9]{2,5})$/);
        return match ? match[1] : 'jpg';
    }

    function getDownloadConcurrency() {
        const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
        if (connection?.saveData) {
            return 1;
        }
        const effectiveType = String(connection?.effectiveType || '').toLowerCase();
        const downlink = Number(connection?.downlink || 0);
        if (effectiveType.includes('2g') || (downlink && downlink < 1.2)) {
            return 1;
        }
        if (effectiveType === '3g' || (downlink && downlink < 4)) {
            return 2;
        }
        return 3;
    }

    function isImageZoomEnabled() {
        return state.running && state.readerMode && state.fitToScreen;
    }

    function isZoomModeActive() {
        return isImageZoomEnabled() && state.zoomedIndex !== null;
    }

    function getEntryByIndex(index) {
        return utils.getEntries()[index] || null;
    }

    function syncZoomClasses() {
        utils.getEntries().forEach((entry, index) => {
            entry.anchor.classList.toggle('is-zoomed', state.zoomedIndex === index && isImageZoomEnabled());
        });
    }

    function enterZoomMode(index) {
        if (!isImageZoomEnabled()) {
            return;
        }

        state.zoomedIndex = index;
        syncZoomClasses();

        const entry = getEntryByIndex(index);
        if (entry) {
            requestAnimationFrame(() => {
                entry.anchor.scrollIntoView({ behavior: 'smooth', block: 'start' });
            });
        }
    }

    function exitZoomMode(options = {}) {
        if (state.zoomedIndex === null) {
            return;
        }

        const previousIndex = state.zoomedIndex;
        state.zoomedIndex = null;
        syncZoomClasses();

        if (options.recenter) {
            const entry = getEntryByIndex(previousIndex);
            if (entry) {
                requestAnimationFrame(() => {
                    entry.anchor.scrollIntoView({ behavior: 'smooth', block: 'center' });
                });
            }
        }
    }

    function onImageClick(event, index) {
        if (!isImageZoomEnabled()) {
            return;
        }

        event.preventDefault();
        event.stopPropagation();

        clearTimeout(state.imageClickTimer);
        state.imageClickTimer = 0;

        if (state.zoomedIndex === index) {
            return;
        }

        state.imageClickTimer = window.setTimeout(() => {
            state.imageClickTimer = 0;
            enterZoomMode(index);
        }, TIMINGS.clickMs);
    }

    function onImageDoubleClick(event, index) {
        if (!isImageZoomEnabled()) {
            return;
        }

        event.preventDefault();
        event.stopPropagation();
        clearTimeout(state.imageClickTimer);
        state.imageClickTimer = 0;

        if (state.zoomedIndex === index) {
            exitZoomMode({ recenter: true });
        }
    }

    function cacheNaturalSize(img) {
        if (!img?.naturalWidth) {
            return;
        }
        img.style.setProperty('--kc-natural-width', `${img.naturalWidth}px`);
    }

    function onImageLoad(event) {
        cacheNaturalSize(event.currentTarget);
    }

    function updateReaderMode(value) {
        state.readerMode = Boolean(value);
        localStorage.setItem(STORAGE_KEYS.reader, String(state.readerMode));
        if (!isImageZoomEnabled()) {
            exitZoomMode();
        }
        applyModeClasses();
        ui.sync();
    }

    function updateFitMode(value) {
        state.fitToScreen = Boolean(value);
        localStorage.setItem(STORAGE_KEYS.fit, String(state.fitToScreen));
        if (!isImageZoomEnabled()) {
            exitZoomMode();
        }
        applyModeClasses();
        ui.sync();
    }

    function updateTheme(index) {
        state.themeIndex = clamp(index, 0, THEMES.length - 1);
        localStorage.setItem(STORAGE_KEYS.theme, String(state.themeIndex));
        applyTheme();
        ui.sync();
    }

    function cycleTheme() {
        updateTheme((state.themeIndex + 1) % THEMES.length);
    }

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

        const style = document.createElement('style');
        style.id = UI_IDS.style;
        style.textContent = `
:root {
--kc-theme-bg: ${THEMES[state.themeIndex]};
--kc-ui-surface: rgba(245, 241, 232, 0.96);
--kc-ui-stroke: rgba(0, 0, 0, 0.08);
--kc-ui-text: #2d3137;
--kc-ui-track: rgba(0, 0, 0, 0.12);
--kc-ui-fill: #303843;
--kc-ui-fill-done: #2d8a4f;
--kc-ui-fill-error: #c47a2c;
}

body.kc-post-active {
background: var(--kc-theme-bg) !important;
transition: background-color 180ms ease;
}

body.kc-post-active .post__files {
display: flex;
flex-direction: column;
align-items: center;
gap: clamp(10px, 2vh, 18px);
padding: 10px 0 104px;
}

body.kc-post-active .post__files > * {
width: min(100%, 1800px);
}

body.kc-post-active .post__files a.fileThumb {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
scroll-margin-top: 10px;
scroll-margin-bottom: 10px;
}

body.kc-post-active .post__files img.kc-fullres {
display: block;
width: auto;
height: auto;
max-width: min(calc(100vw - 32px), 1800px);
border-radius: 10px;
background: rgba(0, 0, 0, 0.07);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
image-rendering: auto;
transition: opacity .25s ease, max-height .18s ease, width .18s ease, max-width .18s ease, border-radius .18s ease;
}

body.kc-post-active .post__files img.kc-fullres.kc-loading {  opacity: .45;
}

body.kc-post-active.kc-fit-mode .post__files img.kc-fullres {
max-height: calc(100vh - 1cm);
object-fit: contain;
cursor: zoom-in;
}

body.kc-post-active:not(.kc-fit-mode) .post__files img.kc-fullres {
width: min(var(--kc-natural-width, calc(100vw - 32px)), calc(100vw - 32px));
max-width: none;
max-height: none;
object-fit: initial;
}

body.kc-post-active.kc-fit-mode .post__files a.kc-entry.is-zoomed {
align-items: flex-start;
}

body.kc-post-active.kc-fit-mode .post__files a.kc-entry.is-zoomed img.kc-fullres {
width: min(var(--kc-natural-width, calc(100vw - 24px)), calc(100vw - 24px));
max-width: none;
max-height: none;
object-fit: initial;
cursor: zoom-out;
}

#${UI_IDS.root} {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 2147483646;
display: flex;
flex-direction: column;
gap: 12px;
pointer-events: none;
font-family: "Segoe UI", sans-serif;
user-select: none;
}

.kc-stack-item {
position: relative;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 48px;
pointer-events: auto;
}

.kc-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(--kc-ui-stroke);
border-radius: 999px;
background: var(--kc-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;
}

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

.kc-btn {
width: 46px;
height: 46px;
border: 1px solid var(--kc-ui-stroke);
border-radius: 50%;
background: var(--kc-ui-surface);
color: var(--kc-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;
}

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

.kc-btn svg {
display: block;
fill: none;
stroke: currentColor;
stroke-width: 2.2;
stroke-linecap: round;
stroke-linejoin: round;
flex: none;
}

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

.kc-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: 600;
transition: background-color 150ms ease, color 150ms ease;
}

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

.kc-swatches {
display: flex;
align-items: center;
gap: 10px;
}

.kc-swatch {
width: 26px;
height: 26px;
border: 2px solid transparent;
border-radius: 50%;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
transition: transform 150ms ease, border-color 150ms ease;
}

.kc-swatch.is-active {
border-color: #303843;
transform: scale(1.08);
}

.kc-progress {
width: 180px;
display: flex;
flex-direction: column;
gap: 6px;
}

.kc-progress__label {
font-size: 12px;
font-weight: 600;
color: var(--kc-ui-text);
text-align: left;
}

.kc-progress__track {
width: 100%;
height: 6px;
border-radius: 999px;
overflow: hidden;
background: var(--kc-ui-track);
}

.kc-progress__fill {
width: 0;
height: 100%;
border-radius: inherit;
background: var(--kc-ui-fill);
transition: width 180ms linear, background-color 180ms ease;
}

.kc-stack-item[data-item="download"].is-complete .kc-progress__fill {
background: var(--kc-ui-fill-done);
}

.kc-stack-item[data-item="download"].is-error .kc-progress__fill {
background: var(--kc-ui-fill-error);
}
`;

        document.head.appendChild(style);
    }

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

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

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

        item.append(panel, button);
        return { item, panel, button };
    }

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

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

        return { root, buttons };
    }

    function wireSimplePanelToggle(entry, name) {
        entry.button.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            const isOpen = entry.item.classList.contains('is-open');
            if (isOpen) {
                ui.closeAll();
            } else {
                ui.open(name);
            }
        });
    }

    function applyTheme() {
        document.documentElement.style.setProperty('--kc-theme-bg', THEMES[state.themeIndex]);
    }

    function applyModeClasses() {
        if (!document.body) {
            return;
        }
        document.body.classList.toggle('kc-post-active', state.running);
        document.body.classList.toggle('kc-reader-mode', state.running && state.readerMode);
        document.body.classList.toggle('kc-fit-mode', state.running && state.fitToScreen);
        applyTheme();
        syncZoomClasses();
    }

    function bindRuntimeListeners() {
        const addManagedListener = (target, type, handler, options) => {
            target.addEventListener(type, handler, options);
            state.listeners.push(() => target.removeEventListener(type, handler, options));
        };

        const handleWheel = (event) => {

            if (!state.running || !state.readerMode || utils.isTypingTarget(event.target)) {
                return;
            }

            if (isZoomModeActive()) {
                return;
            }

            if (Math.abs(event.deltaY) < 8) {
                return;
            }

            // Fit Mode keeps the current one-wheel-per-image behavior.
            if (state.fitMode) {

                event.preventDefault();

                if (state.scrollLocked) {
                    return;
                }

                state.scrollLocked = true;

                navigation.move(event.deltaY > 0 ? 1 : -1);

                clearTimeout(state.scrollUnlockTimer);

                state.scrollUnlockTimer = window.setTimeout(() => {
                    state.scrollLocked = false;
                }, TIMINGS.scrollCooldownMs);

                return;

            }

            // Natural Mode
            const current = utils.getEntries()[state.currentIndex];

            if (!current)
                return;

            const rect = current.anchor.getBoundingClientRect();

            const threshold = 40;

            if (event.deltaY > 0) {

                // Not at the bottom yet? Let the browser scroll normally.
                if (rect.bottom > window.innerHeight + threshold) {
                    return;
                }

            } else {

                // Not at the top yet? Let the browser scroll normally.
                if (rect.top < -threshold) {
                    return;
                }

            }

            event.preventDefault();

            if (state.scrollLocked)
                return;

            state.scrollLocked = true;

            navigation.move(event.deltaY > 0 ? 1 : -1);

            clearTimeout(state.scrollUnlockTimer);

            state.scrollUnlockTimer = window.setTimeout(() => {
                state.scrollLocked = false;
            }, TIMINGS.scrollCooldownMs);

        };

        const handleKeydown = (event) => {
            if (!state.running || event.altKey || event.ctrlKey || event.metaKey || utils.isTypingTarget(document.activeElement)) {
                return;
            }

            const key = event.key.toLowerCase();
            if (key === 'r') {
                event.preventDefault();
                updateReaderMode(!state.readerMode);
                ui.armIdleCollapse();
                return;
            }
            if (key === 'f') {
                event.preventDefault();
                updateFitMode(!state.fitToScreen);
                ui.armIdleCollapse();
                return;
            }
            if (key === 't') {
                event.preventDefault();
                cycleTheme();
                ui.armIdleCollapse();
                return;
            }

            if (!state.readerMode) {
                return;
            }
            if (isZoomModeActive()) {
                return;
            }

            if (event.code === 'Space' || key === 'arrowdown' || key === 'pagedown') {
                event.preventDefault();
                navigation.move(1);
                return;
            }

            if (key === 'arrowup' || key === 'pageup') {
                event.preventDefault();
                navigation.move(-1);
            }
        };

        const syncIndexFromScroll = () => {
            if (!state.running) {
                return;
            }
            if (state.syncRaf) {
                cancelAnimationFrame(state.syncRaf);
            }
            state.syncRaf = requestAnimationFrame(() => {
                state.syncRaf = 0;
                navigation.syncCurrentIndex();
                if (state.zoomedIndex !== null && state.currentIndex !== state.zoomedIndex) {
                    exitZoomMode();
                }
            });
        };

        const handlePointerDown = (event) => {
            if (!state.ui?.root) {
                return;
            }
            if (state.ui.root.contains(event.target)) {
                ui.armIdleCollapse();
                return;
            }
            ui.closeAll({ keepDownload: true });
        };

        addManagedListener(window, 'wheel', handleWheel, { passive: false });
        addManagedListener(window, 'keydown', handleKeydown);
        addManagedListener(window, 'scroll', syncIndexFromScroll, { passive: true });
        addManagedListener(window, 'resize', syncIndexFromScroll, { passive: true });
        addManagedListener(document, 'pointerdown', handlePointerDown, true);
    }

    function observeMutations() {
        if (state.mutationObserver || !document.body) {
            return;
        }

        state.mutationObserver = new MutationObserver(() => {
            clearTimeout(state.refreshTimer);
            state.refreshTimer = window.setTimeout(() => {
                refreshPage();
            }, TIMINGS.refreshDebounceMs);
        });

        state.mutationObserver.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    function refreshPage(force = false) {
        if (!state.running) {
            return;
        }

        gallery.upgrade();
        imageLoader.run();
        applyModeClasses();
        ui.mount();
        ui.sync();

        if (state.zoomedIndex !== null && !getEntryByIndex(state.zoomedIndex)) {
            exitZoomMode();
        }

        const signature = utils.getImageUrls().join('|');
        if (force || signature !== state.lastEntrySignature) {
            state.lastEntrySignature = signature;
            navigation.syncCurrentIndex();
        }
    }

    function installRouteWatcher() {
        const notify = () => {
            window.setTimeout(() => {
                lifecycle.syncWithRoute(true);
            }, 0);
        };

        ['pushState', 'replaceState'].forEach((methodName) => {
            const original = history[methodName];
            history[methodName] = function wrappedHistoryMethod(...args) {
                const result = original.apply(this, args);
                notify();
                return result;
            };
        });

        window.addEventListener('popstate', notify, { passive: true });
        window.addEventListener('hashchange', notify, { passive: true });

        state.routePoller = window.setInterval(() => {
            lifecycle.syncWithRoute();
        }, TIMINGS.routePollMs);
    }

    function boot() {
        installRouteWatcher();
        lifecycle.syncWithRoute(true);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', boot, { once: true });
    } else {
        boot();
    }
})();