☰

Manga Viewer MM

Shows all chapter pages at once in a single scrolling reader on 100+ manga, manhwa, comic and adult sites (MangaDex, Asura, Batoto, MangaPark, WebToons, Madara/MangaStream sites and more). Zoom, 7 view modes, hotkeys, bookmarks, auto-scroll, lazy loading, ZIP download, local files.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

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

// ==UserScript==
// @name          Manga Viewer MM
// @namespace     https://greasyfork.org/en/scripts/599338-manga-viewer-mm
// @description   Shows all chapter pages at once in a single scrolling reader on 100+ manga, manhwa, comic and adult sites (MangaDex, Asura, Batoto, MangaPark, WebToons, Madara/MangaStream sites and more). Zoom, 7 view modes, hotkeys, bookmarks, auto-scroll, lazy loading, ZIP download, local files.
// @version       1.0.1
// @license       MIT
// @icon          https://cdn-icons-png.flaticon.com/32/2281/2281832.png
// @run-at        document-end
// @grant         unsafeWindow
// @grant         GM_getValue
// @grant         GM_setValue
// @grant         GM_listValues
// @grant         GM_deleteValue
// @grant         GM_xmlhttpRequest
// @grant         GM_addValueChangeListener
// @noframes      on
// @connect       *
// @include       /https?:\/\/(www.)?(asurascans|asuracomics).(com|net)\/.+/
// @include       /https?:\/\/(?:www\.)?(?:fto|jto|hto|dto|mto|wto|bato|battwo|batotwo|comiko|batocomic|readtoto|zbato|xbato|mangatoto)\.(?:to|com|net|org)\/(chapter|title).*/
// @include       /https?:\/\/(www\.)?(bilibilicomics).net\/episode\/.+/
// @include       /https?:\/\/(www\.)?comick.io\/.+/
// @include       /https?:\/\/comix\.to\/(title|comic)\/.+\/.+/
// @include       /https?:\/\/(www\.)?demonicscans\.org\/(title\/.+\/chapter\/.+|chaptered\.php\?manga=\d+&chapter=.+)/
// @include       /https?:\/\/(www\.)?dynasty-scans.com\/chapters\/.+/
// @include       /https?:\/\/(www.)?(flamecomics).(xyz)\/series\/.+/
// @include       /https?:\/\/(visorikigai|visualikigai).(ajaco|eltanews|foodib|jobswu).(com|net|site)\/capitulo\/\d+/
// @include       /https:\/\/(www\.)?kagane\.org\/series\/.+\/reader\/.+/
// @include       /https?:\/\/(www\.)?kumanga.com\/manga\/leer\/.+/
// @include       /https?:\/\/(www.)?leercapitulo.co\/leer\/.+/
// @include       /https?:\/\/(www\.)?lectorxd\.com\/.+\/leer\/.+/
// @include       /https?:\/\/(www\.)?lhtranslation.net\/read.+/
// @include       /(file:\/\/\/.+(index)?.html)/
// @include       /https?:\/\/(www\.)?m440.in\/manga\/.+\/.+\/\d+/
// @include       /https?:\/\/(www\.)?mangabuddy.com\/.+\/chapter.+/
// @include       /https?:\/\/(www\.)?mangadex.org/
// @include       /https?:\/\/(www\.)?(fanfox.net|mangahere.cc)\/manga\/.+\/.+\//
// @include       /https?:\/\/(www\.)?mangago.me\/.*\/.*\/.*/
// @include       /https?:\/\/(www\.)?(mangahub).io\/chapter\/.+\/.+/
// @include       /https?:\/\/(www\.)?(read|chap)?(nelomanga|mangakakalot|natomanga|manganato|mangabats|mangakakalove).(com|gg|net).*\/(chapter|manga)\/.+\/.+/
// @include       /https?:\/\/mangaball\.net\/chapter-detail\/.+/
// @include       /https?:\/\/(www\.)?manga-oni.com\/lector\/.+\/\d+\/cascada/
// @include       /https?:\/\/(www\.)?(mangapark|mpark|comicpark|readpark|parkmanga).(com|me|org|net|io|to)\/title\/.+\/.+/
// @include       /https?:\/\/(www\.)?mangareader.to\/read\/.+\/.+\/.+/
// @include       /https?:\/\/.*mangatoon.mobi\/.+\/watch\/.+/
// @include       /https?:\/\/www\.mangatown\.com\/manga\/.+\/.+\//
// @include       /https?:\/\/(www\.)?manhwaweb.com\/leer\/.+/
// @include       /https?:\/\/(www\.)?mgeko.(com|cc)?\/reader\/.*/
// @include       /https?:\/\/(www\.)?nineanime.com\/chapter\/.+/
// @include       /https?:\/\/(www\.)?olympusxyz.com\/capitulo\/\d+\/.+/
// @include       /https?:\/\/(www\.)?qimanga\.com\/series\/[^/]+\/chapter-.+/
// @include       /https?:\/\/qimanhwa\.com\/series\/[^/]+\/chapter-.+/
// @include       /https?:\/\/(www\.)?readcomicsonline.ru\/comic\/.*\/\d*/
// @include       /https?:\/\/(www\.)?reaperscans\.com\/series\/.+\/chapter.+/
// @include       /https?:\/\/(www\.)?reimanga\.com\/manga\/[^/]+-\d+\/\d+/
// @include       /https?:\/\/(www\.)?zonatmo\.(com|org|app)\/(viewer|news|view_uploads|reader|library)\/.+/
// @include       /https?:\/\/(www\.)?webnovel.com\/comic\/.+/
// @include       /https?:\/\/(www\.)?webtoons.com\/.+viewer.+/
// @include       /https?:\/\/(www\.)?(weebcentral).com\/chapters\/.+/
// @include       /https?:\/\/(www\.)?weebdex\.org\/.+/
// @include       /https?:\/\/(www.)?(vortexscans).(org)\/.+/
// @include       /https?:\/\/(www\.)?zscans.com\/comics\/.+/
// @include       /https?:\/\/[^/]*(scans?|comic|realm|rizz|hivetoon|tresdaos|zonamiau|ntrgod|threedaos)[^/]*\/.+/
// @include       /^(?!.*jaiminisbox).*\/read\/.+/
// @include       /https?:\/\/.+\/(manga|series|manhua|comic|ch|novel|webtoon|tmo)\/.+\/.+/
// @include       /https?:\/\/(www\.)?akuma\.moe\/g\/.+\/.+/
// @include       /https?:\/\/(www\.)?bestporncomix.com\/gallery\/.+/
// @include       /https?:\/\/(www\.)?doujins.com\/.+/
// @include       /https?:\/\/(www\.)?dragontranslation.net\/leer\/.+/
// @include       /https?:\/\/(comics.)?8muses.(com|io)\/(comics\/)?picture\/.+/
// @include       /https?:\/\/(g\.)?(exhentai|e-hentai).org\/s\/.+\/.+/
// @include       /https?:\/\/(www\.)?fsicomics.com\/.+/
// @include       /https?:\/\/(www\.)?freeadultcomix.com\/.+/
// @include       /https?:\/\/(www\.)?gntai.net\/(?!(category|tags|autores))[^/]+\/.+/
// @include       /https?:\/\/(www\.)?hdoujin\.org\/.+/
// @include       /https?:\/\/(www\.)?hentai2read.com\/[^/]+\/\d+(.\d+)?\//
// @include       /https?:\/\/(www\.)?hentaiera.com\/view\/.+\/\d+\/?/
// @include       /https?:\/\/(www\.)?hentaiforce.net\/view\/.+\/\d+/
// @include       /https?:\/\/(www\.)?hentaifox.com\/g\/.+/
// @include       /https?:\/\/(www\.)?(hentaihand|nhentai)\.com\/(?:[a-z]{2}\/)?(?:comic|reader)\/.+\/\d+/
// @include       /https?:\/\/(www\.)?hentaihere.com\/.+\/.+\/.+/
// @include       /https?:\/\/(www\.)?hentainexus.com\/read\/.+/
// @include       /https?:\/\/(www.)?hentalk.pw/
// @include       /https?:\/\/hitomi.la\/reader\/.+/
// @include       /https?:\/\/(www\.)?imhentai.xxx\/view\/.+\/.+\//
// @include       /https?:\/\/(www\.)?(kingcomix|chochox|comics18).(com|org)\/.+/
// @include       /https?:\/\/(www\.)?luscious.net\/.+\/read\/.+/
// @include       /https?:\/\/(www\.)?multporn.net\/(comics|hentai_manga)\/.+/
// @include       /https?:\/\/(www\.)?myhentaigallery.com\/g\/.+\/\d+/
// @include       /https?:\/\/(www\.)?nhentai\.net\/g\/.+/
// @include       /https?:\/\/(www\.)?nhentai\.to\/g\/\d+\/\d+/
// @include       /https?:\/\/(www\.)?nhentai\.xxx\/g\/\d+\/\d+/
// @include       /https?:\/\/(www\.)?9hentai.(so)\/g\/.+\/.+/
// @include       /https?:\/\/(www\.)?porncomicshd.com\/es.*/
// @include       /https?:\/\/(www\.)?pururin.me\/(view|read)\/.+\/.+\/.+/
// @include       /https?:\/\/(www\.)?(niyaniya|shupogaki|hoshino).(moe|one)/
// @include       /https?:\/\/(www\.)?simply-hentai.com\/.+\/page\/.+/
// @include       /https?:\/\/(www\.)?tmohentai\.(com|app)\/(reader|library|view_uploads)\/.+/
// @include       /https?:\/\/(www|es|fr|it|pt|ru)?\.?(3hentai|hentaivox).(net|com)\/(d|view)\/.+\/.+/
// @include       /https?:\/\/(www\.)?tsumino.com\/Read\/Index\/\d+(\?page=.+)?/
// @include       /https?:\/\/(www\.)?(vermangasporno|vercomicsporno).com\/.+/
// @include       /https?:\/\/(www\.)?wnacg.com\/photos-view-id-.+/
// @include       /https?:\/\/(www\.)?xlecx.one\/.+/
// @include       /https?:\/\/(www\.)?xyzcomics.com\/.+/
// @include       /https?:\/\/(www\.)?yabai.si\/g\/.+\/read/
// @include       /https?:\/\/(.+\/(porncomic|read-scan)|(www\.)?(manytoon|mangadistrict)\.com\/title)\/.+\/.+/
// ==/UserScript==

(function () {
  'use strict';

  // ==========================================================================================
  // HOW THIS FILE IS ORGANIZED
  // The script is one function (IIFE) split into numbered sections. Each section only uses
  // sections ABOVE it, so you can read the file from top to bottom. Search for "==== NN." to
  // jump to a section. Documentation: docs/README.md (reading order) and docs/06-code-map.md.
  //
  //   01 CONFIG               - every tunable constant, including VERBOSE LOGGING
  //   02 LOGGING              - log / logDebug / logError and safe wrappers
  //   03 BASIC HELPERS        - pure functions (no DOM): emptiness, merge, diff, debounce...
  //   04 WAITING              - waitUntil, withTimeout, sleep
  //   05 PLATFORM             - userscript manager storage, page window, device detection
  //   06 PAGE READING/NETWORK - query/read helpers, fetch helpers (used by core and sites)
  //   07 LOCALES              - UI texts in 6 languages
  //   08 SETTINGS STORE       - defaults, load, save (diff only), cross-tab sync, bookmarks
  //   09 APP STATE            - runtime values (manga, image status, page layout, current page)
  //   10 COLORS AND THEMES    - color math, palettes (hex), theme CSS variables
  //   11 STYLES               - icons and CSS (base, layout: header, view modes, navbar, pagination,
  //                             side panels and settings controls)
  //   12 UI TEMPLATES         - functions that return HTML strings (header, pages, navbar, generic
  //                             settings controls, settings panel, keybindings panel shell,
  //                             bookmarks panel, comments panel, toast stack)
  //   13 POPUPS               - dialogs (start prompt, page range with two-handle slider, confirm,
  //                             warnings) and toasts (small auto-hiding messages)
  //   14 SETTING APPLIERS     - ONE table: setting name -> function that updates the screen, plus
  //                             the reader features they drive (zoom, view mode, header, navbar,
  //                             pagination, page controls, current page, device, panels, language,
  //                             bookmarks list)
  //   15 IMAGE LOADING        - <img src> loading with throttle, retry, progress and lazy loading
  //                             (rules 'distance', 'lookAhead', 'both'; load on demand)
  //   16 ACTIONS              - ONE table: action name -> function (used by clicks and keys),
  //                             incl. keyboard scrolling and the settings panel actions
  //   17 KEYBOARD             - own hotkeys engine (key names, matching, one keydown listener)
  //                             and the keybindings panel (list, editor, validation, save)
  //   18 EVENTS               - delegated click/value/wheel listeners, scroll and resize handlers
  //   19 ZIP AND DOWNLOAD     - self-contained CRC-32, ZIP writer, DEFLATE decoder, ZIP reader;
  //                             then the chapter download (bytes, file names, save, button state)
  //   20 FEATURES             - setting-change toasts, bookmarks panel and one-time removal,
  //                             auto-scroll, comments capture/panel, local file upload reader
  //   21 STARTUP              - find the site, validate its result, take over the page
  //   22 SITE HELPERS         - text finders, scroll collector, language/category labels
  //   23 SITE MODULES         - one object per supported website
  //   24 ENTRY                - console helpers and the call to start()
  // ==========================================================================================

  // ==========================================================================================
  // ==== 01. CONFIG ===========================================================================
  // ==========================================================================================

  /**
   * Every tunable value of the script lives here, so nobody has to hunt for magic numbers.
   * VERBOSE LOGGING: set `verboseLogging` to true to log every step (site matching, each wait,
   * each image). You can also switch it on without editing the file: run `MVMDebug(true)` in the
   * browser console and reload the page (the choice is stored by the userscript manager).
   */
  const CONFIG = {
    scriptName: 'Manga Viewer MM',
    logPrefix: 'MangaViewerMM:',
    verboseLogging: false,
    verboseStorageKey: 'MVMVerboseLogging',
    rootElementId: 'MangaViewerMM',
    takeoverMarkerAttribute: 'mvm',
    siteWaitTimeoutMs: 30000,
    waitPollIntervalMs: 200,
    fetchTimeoutMs: 30000,
    startDelayMs: 50,
    waitPopupMs: 3000,
    imageRetryDelayMs: 2000,
    imageLoadTimeoutMs: 60000,
    lazyDistancePx: 2000,
    lookAheadPages: 5,
    settingsSyncDebounceMs: 300,
    maxStoredErrors: 50,
    headerHeightPx: 49,
    headerEndDistancePx: 100,
    headroomThrottleMs: 300,
    currentPageThrottleMs: 100,
    resizeDebounceMs: 200,
    navbarSizePx: 34,
    navbarFadeMs: 50,
    zoomSliderMax: 200,
    maxGlobalZoomExclusive: 500,
    // M3: lazy loading (2025 version: checked on scroll/touchmove/resize every 500 ms, OLD:1213-1258)
    lazyCheckThrottleMs: 500,
    // M3: toasts (2026 version: 3 s default, 2 s for setting changes, 10 s for bookmarks)
    toastDurationMs: 3000,
    settingToastMs: 2000,
    bookmarkToastMs: 10000,
    toastExitMs: 300,
    // M3: auto-scroll (one "tick" = one 60 Hz frame; resume 500 ms after the last wheel/touch)
    autoScrollTickMs: 1000 / 60,
    autoScrollResumeMs: 500,
    autoScrollStuckFrames: 6,
    // M3: comments capture (2025 version waited at most 5 s, OLD:306-308 and OLD:4861)
    commentsSelector: '#disqus_thread, #fb-comments',
    commentsWaitTimeoutMs: 5000,
    scrollCollectTimeoutMs: 5000,
    scrollCollectPollMs: 100,
    clickCollectDelayMs: 1000,
    siteDataSampleItems: 3,
    siteDataMaxUrls: 20,
    siteDataMaxDepth: 4,
    siteDataMaxStringLength: 300,
    // M3: download
    downloadRequestTimeoutMs: 60000,
    objectUrlRevokeMs: 60000,
    maxFileNameLength: 150,
    loadSpeedPresets: {
      Safe: { concurrency: 5, delayMs: 1000 },
      Standard: { concurrency: 5, delayMs: 500 },
      Faster: { concurrency: 10, delayMs: 500 },
      Extreme: { concurrency: 10, delayMs: 250 },
      All: { concurrency: 20, delayMs: 50 }
    }
  };

  // ==========================================================================================
  // ==== 02. LOGGING ==========================================================================
  // Three levels:
  //   log(...)       - always printed: important events (site found, viewer started, failures).
  //   logDebug(...)  - printed only when verbose logging is on: every step, for diagnosing.
  //   logError(ctx, error, details) - always printed, and kept in a list (MVMErrors() shows it).
  // Every line starts with "MangaViewerMM:" so you can filter the browser console.
  // ==========================================================================================

  /** Current verbose flag. Starts from CONFIG and may be switched on by stored preference. */
  let verboseLoggingEnabled = CONFIG.verboseLogging;

  /** The last errors, newest last. Shown by the console helper MVMErrors(). */
  const ERROR_HISTORY = [];

  /**
   * Converts arguments into a real array (ES2017-safe replacement for rest parameters in old code).
   * @param {IArguments} argumentList - The `arguments` object.
   * @returns {Array}
   */
  function argumentsToArray(argumentList) {
    return Array.prototype.slice.call(argumentList);
  }

  /** True when verbose logging is on. */
  function isVerbose() {
    return verboseLoggingEnabled;
  }

  /** Switches verbose logging on or off for this page (does not store it). */
  function setVerbose(enabled) {
    verboseLoggingEnabled = enabled === true;
  }

  /** Always-visible log line. Accepts any number of arguments, like console.log. */
  function log() {
    console.log.apply(console, [CONFIG.logPrefix].concat(argumentsToArray(arguments)));
  }

  /** Verbose-only log line. Accepts any number of arguments. */
  function logDebug() {
    if (!verboseLoggingEnabled) {
      return;
    }
    console.info.apply(console, [CONFIG.logPrefix + ' [debug]'].concat(argumentsToArray(arguments)));
  }

  /** Always-visible warning line. Accepts any number of arguments. */
  function logWarn() {
    console.warn.apply(console, [CONFIG.logPrefix + ' [warning]'].concat(argumentsToArray(arguments)));
  }

  /**
   * Turns anything thrown into a readable one-line message.
   * @param {*} error - An Error, a string, or anything else that was thrown.
   * @returns {string}
   */
  function describeError(error) {
    if (error === null || error === undefined) {
      return 'Unknown error';
    }
    if (error instanceof Error) {
      return error.name + ': ' + error.message;
    }
    if (typeof error === 'string') {
      return error;
    }
    try {
      return JSON.stringify(error);
    } catch (stringifyError) {
      return String(error);
    }
  }

  /**
   * Logs an error with its context (WHERE it happened) and optional details (WHAT was being
   * processed: ids, URLs, selectors...). Also stores it for MVMErrors().
   * @param {string} context - Where the error happened, e.g. 'startup.runSite' or 'action:next-chapter'.
   * @param {*} error - What was thrown.
   * @param {Object} [details] - Extra data that helps to reproduce the problem.
   */
  function logError(context, error, details) {
    const entry = {
      time: new Date().toISOString(),
      context: context,
      message: describeError(error),
      details: details === undefined ? null : details,
      stack: error && error.stack ? String(error.stack) : ''
    };
    ERROR_HISTORY.push(entry);
    if (ERROR_HISTORY.length > CONFIG.maxStoredErrors) {
      ERROR_HISTORY.shift();
    }
    console.error(CONFIG.logPrefix + ' [error] ' + context + ': ' + entry.message, details === undefined ? '' : details, error);
  }

  /**
   * Runs a function and catches anything it throws. Use it around any step that must not stop
   * the rest of the script.
   * @param {string} context - Name of the step, used in the error log.
   * @param {function(): *} action - The code to run.
   * @param {*} [fallbackValue] - Returned when the action throws.
   * @param {Object} [details] - Extra data for the error log.
   * @returns {*} The action's result, or fallbackValue.
   */
  function safeRun(context, action, fallbackValue, details) {
    try {
      return action();
    } catch (error) {
      logError(context, error, details);
      return fallbackValue;
    }
  }

  /**
   * Async version of safeRun: awaits the action and catches rejections too.
   * @param {string} context - Name of the step.
   * @param {function(): Promise<*>} action - Async code to run.
   * @param {*} [fallbackValue] - Resolved value when the action fails.
   * @param {Object} [details] - Extra data for the error log.
   * @returns {Promise<*>}
   */
  async function safeRunAsync(context, action, fallbackValue, details) {
    try {
      return await action();
    } catch (error) {
      logError(context, error, details);
      return fallbackValue;
    }
  }

  /**
   * Wraps an event handler so an error inside it is logged instead of being lost.
   * @param {string} context - Name used in the error log.
   * @param {function(Event): void} handler - The handler.
   * @returns {function(Event): void}
   */
  function guardHandler(context, handler) {
    return function (event) {
      try {
        handler(event);
      } catch (error) {
        logError(context, error, { eventType: event && event.type ? event.type : '' });
      }
    };
  }

  // ==========================================================================================
  // ==== 03. BASIC HELPERS ====================================================================
  // Pure functions: they only look at their arguments and never touch the page.
  // ==========================================================================================

  /**
   * True when a value carries no data: null, undefined, false, '' (or only spaces), [] or {}.
   * Every number (including 0), true and functions are NOT "nothing".
   * Note: the 2026 version treated numbers and functions as "nothing", so `waitVar` on a numeric
   * page variable (e.g. a chapter id 12345) never finished. Here it does.
   * Arrays are "nothing" when every item is "nothing" (e.g. ['', null]).
   * @param {*} value - Any value.
   * @returns {boolean}
   */
  function isNothing(value) {
    if (value === null || value === undefined || value === false) {
      return true;
    }
    if (typeof value === 'string') {
      return value.trim() === '';
    }
    if (Array.isArray(value)) {
      return value.every(isNothing);
    }
    if (isPlainObject(value)) {
      return Object.keys(value).length === 0;
    }
    return false;
  }

  /**
   * True for objects created with {} or new Object() (not arrays, not DOM nodes, not null).
   * @param {*} value - Any value.
   * @returns {boolean}
   */
  function isPlainObject(value) {
    if (value === null || typeof value !== 'object' || Array.isArray(value)) {
      return false;
    }
    const prototype = Object.getPrototypeOf(value);
    return prototype === Object.prototype || prototype === null;
  }

  /**
   * Deep copy of plain data (objects, arrays, strings, numbers, booleans).
   * @param {*} value - Data to copy.
   * @returns {*}
   */
  function deepClone(value) {
    if (Array.isArray(value)) {
      return value.map(deepClone);
    }
    if (isPlainObject(value)) {
      const copy = {};
      Object.keys(value).forEach(function (key) {
        copy[key] = deepClone(value[key]);
      });
      return copy;
    }
    return value;
  }

  /**
   * Deep equality for plain data (objects, arrays, primitives).
   * @param {*} first - First value.
   * @param {*} second - Second value.
   * @returns {boolean}
   */
  function deepEqual(first, second) {
    if (first === second) {
      return true;
    }
    if (Array.isArray(first) && Array.isArray(second)) {
      if (first.length !== second.length) {
        return false;
      }
      for (let i = 0; i < first.length; i++) {
        if (!deepEqual(first[i], second[i])) {
          return false;
        }
      }
      return true;
    }
    if (isPlainObject(first) && isPlainObject(second)) {
      const firstKeys = Object.keys(first);
      const secondKeys = Object.keys(second);
      if (firstKeys.length !== secondKeys.length) {
        return false;
      }
      return firstKeys.every(function (key) {
        return Object.prototype.hasOwnProperty.call(second, key) && deepEqual(first[key], second[key]);
      });
    }
    return false;
  }

  /**
   * Returns a NEW object: `value` with every missing key filled from `defaults`, recursively for
   * nested plain objects. Arrays are taken whole from `value` when present. Neither input is
   * changed (the 2026 version mutated its shared defaults here; this one never does).
   * @param {Object} value - Stored data (may be null/undefined).
   * @param {Object} defaults - Default data.
   * @returns {Object}
   */
  function fillDefaults(value, defaults) {
    const result = deepClone(defaults);
    if (!isPlainObject(value)) {
      return result;
    }
    Object.keys(value).forEach(function (key) {
      const storedValue = value[key];
      if (storedValue === undefined) {
        return;
      }
      if (isPlainObject(storedValue) && isPlainObject(result[key])) {
        result[key] = fillDefaults(storedValue, result[key]);
      } else {
        result[key] = deepClone(storedValue);
      }
    });
    return result;
  }

  /**
   * Returns only the parts of `changed` that differ from `original` (deep for plain objects,
   * whole replacement for arrays). Used to store only what the user changed.
   * @param {Object} changed - Current data.
   * @param {Object} original - Reference data (usually the defaults).
   * @returns {Object}
   */
  function diffObjects(changed, original) {
    const result = {};
    if (!isPlainObject(changed)) {
      return result;
    }
    const base = isPlainObject(original) ? original : {};
    Object.keys(changed).forEach(function (key) {
      const changedValue = changed[key];
      const originalValue = base[key];
      if (deepEqual(changedValue, originalValue)) {
        return;
      }
      if (isPlainObject(changedValue) && isPlainObject(originalValue)) {
        const nested = diffObjects(changedValue, originalValue);
        if (Object.keys(nested).length > 0) {
          result[key] = nested;
        }
      } else {
        result[key] = deepClone(changedValue);
      }
    });
    return result;
  }

  /**
   * Returns a function that runs `action` only after it stopped being called for `waitMs`.
   * @param {function} action - Function to delay.
   * @param {number} waitMs - Quiet time in milliseconds.
   * @returns {function}
   */
  function debounce(action, waitMs) {
    let timerId = null;
    return function () {
      const callArguments = arguments;
      const self = this;
      if (timerId !== null) {
        clearTimeout(timerId);
      }
      timerId = setTimeout(function () {
        timerId = null;
        action.apply(self, callArguments);
      }, waitMs);
    };
  }

  /**
   * Returns a function that runs `action` at most once every `waitMs` (first call runs at once,
   * the last call inside the window runs at its end).
   * @param {function} action - Function to limit.
   * @param {number} waitMs - Minimum time between runs.
   * @returns {function}
   */
  function throttle(action, waitMs) {
    let lastRun = 0;
    let pendingTimer = null;
    let pendingArguments = null;
    return function () {
      const now = Date.now();
      const remaining = waitMs - (now - lastRun);
      pendingArguments = arguments;
      if (remaining <= 0) {
        lastRun = now;
        action.apply(this, pendingArguments);
        return;
      }
      if (pendingTimer === null) {
        const self = this;
        pendingTimer = setTimeout(function () {
          pendingTimer = null;
          lastRun = Date.now();
          action.apply(self, pendingArguments);
        }, remaining);
      }
    };
  }

  /**
   * Page numbers from `begin` to `count`, inclusive. sequence(5, 2) -> [2, 3, 4, 5].
   * @param {number} count - Last page number.
   * @param {number} [begin=1] - First page number.
   * @returns {number[]}
   */
  function sequence(count, begin) {
    const first = begin && begin > 0 ? begin : 1;
    const result = [];
    for (let page = first; page <= count; page++) {
      result.push(page);
    }
    return result;
  }

  /**
   * Limits a number to a range.
   * @param {number} value - The number.
   * @param {number} minimum - Lowest allowed value.
   * @param {number} maximum - Highest allowed value.
   * @returns {number}
   */
  function clamp(value, minimum, maximum) {
    return Math.min(Math.max(value, minimum), maximum);
  }

  /**
   * Makes text safe to put inside HTML (site titles can contain < > & " ').
   * @param {*} text - Any value; null/undefined become ''.
   * @returns {string}
   */
  function escapeHtml(text) {
    if (text === null || text === undefined) {
      return '';
    }
    return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
  }

  /**
   * Trims a URL and turns protocol-relative '//host/x' into 'https://host/x'.
   * @param {string} url - URL from a site module.
   * @returns {string} '' for empty input.
   */
  function normalizeUrl(url) {
    if (typeof url !== 'string') {
      return '';
    }
    const trimmed = url.trim();
    if (trimmed.indexOf('//') === 0) {
      return 'https:' + trimmed;
    }
    return trimmed;
  }

  /**
   * True when the text is a URL the browser can parse (absolute, or relative to this page).
   * Replaces URL.canParse, which older browsers lack.
   * @param {string} url - Candidate URL.
   * @returns {boolean}
   */
  function isValidUrl(url) {
    if (typeof url !== 'string' || url.trim() === '') {
      return false;
    }
    try {
      new URL(url, window.location.href);
      return true;
    } catch (error) {
      return false;
    }
  }

  /**
   * True for URLs that point to data already in memory (no network request needed).
   * @param {string} url - URL.
   * @returns {boolean}
   */
  function isInMemoryUrl(url) {
    return typeof url === 'string' && (url.indexOf('data:') === 0 || url.indexOf('blob:') === 0);
  }

  /**
   * Adds or replaces a `forceReload=N` query parameter, so the browser requests the image again
   * instead of reusing a broken cached copy.
   * @param {string} url - Image URL.
   * @param {number} attempt - Retry number (1, 2, ...).
   * @returns {string}
   */
  function addCacheBuster(url, attempt) {
    if (isInMemoryUrl(url)) {
      return url;
    }
    const withoutOld = url.replace(/([?&])forceReload=\d+(&|$)/, function (match, before, after) {
      return after === '&' ? before : '';
    });
    const separator = withoutOld.indexOf('?') === -1 ? '?' : '&';
    return withoutOld + separator + 'forceReload=' + attempt;
  }

  // ==========================================================================================
  // ==== 04. WAITING ==========================================================================
  // Every wait has a timeout, so nothing can hang silently. A timeout rejects with an Error
  // whose message says WHAT was awaited and for HOW LONG.
  // ==========================================================================================

  /**
   * Waits a number of milliseconds.
   * @param {number} milliseconds - Time to wait.
   * @returns {Promise<void>}
   */
  function sleep(milliseconds) {
    return new Promise(function (resolve) {
      setTimeout(resolve, milliseconds);
    });
  }

  /**
   * Rejects if `promise` does not settle within `timeoutMs`.
   * @param {Promise} promise - The work.
   * @param {number} timeoutMs - Limit in milliseconds.
   * @param {string} description - What is awaited (used in the error message).
   * @returns {Promise}
   */
  function withTimeout(promise, timeoutMs, description) {
    return new Promise(function (resolve, reject) {
      const timerId = setTimeout(function () {
        reject(new Error('Timeout after ' + timeoutMs + ' ms: ' + description));
      }, timeoutMs);
      promise.then(function (value) {
        clearTimeout(timerId);
        resolve(value);
      }, function (error) {
        clearTimeout(timerId);
        reject(error);
      });
    });
  }

  /**
   * Polls `check` until it returns something that is not "nothing", then resolves with it.
   * Errors thrown by `check` are logged (verbose) and treated as "not yet".
   * @param {function(): *} check - Returns the awaited value, or nothing/false while not ready.
   * @param {string} description - What is awaited, e.g. 'element "#reader img"'.
   * @param {number} [timeoutMs=CONFIG.siteWaitTimeoutMs] - Limit.
   * @returns {Promise<*>} Resolves with the value; rejects on timeout.
   */
  function waitUntil(check, description, timeoutMs) {
    const limit = timeoutMs || CONFIG.siteWaitTimeoutMs;
    const startedAt = Date.now();
    return new Promise(function (resolve, reject) {
      let lastCheckError = null;

      /** One polling round. */
      function poll() {
        let value = null;
        try {
          value = check();
          lastCheckError = null;
        } catch (error) {
          lastCheckError = error;
          logDebug('waitUntil: check threw (will retry)', description, describeError(error));
        }
        if (!isNothing(value)) {
          logDebug('waitUntil: ready after ' + (Date.now() - startedAt) + ' ms', description);
          resolve(value);
          return;
        }
        if (Date.now() - startedAt >= limit) {
          const reason = lastCheckError ? ' (last check error: ' + describeError(lastCheckError) + ')' : '';
          reject(new Error('Timeout after ' + limit + ' ms waiting for ' + description + reason));
          return;
        }
        setTimeout(poll, CONFIG.waitPollIntervalMs);
      }

      poll();
    });
  }

  // ==========================================================================================
  // ==== 05. PLATFORM =========================================================================
  // Everything that depends on the userscript manager or the browser. All GM_* calls are
  // guarded with `typeof`, so the script can also run in a plain page (tests, console).
  // ==========================================================================================

  /** The page's own window (site variables live here). See docs/01 section 4. */
  const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;

  /** Script and manager information for logs. */
  const SCRIPT_INFO = (function readScriptInfo() {
    if (typeof GM_info !== 'undefined' && GM_info && GM_info.script) {
      return {
        name: GM_info.script.name || CONFIG.scriptName,
        version: GM_info.script.version || 'unknown',
        manager: GM_info.scriptHandler || 'unknown manager'
      };
    }
    return { name: CONFIG.scriptName, version: 'development', manager: 'none (plain page)' };
  })();

  /**
   * Turns a value returned by the userscript manager into plain data of THIS script.
   * Why: Violentmonkey on Firefox (content mode) returns objects created in another JavaScript
   * context; they are real objects but fail `Object.getPrototypeOf(x) === Object.prototype`, so
   * isPlainObject() rejected stored settings (found 2026-10-08: "stored value is not an object").
   * A JSON round trip rebuilds them as ordinary objects. Strings, numbers and booleans pass as is.
   * @param {*} value - Value from GM_getValue or a value-change listener.
   * @param {string} key - Storage key (for the error log).
   * @returns {*}
   */
  function normalizeStoredValue(value, key) {
    if (value === null || typeof value !== 'object') {
      return value;
    }
    try {
      return JSON.parse(JSON.stringify(value));
    } catch (error) {
      logError('storage.normalize', error, { key: key });
      return value;
    }
  }

  /**
   * Reads a value stored by the userscript manager. Returns `defaultValue` when missing or when
   * no manager is present. Stored JSON strings (very old versions) are parsed.
   * @param {string} key - Storage key.
   * @param {*} defaultValue - Fallback.
   * @returns {*}
   */
  function readStoredValue(key, defaultValue) {
    if (typeof GM_getValue === 'undefined') {
      logDebug('storage: no GM_getValue, using default for', key);
      return defaultValue;
    }
    try {
      const value = GM_getValue(key, defaultValue);
      if (typeof value === 'string' && value.trim() !== '' && (value.trim()[0] === '{' || value.trim()[0] === '[')) {
        try {
          return JSON.parse(value);
        } catch (parseError) {
          logError('storage.read: stored text is not valid JSON', parseError, { key: key });
          return defaultValue;
        }
      }
      return normalizeStoredValue(value, key);
    } catch (error) {
      logError('storage.read', error, { key: key });
      return defaultValue;
    }
  }

  /**
   * Stores a value with the userscript manager. Does nothing (but logs) without a manager.
   * @param {string} key - Storage key.
   * @param {*} value - Plain data.
   * @returns {boolean} True when stored.
   */
  function writeStoredValue(key, value) {
    if (typeof GM_setValue === 'undefined') {
      logDebug('storage: no GM_setValue, not saved', key, value);
      return false;
    }
    try {
      GM_setValue(key, value);
      logDebug('storage: saved', key, value);
      return true;
    } catch (error) {
      logError('storage.write', error, { key: key });
      return false;
    }
  }

  /**
   * Deletes a stored value.
   * @param {string} key - Storage key.
   */
  function deleteStoredValue(key) {
    if (typeof GM_deleteValue === 'undefined') {
      return;
    }
    safeRun('storage.delete', function () {
      GM_deleteValue(key);
    }, undefined, { key: key });
  }

  /**
   * Calls `onChange(newValue)` when ANOTHER tab changes the stored value of `key`.
   * @param {string} key - Storage key.
   * @param {function(*): void} onChange - Callback.
   */
  function listenToStoredValue(key, onChange) {
    if (typeof GM_addValueChangeListener === 'undefined') {
      logDebug('storage: no GM_addValueChangeListener, cross-tab sync disabled for', key);
      return;
    }
    safeRun('storage.listen', function () {
      GM_addValueChangeListener(key, function (name, oldValue, newValue, remote) {
        if (remote) {
          safeRun('storage.onRemoteChange', function () {
            onChange(normalizeStoredValue(newValue, key));
          }, undefined, { key: key });
        }
      });
    }, undefined, { key: key });
  }

  /**
   * Browser name and version from the user agent, e.g. 'Firefox 115'. Best effort, for logs.
   * @returns {string}
   */
  function getBrowserName() {
    const userAgent = window.navigator.userAgent;
    const patterns = [
      ['Edge', /Edg(?:e|A|iOS)?\/([\d.]+)/],
      ['Opera', /OPR\/([\d.]+)/],
      ['Firefox', /(?:Firefox|FxiOS)\/([\d.]+)/],
      ['Chrome', /(?:Chrome|CriOS)\/([\d.]+)/],
      ['Safari', /Version\/([\d.]+).*Safari/]
    ];
    for (let i = 0; i < patterns.length; i++) {
      const match = patterns[i][1].exec(userAgent);
      if (match) {
        return patterns[i][0] + ' ' + match[1].split('.')[0];
      }
    }
    return 'Unknown browser';
  }

  /**
   * Device type from the user agent and the screen width (same rules as the 2026 version:
   * phones or width <= 600px -> 'mobile'; tablets or width <= 992px -> 'tablet').
   * @returns {string} 'mobile', 'tablet' or 'desktop'.
   */
  function detectDevice() {
    const userAgent = window.navigator.userAgent;
    const isPhoneAgent = /iPhone|iPod|Android.*Mobile|Windows Phone|Mobile Safari/i.test(userAgent) && !/iPad|Tablet/i.test(userAgent);
    const isTabletAgent = /iPad|Tablet|Android(?!.*Mobile)/i.test(userAgent);
    /**
     * True when the screen is at most `maxWidth` pixels wide.
     * @param {number} maxWidth - Width in CSS pixels.
     * @returns {boolean}
     */
    const matchesWidth = function (maxWidth) {
      return typeof window.matchMedia === 'function' && window.matchMedia('screen and (max-width: ' + maxWidth + 'px)').matches;
    };
    if (isPhoneAgent || matchesWidth(600)) {
      return 'mobile';
    }
    if (isTabletAgent || matchesWidth(992)) {
      return 'tablet';
    }
    return 'desktop';
  }

  /** True on phones and tablets. */
  function isMobileDevice() {
    const device = detectDevice();
    return device === 'mobile' || device === 'tablet';
  }

  /** Values of the `deviceMode` setting that force a device type ('auto' = detect). */
  const FORCED_DEVICE_MODES = ['desktop', 'tablet', 'mobile'];

  /**
   * The device type the reader uses: the forced one from the `deviceMode` setting, or the
   * detected one when the setting is 'auto' (or unknown). Same rule as the 2026 version
   * (NEW:3651-3654). Pure.
   * @param {string} deviceMode - Setting value: 'auto', 'desktop', 'tablet' or 'mobile'.
   * @param {string} detectedDevice - Result of detectDevice().
   * @returns {string} 'mobile', 'tablet' or 'desktop'.
   */
  function resolveDeviceMode(deviceMode, detectedDevice) {
    if (FORCED_DEVICE_MODES.indexOf(deviceMode) !== -1) {
      return deviceMode;
    }
    return detectedDevice;
  }

  /** True when running from a local HTML file (standalone local reader). */
  function isStandalone() {
    return window.location.protocol === 'file:' || /Manga_Viewer_MM_Local\.html$/.test(window.location.pathname);
  }

  /**
   * Exposes a function or value on the page window (and the script window), for console use.
   * @param {string} name - Global name, e.g. 'MVMSettings'.
   * @param {*} value - Function or value.
   */
  function exposeToConsole(name, value) {
    safeRun('exposeToConsole', function () {
      pageWindow[name] = value;
      if (pageWindow !== window) {
        window[name] = value;
      }
    }, undefined, { name: name });
  }

  // ---- Trusted Types (sites whose security policy blocks plain HTML strings) ----
  // Some sites (for example MangaDex) send the CSP rule "require-trusted-types-for 'script'".
  // The browser then refuses `element.innerHTML = "<p>..."` with a plain string and only accepts
  // a TrustedHTML object created by a named policy. Every HTML assignment in this script goes
  // through setElementHtml / setElementOuterHtml / toTrustedHtml below, so it works on those sites
  // and behaves exactly as before on all others.

  /** Name of this script's Trusted Types policy (must be unique per page). */
  const TRUSTED_TYPES_POLICY_NAME = 'manga-viewer-mm';

  /**
   * Creates the Trusted Types policy once, when the browser supports Trusted Types.
   * Returns null when not supported, or when the site's CSP does not allow this policy name
   * (then plain strings are used; on such a site HTML assignments may still be refused, which
   * is logged).
   * @returns {{createHTML: function(string): *}|null}
   */
  function createTrustedHtmlPolicy() {
    const factory = window.trustedTypes || (pageWindow && pageWindow.trustedTypes);
    if (!factory || typeof factory.createPolicy !== 'function') {
      return null;
    }
    try {
      const policy = factory.createPolicy(TRUSTED_TYPES_POLICY_NAME, {
        createHTML: function (html) {
          return html;
        }
      });
      logDebug('Trusted Types policy created:', TRUSTED_TYPES_POLICY_NAME);
      return policy;
    } catch (error) {
      logError('trustedTypes.createPolicy', error, { policyName: TRUSTED_TYPES_POLICY_NAME });
      return null;
    }
  }

  /** The policy, or null (see createTrustedHtmlPolicy). */
  const TRUSTED_HTML_POLICY = createTrustedHtmlPolicy();

  /**
   * Wraps an HTML string so it can be assigned on Trusted Types sites. Returns the string itself
   * when Trusted Types are not in use.
   * @param {string} html - HTML built by this script (site text already escaped).
   * @returns {*} TrustedHTML or string.
   */
  function toTrustedHtml(html) {
    const text = html === null || html === undefined ? '' : String(html);
    if (TRUSTED_HTML_POLICY) {
      return TRUSTED_HTML_POLICY.createHTML(text);
    }
    return text;
  }

  /**
   * Replaces an element's content with HTML (Trusted Types safe).
   * @param {Element} element - Target element.
   * @param {string} html - HTML string.
   */
  function setElementHtml(element, html) {
    element.innerHTML = toTrustedHtml(html);
  }

  /**
   * Replaces an element itself with HTML (Trusted Types safe).
   * @param {Element} element - Target element.
   * @param {string} html - HTML string.
   */
  function setElementOuterHtml(element, html) {
    element.outerHTML = toTrustedHtml(html);
  }

  // Apply the stored verbose preference (set with MVMDebug(true)) as early as possible.
  if (readStoredValue(CONFIG.verboseStorageKey, false) === true) {
    setVerbose(true);
  }

  // ==========================================================================================
  // ==== 06. PAGE READING AND NETWORK =========================================================
  // Small, safe functions to read the DOM and fetch pages. Used by the core AND by site modules.
  // Rules:
  //   - "read"/"query" helpers never throw for "not found": they return '' / null / [];
  //   - fetchJson / fetchDocument / fetchTextOrThrow DO throw, with a message naming the URL and
  //     the HTTP status, because a site cannot continue without that data;
  //   - fetchHtml / getElementAttribute never throw (they return null / '' and log why).
  // ==========================================================================================

  /**
   * Returns the first element matching a CSS selector, or null.
   * @param {string} selector - CSS selector.
   * @param {ParentNode} [root=document] - Where to search.
   * @returns {Element|null}
   */
  function query(selector, root) {
    const searchRoot = root || document;
    try {
      return searchRoot.querySelector(selector);
    } catch (error) {
      logError('query', error, { selector: selector });
      return null;
    }
  }

  /**
   * Returns all elements matching a CSS selector as a real Array (never null).
   * @param {string} selector - CSS selector.
   * @param {ParentNode} [root=document] - Where to search.
   * @returns {Element[]}
   */
  function queryAll(selector, root) {
    const searchRoot = root || document;
    try {
      return Array.prototype.slice.call(searchRoot.querySelectorAll(selector));
    } catch (error) {
      logError('queryAll', error, { selector: selector });
      return [];
    }
  }

  /**
   * Reads an attribute of an element. Returns '' when the element or the attribute is missing.
   * @param {Element|null} element - The element (may be null).
   * @param {string} attribute - Attribute name, e.g. 'href'.
   * @returns {string}
   */
  function readAttribute(element, attribute) {
    if (!element) {
      return '';
    }
    const value = element.getAttribute(attribute);
    if (value === null || value === undefined) {
      return '';
    }
    return value;
  }

  /**
   * Reads the first non-empty attribute from a list, e.g. ['data-src', 'data-lazy-src', 'src'].
   * Useful for lazy-loaded images. Returns '' when none is set.
   * @param {Element|null} element - The element (may be null).
   * @param {string[]} attributes - Attribute names, in priority order.
   * @returns {string}
   */
  function readFirstAttribute(element, attributes) {
    for (let i = 0; i < attributes.length; i++) {
      const value = readAttribute(element, attributes[i]).trim();
      if (value !== '') {
        return value;
      }
    }
    return '';
  }

  /**
   * Reads the trimmed text of an element. Returns '' when the element is missing.
   * @param {Element|null} element - The element (may be null).
   * @returns {string}
   */
  function readText(element) {
    if (!element || element.textContent === null) {
      return '';
    }
    return element.textContent.trim();
  }

  /**
   * Shortcut: attribute of the first element matching a selector, or ''.
   * @param {string} selector - CSS selector.
   * @param {string} attribute - Attribute name.
   * @param {ParentNode} [root=document] - Where to search.
   * @returns {string}
   */
  function queryAttribute(selector, attribute, root) {
    return readAttribute(query(selector, root), attribute);
  }

  /**
   * Shortcut: trimmed text of the first element matching a selector, or ''.
   * @param {string} selector - CSS selector.
   * @param {ParentNode} [root=document] - Where to search.
   * @returns {string}
   */
  function queryText(selector, root) {
    return readText(query(selector, root));
  }

  /**
   * The page's own <title> text, trimmed, or ''.
   * @returns {string}
   */
  function getPageTitle() {
    return queryText('title');
  }

  /**
   * Returns the last item of an array, or undefined for an empty/missing array.
   * Replaces `array.at(-1)` (not available in ES2017).
   * @param {Array} list - The array.
   * @returns {*}
   */
  function lastItem(list) {
    if (!list || list.length === 0) {
      return undefined;
    }
    return list[list.length - 1];
  }

  /**
   * Returns the keys of an object, or [] when the value is null/undefined/not an object.
   * Replaces lodash `_.keys`, which never throws (Object.keys(undefined) throws).
   * @param {*} value - Any value.
   * @returns {string[]}
   */
  function safeKeys(value) {
    if (value === null || value === undefined || typeof value !== 'object') {
      return [];
    }
    return Object.keys(value);
  }

  /**
   * Removes duplicates from an array, keeping the first occurrence and the original order.
   * @param {Array} list - The array.
   * @returns {Array}
   */
  function uniqueValues(list) {
    const result = [];
    const seen = new Set();
    for (let i = 0; i < list.length; i++) {
      if (!seen.has(list[i])) {
        seen.add(list[i]);
        result.push(list[i]);
      }
    }
    return result;
  }

  /**
   * Turns a possibly relative URL into an absolute one. Returns '' for empty or invalid input.
   * Examples: ('/a/1.jpg') -> 'https://site.com/a/1.jpg'; ('//cdn.x/1.jpg') -> 'https://cdn.x/1.jpg'.
   * @param {string} url - The URL found in the page.
   * @param {string} [baseUrl=window.location.href] - Base used for relative URLs.
   * @returns {string}
   */
  function toAbsoluteUrl(url, baseUrl) {
    if (typeof url !== 'string' || url.trim() === '') {
      return '';
    }
    const trimmed = url.trim();
    if (trimmed.indexOf('data:') === 0 || trimmed.indexOf('blob:') === 0) {
      return trimmed;
    }
    try {
      return new URL(trimmed, baseUrl || window.location.href).href;
    } catch (error) {
      logDebug('toAbsoluteUrl: invalid URL ignored', trimmed);
      return '';
    }
  }

  /**
   * Parses an HTML (or XML) string into a Document without running its scripts.
   * @param {string} text - Markup.
   * @param {string} [format='text/html'] - MIME type for DOMParser.
   * @returns {Document}
   */
  function parseHtml(text, format) {
    return new DOMParser().parseFromString(toTrustedHtml(text), format || 'text/html');
  }

  /**
   * fetch() with a timeout. Rejects with a clear message on network error, timeout or HTTP error.
   * @param {string} url - URL to fetch.
   * @param {RequestInit} [options] - fetch options (headers, credentials...).
   * @param {number} [timeoutMs=CONFIG.fetchTimeoutMs] - Timeout.
   * @returns {Promise<Response>}
   */
  function fetchOrThrow(url, options, timeoutMs) {
    const limit = timeoutMs || CONFIG.fetchTimeoutMs;
    logDebug('fetch start', url);
    const request = fetch(url, options).then(function (response) {
      if (!response.ok) {
        throw new Error('HTTP ' + response.status + ' ' + response.statusText + ' for ' + url);
      }
      logDebug('fetch ok', url, response.status);
      return response;
    });
    return withTimeout(request, limit, 'fetch ' + url);
  }

  /**
   * Fetches a URL and parses it as JSON. THROWS on any failure (site cannot continue without it).
   * @param {string} url - URL of the JSON API.
   * @param {RequestInit} [options] - fetch options.
   * @returns {Promise<*>}
   */
  async function fetchJson(url, options) {
    const response = await fetchOrThrow(url, options);
    try {
      return await response.json();
    } catch (error) {
      throw new Error('Invalid JSON from ' + url + ': ' + error.message);
    }
  }

  /**
   * Fetches a URL through the userscript manager (GM_xmlhttpRequest) and parses it as JSON.
   * Unlike fetchJson (page fetch, CORS-limited), the manager request is not bound by the
   * site's CORS headers, so it works for cross-origin APIs (e.g. a CDN GraphQL endpoint).
   * Falls back to fetchJson when no manager request function is available. THROWS on failure.
   * @param {string} url - URL of the JSON API.
   * @param {object} [options] - Request options ({ method, headers, data }).
   * @returns {Promise<*>}
   */
  function fetchJsonViaManager(url, options) {
    const settings = options || {};
    if (typeof GM_xmlhttpRequest === 'undefined') {
      // Without a manager: same request through the page's fetch (fetch calls the payload 'body').
      return fetchJson(url, { method: settings.method || 'GET', headers: settings.headers || {}, body: settings.data || undefined });
    }
    logDebug('manager fetch start', url);
    const request = new Promise(function (resolve, reject) {
      try {
        GM_xmlhttpRequest({
          method: settings.method || 'GET',
          url: url,
          headers: settings.headers || {},
          data: settings.data || '',
          onload: function (response) {
            if (response.status < 200 || response.status >= 300) {
              reject(new Error('HTTP ' + response.status + ' for ' + url));
              return;
            }
            try {
              resolve(JSON.parse(response.responseText));
            } catch (error) {
              reject(new Error('Invalid JSON from ' + url + ': ' + error.message));
            }
          },
          onerror: function () {
            reject(new Error('Manager request failed for ' + url));
          },
          ontimeout: function () {
            reject(new Error('Manager request timed out for ' + url));
          }
        });
      } catch (error) {
        reject(error);
      }
    });
    return withTimeout(request, CONFIG.fetchTimeoutMs, 'manager fetch ' + url);
  }

  /**
   * Fetches a URL and returns the body text. THROWS on any failure.
   * @param {string} url - URL.
   * @param {RequestInit} [options] - fetch options.
   * @returns {Promise<string>}
   */
  async function fetchTextOrThrow(url, options) {
    const response = await fetchOrThrow(url, options);
    return response.text();
  }

  /**
   * Fetches a page and parses it. THROWS on any failure. Use in site modules.
   * @param {string} url - Page URL.
   * @param {RequestInit} [options] - fetch options.
   * @returns {Promise<Document>}
   */
  async function fetchDocument(url, options) {
    const text = await fetchTextOrThrow(url, options);
    return parseHtml(text, 'text/html');
  }

  /**
   * Fetches a page and parses it. NEVER throws: returns null on failure (and logs why).
   * Used by the core for the "listPages" strategy, where one bad page must not stop the others.
   * @param {string} url - Page URL.
   * @returns {Promise<Document|null>}
   */
  async function fetchHtml(url) {
    try {
      return await fetchDocument(url);
    } catch (error) {
      logError('fetchHtml', error, { url: url });
      return null;
    }
  }

  /**
   * Fetches a page and returns one attribute of one element in it, as an absolute URL when it
   * looks like a URL. Returns '' on any failure (never throws).
   * @param {string} url - Page URL.
   * @param {string} selector - CSS selector of the element inside that page.
   * @param {string} attribute - Attribute to read (e.g. 'src', 'data-src').
   * @returns {Promise<string>}
   */
  async function getElementAttribute(url, selector, attribute) {
    const pageDocument = await fetchHtml(url);
    if (!pageDocument) {
      return '';
    }
    const value = queryAttribute(selector, attribute, pageDocument).trim();
    if (value === '') {
      logError('getElementAttribute', new Error('Element or attribute not found'), { url: url, selector: selector, attribute: attribute });
      return '';
    }
    return toAbsoluteUrl(value, url);
  }

  // ==========================================================================================
  // ==== 07. LOCALES ===========================================================================
  // UI texts in 6 languages (en_US first = fallback). To add a text: add the key to EVERY
  // language object; a key missing in one language falls back to English.
  // ==========================================================================================

  /** All UI strings, one object per language. Generated from NEW:1078-1919; en_US is the fallback. */
  const LOCALES = [
    {
      ID: "en_US",
      NAME: "English (US)",
      STARTING: "Starting Manga Viewer MM",
      RESUME: "Resuming reading from Page ",
      WAITING: "Please wait, 3 seconds...",
      CHOOSE_BEGINNING: "Choose the Page to start from:",
      BUTTON_START: "Start Manga Viewer MM",
      SETTINGS: "Settings",
      LANGUAGE: "Language",
      COLOR_SCHEME: "Color Scheme",
      THEME: "Theme",
      THEME_COLOR: "Color",
      THEME_HUE: "Color Hue",
      THEME_SHADE: "Color Shade",
      DEFAULT_LOAD_MODE: "Default Load Mode",
      DEVICE_MODE: "Device Mode",
      DEVICE_MODE_AUTO: "Auto",
      DEVICE_MODE_DESKTOP: "Desktop",
      DEVICE_MODE_TABLET: "Tablet",
      DEVICE_MODE_MOBILE: "Mobile",
      LOAD_MODE_NORMAL: "Normal(Wait 3 sec)",
      LOAD_MODE_ALWAYS: "Always(Immediately)",
      LOAD_MODE_NEVER: "Never(Manually)",
      LOAD_SPEED: "Load Speed",
      DEFAULT_ZOOM: "Default Zoom (between 5 and 200)",
      DEFAULT_ZOOM_MODE: "Default Zoom Mode",
      MINIMUM_ZOOM: "Minimum Zoom relative to the width of screen (between 30 and 100)",
      ZOOM_STEP: "Zoom Change Step (between 5 and 50)",
      DEFAULT_VIEW_MODE: "Default View Mode",
      VIEW_MODE_VERTICAL: "Vertical",
      VIEW_MODE_LEFT: "Horizontal - Left to Right",
      VIEW_MODE_RIGHT: "Horizontal - Right to Left",
      VIEW_MODE_WEBCOMIC: "WebComic",
      VIEW_MODE_BOOK: "Book - Left to Right",
      VIEW_MODE_MANGA: "Manga - Right to Left",
      VIEW_MODE_GALLERY: "Gallery",
      FIT_WIDTH_OVERSIZED: "Fit Width if Oversized",
      SHOW_THUMBNAILS: "Show Thumbnails",
      HIDE_CONTROLS: "Always Hide Page Controls",
      HEADER_TYPE: "Change Header Type",
      HEADER_HOVER: "Hover",
      HEADER_SCROLL: "Scroll",
      HEADER_CLICK: "Click",
      HEADER_FIXED: "Fixed",
      HEADER_SIMPLE: "Simple",
      BUTTON_DOWNLOAD: "Download",
      DOWNLOAD_ZIP: "Download Zip file",
      DOWNLOAD_IMAGES: "Download Images as Zip Automatically",
      DOWNLOAD_PROGRESS: "Downloading: ##num## of ##total##",
      GENERATING_ZIP: "Generating Zip file...",
      DOWNLOAD_INCOMPLETE: "Download Incomplete",
      DOWNLOAD_INCOMPLETE_MESSAGE: "Some pages failed to download and were skipped. A list of failed pages has been added to the ZIP file.",
      BUTTON_NEXT: "Next",
      NEXT_CHAPTER: "Next Chapter",
      BUTTON_PREVIOUS: "Previous",
      PREVIOUS_CHAPTER: "Previous Chapter",
      BOOKMARKS: "Bookmarks",
      BOOKMARK: "Bookmark",
      BOOKMARK_REMOVED: "Bookmark Removed",
      BOOKMARK_SAVED: "Bookmark Saved",
      BOOKMARK_MESSAGE: "Next time you open this chapter it will resume from: Page ##num## (Only ONCE per Bookmark)",
      KEYBINDINGS: "Keybindings",
      EDIT_KEYBINDS: "Edit KeyBindings",
      SAVE_KEYBINDS: "Save KeyBindings",
      BUTTON_EDIT: "Edit",
      BUTTON_SAVE: "Save",
      KEYBIND_RULES: "\n    <h3>Supported Keys</h3>\n    Allowed modifiers: shift, option, alt, ctrl, control, command. <br/>\n    Special keys: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n    Examples: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n  ",
      ATTENTION: "Attention",
      WARNING: "Warning",
      BUTTON_RESET_SETTINGS: "Reset Settings",
      SETTINGS_RESET: "Settings have been reset, reload the page to take effect",
      LANGUAGE_CHANGED: "Language has been changed, reload the page to take effect",
      AUTO_DOWNLOAD: "Next time a chapter finish loading you will be prompted to save automatically",
      LAZY_LOAD: "Pages that have not loaded yet are left out of the ZIP download and listed in failed_pages.txt.<br/> Suggestion: <span style='color:red;font-weight:bold'>Disable Thumbnails</span> to save Bandwidth/Memory.",
      LAZY_LOAD_IMAGES_ENABLE: "Enable Lazy Load Images",
      LAZY_LOAD_IMAGES: "Lazy Start From Page (between 5 and 100)",
      RETURN_CHAPTER_LIST: "Return to Chapter List",
      PAGES_LOADED: "Pages Loaded",
      GO_TO_PAGE: "Go to Page",
      ENLARGE: "Enlarge",
      RESTORE: "Restore",
      REDUCE: "Reduce",
      FIT_WIDTH: "Fit Width",
      FIT_HEIGHT: "Fit Height",
      PERCENT: "Percent",
      TOGGLE_CONTROLS: "Toggle page controls",
      ZOOM_IN: "Zoom In",
      ZOOM_OUT: "Zoom Out",
      ZOOM_RESET: "Zoom Reset",
      ZOOM_WIDTH: "Zoom to Width",
      ZOOM_HEIGHT: "Zoom to Height",
      HIDE: "Hide",
      RELOAD: "Reload",
      SLOWLY: "Slowly",
      NORMAL: "Normal",
      FAST: "Fast",
      EXTREME: "Extreme",
      ALL_PAGES: "All Pages",
      SPEED_WARNING: "Loading Speed too High",
      SPEED_WARNING_MESSAGE: "This speed is not recommended.<br/> It may hurt some servers or get your IP marked as DDoS attacker.<br/> Please use with caution!",
      SCROLL_UP: "Scroll Up",
      SCROLL_DOWN: "Scroll Down",
      CLOSE: "Close",
      CANCEL: "Cancel",
      LIST_EMPTY: "List Empty",
      SCROLL_START: "Toggle Auto Scroll",
      INCREASE_SPEED: "Increase Scroll Speed",
      DECREASE_SPEED: "Decrease Scroll Speed",
      AUTO_SCROLL_HEIGHT: "Auto Scroll Speed in Pixels",
      VERTICAL_SEPARATOR: "Show Vertical Separators",
      END: "End",
      SCOPE: "Scope",
      GLOBAL: "Global",
      GENERAL: "General",
      LOADING: "Loading",
      ZOOM: "Zoom",
      OTHERS: "Others",
      NAVBAR_TYPE: "Change Navbar Type",
      NAVBAR_BOTTOM: "Bottom",
      NAVBAR_LEFT: "Left",
      NAVBAR_RIGHT: "Right",
      NAVBAR_DISABLED: "Disabled",
      PAGINATION_TYPE: "Pagination Type",
      PAGINATION_DISABLED: "Disabled",
      PAGINATION_SLIDER: "Slider",
      PAGINATION_ARROWS: "Side Arrows",
      PAGINATION_BOTH: "Both",
      FILE_MENU: "Main Menu",
      VIEW_MENU: "View Menu",
      ZOOM_MENU: "Zoom Menu",
      DOUBLE_PAGE: "Toggle Double Page",
      CHOOSE_FILE: "Choose File",
      NO_FILES_SELECTED: "No files selected",
      // Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
      COLOR_SCHEME_DARK: "Dark",
      COLOR_SCHEME_LIGHT: "Light",
      LAZY_LOAD_RULE: "Lazy Load Rule",
      LAZY_LOAD_RULE_DISTANCE: "Near the screen (2000 px)",
      LAZY_LOAD_RULE_LOOKAHEAD: "Next 5 pages",
      LAZY_LOAD_RULE_BOTH: "Both",
      KEYBIND_INVALID: "Unknown key: ##key##",
      KEYBIND_DUPLICATE: "##key## is already used by: ##action##",
      // Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
      ENABLE_COMMENTS: "Capture Comments (When available)",
      DISPLAY_COMMENTS: "Display Comments",
      COMMENTS: "Comments Section",
      COMMENTS_COLOR_SCHEME: "Comments: light / dark",
      BOOKMARK_OPEN: "Open Bookmark",
      BOOKMARK_DELETE: "Delete Bookmark",
      BOOKMARK_PAGE: "Page",
      BOOKMARK_TOGGLE_CURRENT: "Bookmark the current page",
      BOOKMARKS_CLEAR: "Remove all bookmarks",
      BOOKMARKS_CLEAR_CONFIRM: "Remove all ##count## bookmarks? This cannot be undone.",
      SETTING_CHANGED: "##setting## changed",
      SETTING_CHANGED_DETAIL: "from ##old## to ##new##",
      SCOPE_CHANGED: "Settings scope: ##scope##",
      DOWNLOAD_PARTIAL_STARTED: "Downloading ##loaded## of ##total## pages; ##pending## are still loading",
      DOWNLOAD_PARTIAL_NOTE: "Pages not loaded yet are left out and listed in failed_pages.txt inside the ZIP.",
      DOWNLOAD_NOTHING_LOADED: "No page has finished loading yet. Try again in a moment.",
      DOWNLOAD_BUSY: "A download is already running",
      DOWNLOAD_CANCELLED: "Download cancelled",
      DOWNLOAD_SAVED: "Saved ##file##",
      DOWNLOAD_FAILED: "Download failed: ##error##",
      DOWNLOAD_SKIPPED_PAGES: "Skipped pages: ##pages##",
      LOCAL_FILES_OPEN: "Open local files",
      LOCAL_FILES_IMAGES: "Images",
      LOCAL_FILES_FOLDER: "Folder",
      LOCAL_FILES_ARCHIVE: "ZIP / CBZ archive",
      LOCAL_FILES_HINT: "Choose images, a folder of images, or a ZIP/CBZ archive. Nothing is uploaded: your browser reads the files.",
      LOCAL_FILES_READING: "Reading ##file##...",
      LOCAL_FILES_UNSUPPORTED: "Unsupported file: ##file## (supported: images, folders, ZIP/CBZ; not RAR/CBR or 7z)",
      LOCAL_FILES_EMPTY: "No images found in ##file##",
      LOCAL_FILES_ERROR: "Could not read ##file##: ##error##",
      LOCAL_FILES_TITLE: "Local Images",
      LOCAL_FILES_ALREADY_OPEN: "The reader is already open. Reload the page to open other files.",
      PAGE_RANGE_NEED_NUMBERS: "Please type two page numbers.",
      PAGE_RANGE_INVALID: "Pages must be between 1 and ##total##, first <= last.",
      // Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
      SCROLL_PREPARING: "Preparing pages...",
      SCROLL_PROGRESS: "Scrolling and waiting for pages to load: ##num## / ##total##",
      CLICK_PROGRESS: "Opening pages one by one: ##num## / ##total##"
    },
    {
      ID: "es_ES",
      NAME: "Español (ES)",
      STARTING: "Iniciando Manga Viewer MM",
      RESUME: "Continuando lectura desde la Página ",
      WAITING: "Por favor espere, 3 segundos...",
      CHOOSE_BEGINNING: "Elija la página en la que comenzar:",
      BUTTON_START: "Iniciar Manga Viewer MM",
      SETTINGS: "Ajustes",
      LANGUAGE: "Idioma",
      COLOR_SCHEME: "Esquema de color",
      THEME: "Tema",
      THEME_COLOR: "Color",
      THEME_HUE: "Matiz del color",
      THEME_SHADE: "Saturación del color",
      DEFAULT_LOAD_MODE: "Modo de carga por defecto",
      DEVICE_MODE: "Modo de dispositivo",
      DEVICE_MODE_AUTO: "Automático",
      DEVICE_MODE_DESKTOP: "Escritorio",
      DEVICE_MODE_TABLET: "Tableta",
      DEVICE_MODE_MOBILE: "Móvil",
      LOAD_MODE_NORMAL: "Normal (Espera 3s)",
      LOAD_MODE_ALWAYS: "Siempre (Inmediatamente)",
      LOAD_MODE_NEVER: "Nunca (Manualmente)",
      LOAD_SPEED: "Velocidad carga",
      DEFAULT_ZOOM: "Zoom por defecto (entre 5 y 200)",
      DEFAULT_ZOOM_MODE: "Modo de zoom por defecto",
      MINIMUM_ZOOM: "Zoom mínimo relativo al ancho de la pantalla",
      ZOOM_STEP: "Paso entre cambios de zoom (entre 5 y 50)",
      DEFAULT_VIEW_MODE: "Modo de visualización por defecto",
      VIEW_MODE_VERTICAL: "Vertical",
      VIEW_MODE_LEFT: "Horizontal - Izquierda a derecha",
      VIEW_MODE_RIGHT: "Horizontal - Derecha a izquierda",
      VIEW_MODE_WEBCOMIC: "WebComic",
      VIEW_MODE_BOOK: "Libro - Izquierda a derecha",
      VIEW_MODE_MANGA: "Manga - Derecha a izquierda",
      VIEW_MODE_GALLERY: "Galería",
      FIT_WIDTH_OVERSIZED: "Ajustar ancho si es demasiado grande",
      SHOW_THUMBNAILS: "Mostrar miniaturas",
      HIDE_CONTROLS: "Ocultar siempre la barra de controles",
      HEADER_TYPE: "Cambiar tipo de cabecera",
      HEADER_HOVER: "Pasar por encima",
      HEADER_SCROLL: "Desplazamiento",
      HEADER_CLICK: "Hacer click",
      HEADER_FIXED: "Fijo",
      HEADER_SIMPLE: "Sencillo",
      BUTTON_DOWNLOAD: "Descargar",
      DOWNLOAD_ZIP: "Descargar fichero Zip",
      DOWNLOAD_IMAGES: "Autodescargar imágenes como Zip",
      DOWNLOAD_PROGRESS: "Descargando: ##num## de ##total##",
      GENERATING_ZIP: "Generando archivo Zip...",
      DOWNLOAD_INCOMPLETE: "Descarga Incompleta",
      DOWNLOAD_INCOMPLETE_MESSAGE: "Algunas páginas no se pudieron descargar y se saltaron. Se ha añadido una lista de páginas fallidas al archivo ZIP.",
      BUTTON_NEXT: "Siguiente",
      NEXT_CHAPTER: "Siguiente capítulo",
      BUTTON_PREVIOUS: "Anterior",
      PREVIOUS_CHAPTER: "Capítulo anterior",
      BOOKMARKS: "Marcadores",
      BOOKMARK: "Marcador",
      BOOKMARK_REMOVED: "Marcador eliminado",
      BOOKMARK_SAVED: "Marcador guardado",
      BOOKMARK_MESSAGE: "La próxima vez que abra este capítulo, continuará desde la página ##num## (Sólo UNA VEZ por Marcador)",
      KEYBINDINGS: "Atajos de teclado",
      EDIT_KEYBINDS: "Editar atajos",
      SAVE_KEYBINDS: "Guardar atajos",
      BUTTON_EDIT: "Editar",
      BUTTON_SAVE: "Guardar",
      KEYBIND_RULES: "\n    <h3>Teclas soportadas</h3>\n    Modificadores permitidos: shift, option, alt, ctrl, control, command. <br/>\n    Teclas especiales: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n    Ejemplos: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n  ",
      ATTENTION: "Atención",
      WARNING: "Alerta",
      BUTTON_RESET_SETTINGS: "Reiniciar ajustes(Reset Settings)",
      SETTINGS_RESET: "Se han restablecido los ajustes, vuelve a cargar la página para que surta efecto",
      LANGUAGE_CHANGED: "Se ha cambiado el idioma, vuelve a cargar la página para que surta efecto",
      AUTO_DOWNLOAD: "La próxima vez que termine de cargarse un capítulo, se le pedirá que guarde automáticamente",
      LAZY_LOAD: "Las páginas que aún no se han cargado quedan fuera de la descarga ZIP y se listan en failed_pages.txt.<br/> Sugerencia: <span style='color:red;font-weight:bold'>Desactivar miniaturas</span> para ahorrar Ancho de banda/Memoria.",
      LAZY_LOAD_IMAGES_ENABLE: "Habilitar carga de imágenes diferida",
      LAZY_LOAD_IMAGES: "Empezar carga diferida a partir de la página (entre 5 y 100)",
      RETURN_CHAPTER_LIST: "Regresar a la lista de capítulos",
      PAGES_LOADED: "Páginas cargadas",
      GO_TO_PAGE: "Ir a página",
      ENLARGE: "Agrandar",
      RESTORE: "Restaurar",
      REDUCE: "Reducir",
      FIT_WIDTH: "Ajustar al ancho",
      FIT_HEIGHT: "Ajustar al alto",
      PERCENT: "Porcentual",
      TOGGLE_CONTROLS: "Alternar controles de página",
      ZOOM_IN: "Acercar",
      ZOOM_OUT: "Alejar",
      ZOOM_RESET: "Restablecer zoom",
      ZOOM_WIDTH: "Zoom al ancho",
      ZOOM_HEIGHT: "Zoom al alto",
      HIDE: "Ocultar",
      RELOAD: "Recargar",
      SLOWLY: "Lento",
      NORMAL: "Normal",
      FAST: "Rápido",
      EXTREME: "Extremo",
      ALL_PAGES: "Todas las páginas",
      SPEED_WARNING: "Velocidad de carga muy alta",
      SPEED_WARNING_MESSAGE: "No se recomienda esta velocidad.<br/> Puede dañar algunos servidores o marcar su IP como atacante DDoS.<br/> ¡Utilícelo con precaución!",
      SCROLL_UP: "Desplazar arriba",
      SCROLL_DOWN: "Desplazarse hacia abajo",
      CLOSE: "Cerrar",
      CANCEL: "Cancelar",
      LIST_EMPTY: "Lista vacía",
      SCROLL_START: "Alternar desplazamiento automático",
      INCREASE_SPEED: "Aumentar la velocidad de desplazamiento",
      DECREASE_SPEED: "Disminuir la velocidad de desplazamiento",
      AUTO_SCROLL_HEIGHT: "Velocidad de desplazamiento automático en píxeles",
      VERTICAL_SEPARATOR: "Mostrar separadores verticales",
      END: "Fin",
      SCOPE: "Alcance",
      GLOBAL: "Global",
      GENERAL: "General",
      LOADING: "Carga",
      ZOOM: "Zoom",
      OTHERS: "Otros",
      NAVBAR_TYPE: "Cambiar el tipo de barra de navegación",
      NAVBAR_BOTTOM: "Abajo",
      NAVBAR_LEFT: "Izquierda",
      NAVBAR_RIGHT: "Derecha",
      NAVBAR_DISABLED: "Desactivado",
      PAGINATION_TYPE: "Tipo de paginación",
      PAGINATION_DISABLED: "Desactivado",
      PAGINATION_SLIDER: "Control deslizante",
      PAGINATION_ARROWS: "Flechas laterales",
      PAGINATION_BOTH: "Ambos",
      FILE_MENU: "Menú principal",
      VIEW_MENU: "Ver menú",
      ZOOM_MENU: "Menú Zoom",
      DOUBLE_PAGE: "Alternar Página Doble",
      CHOOSE_FILE: "Elegir archivo",
      NO_FILES_SELECTED: "No se han seleccionado archivos",
      // Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
      COLOR_SCHEME_DARK: "Oscuro",
      COLOR_SCHEME_LIGHT: "Claro",
      LAZY_LOAD_RULE: "Regla de carga diferida",
      LAZY_LOAD_RULE_DISTANCE: "Cerca de la pantalla (2000 px)",
      LAZY_LOAD_RULE_LOOKAHEAD: "Siguientes 5 páginas",
      LAZY_LOAD_RULE_BOTH: "Ambas",
      KEYBIND_INVALID: "Tecla desconocida: ##key##",
      KEYBIND_DUPLICATE: "##key## ya está asignada a: ##action##",
      // Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
      ENABLE_COMMENTS: "Capturar comentarios (cuando esté disponible)",
      DISPLAY_COMMENTS: "Mostrar comentarios",
      COMMENTS: "Sección de comentarios",
      COMMENTS_COLOR_SCHEME: "Comentarios: claro / oscuro",
      BOOKMARK_OPEN: "Abrir marcador",
      BOOKMARK_DELETE: "Eliminar marcador",
      BOOKMARK_PAGE: "Página",
      BOOKMARK_TOGGLE_CURRENT: "Marcar la página actual",
      BOOKMARKS_CLEAR: "Eliminar todos los marcadores",
      BOOKMARKS_CLEAR_CONFIRM: "¿Eliminar los ##count## marcadores? No se puede deshacer.",
      SETTING_CHANGED: "##setting## cambiado",
      SETTING_CHANGED_DETAIL: "de ##old## a ##new##",
      SCOPE_CHANGED: "Ámbito de ajustes: ##scope##",
      DOWNLOAD_PARTIAL_STARTED: "Descargando ##loaded## de ##total## páginas; ##pending## aún se están cargando",
      DOWNLOAD_PARTIAL_NOTE: "Las páginas aún no cargadas se omiten y se listan en failed_pages.txt dentro del ZIP.",
      DOWNLOAD_NOTHING_LOADED: "Ninguna página ha terminado de cargar todavía. Inténtelo de nuevo en un momento.",
      DOWNLOAD_BUSY: "Ya hay una descarga en curso",
      DOWNLOAD_CANCELLED: "Descarga cancelada",
      DOWNLOAD_SAVED: "Guardado ##file##",
      DOWNLOAD_FAILED: "Error en la descarga: ##error##",
      DOWNLOAD_SKIPPED_PAGES: "Páginas omitidas: ##pages##",
      LOCAL_FILES_OPEN: "Abrir archivos locales",
      LOCAL_FILES_IMAGES: "Imágenes",
      LOCAL_FILES_FOLDER: "Carpeta",
      LOCAL_FILES_ARCHIVE: "Archivo ZIP / CBZ",
      LOCAL_FILES_HINT: "Elija imágenes, una carpeta de imágenes o un archivo ZIP/CBZ. No se sube nada: su navegador lee los archivos.",
      LOCAL_FILES_READING: "Leyendo ##file##...",
      LOCAL_FILES_UNSUPPORTED: "Archivo no compatible: ##file## (compatibles: imágenes, carpetas, ZIP/CBZ; no RAR/CBR ni 7z)",
      LOCAL_FILES_EMPTY: "No se encontraron imágenes en ##file##",
      LOCAL_FILES_ERROR: "No se pudo leer ##file##: ##error##",
      LOCAL_FILES_TITLE: "Imágenes locales",
      LOCAL_FILES_ALREADY_OPEN: "El lector ya está abierto. Recargue la página para abrir otros archivos.",
      PAGE_RANGE_NEED_NUMBERS: "Escriba dos números de página.",
      PAGE_RANGE_INVALID: "Las páginas deben estar entre 1 y ##total##, primera <= última.",
      // Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
      SCROLL_PREPARING: "Preparando páginas...",
      SCROLL_PROGRESS: "Desplazando y esperando a que carguen las páginas: ##num## / ##total##",
      CLICK_PROGRESS: "Abriendo las páginas una por una: ##num## / ##total##"
    },
    {
      ID: "pt_BR",
      NAME: "Portugues (Brasil)",
      STARTING: "Iniciando Manga Viewer MM",
      RESUME: "Continuando leitura na Pagina ",
      WAITING: "Por Favor espere, 3 segundos...",
      CHOOSE_BEGINNING: "Escolha a pagina de onde começar:",
      BUTTON_START: "Iniciar Manga Viewer MM",
      SETTINGS: "Configurações",
      LANGUAGE: "Idioma",
      COLOR_SCHEME: "Esquema de Color",
      THEME: "Tema",
      THEME_COLOR: "Cor",
      THEME_HUE: "Tom da Cor",
      THEME_SHADE: "Saturação da Cor",
      DEFAULT_LOAD_MODE: "Forma de Carregamento Padrão",
      DEVICE_MODE: "Modo de Dispositivo",
      DEVICE_MODE_AUTO: "Automático",
      DEVICE_MODE_DESKTOP: "Desktop",
      DEVICE_MODE_TABLET: "Tablet",
      DEVICE_MODE_MOBILE: "Celular",
      LOAD_MODE_NORMAL: "Normal(Esperando 3 sec)",
      LOAD_MODE_ALWAYS: "Sempre(Imediatamente)",
      LOAD_MODE_NEVER: "Nunca(Manualmente)",
      LOAD_SPEED: "Velocidade de Carregamento",
      DEFAULT_ZOOM: "Zoom padrão (entre 5 e 200)",
      DEFAULT_ZOOM_MODE: "Modo de Zoom padrão",
      MINIMUM_ZOOM: "Zoom minimo, relativo ao tamanho da tela (entre 30 e 100)",
      ZOOM_STEP: "Precisão da Mudança do Zoom (entre 5 e 50)",
      DEFAULT_VIEW_MODE: "Modo de Visualização Padrão",
      VIEW_MODE_VERTICAL: "Vertical",
      VIEW_MODE_LEFT: "Horizontal - Esquerda para Direita",
      VIEW_MODE_RIGHT: "Horizontal - Direita para Esquerda",
      VIEW_MODE_WEBCOMIC: "WebComic",
      VIEW_MODE_BOOK: "Livro - Esquerda para Direita",
      VIEW_MODE_MANGA: "Mangá - Direita para Esquerda",
      VIEW_MODE_GALLERY: "Galeria",
      FIT_WIDTH_OVERSIZED: "Encher a tela se grande demais",
      SHOW_THUMBNAILS: "Mostra Miniaturas",
      HIDE_CONTROLS: "Sempre esconder controles das paginas",
      HEADER_TYPE: "Mudar Tipo de Cabeçalho",
      HEADER_HOVER: "Passar por perto",
      HEADER_SCROLL: "Rolagem do Mouse",
      HEADER_CLICK: "Click",
      HEADER_FIXED: "Fixo",
      HEADER_SIMPLE: "Simples",
      BUTTON_DOWNLOAD: "Download",
      DOWNLOAD_ZIP: "Baixar arquivo Zip",
      DOWNLOAD_IMAGES: "Download das Imagens como Zip Automaticamente",
      DOWNLOAD_PROGRESS: "Baixando: ##num## de ##total##",
      GENERATING_ZIP: "Gerando arquivo Zip...",
      DOWNLOAD_INCOMPLETE: "Download Incompleto",
      DOWNLOAD_INCOMPLETE_MESSAGE: "Algumas páginas falharam ao baixar e foram puladas. Uma lista de páginas que falharam foi adicionada ao arquivo ZIP.",
      BUTTON_NEXT: "Proximo",
      NEXT_CHAPTER: "Proximo Capitulo",
      BUTTON_PREVIOUS: "Anterior",
      PREVIOUS_CHAPTER: "Capitulo Anterior",
      BOOKMARKS: "Marca paginas",
      BOOKMARK: "Marcar pagina",
      BOOKMARK_REMOVED: "Marca pagina Removido",
      BOOKMARK_SAVED: "Marca pagina Salvo",
      BOOKMARK_MESSAGE: "Proxima vez que abrir este capitulo continuará a partir da Pagina ##num## (Apenas UMA VEZ por marca pagina)",
      KEYBINDINGS: "Atalhos",
      EDIT_KEYBINDS: "Editar Atalhos",
      SAVE_KEYBINDS: "Salvar Atalhos",
      BUTTON_EDIT: "Editar",
      BUTTON_SAVE: "Salvar",
      KEYBIND_RULES: "\n    <h3>Teclas Suportadas</h3>\n    Modificadores permitidos: shift, option, alt, ctrl, control, command. <br/>\n    Teclas Especiais: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide.<br/>\n    Exemplos: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n  ",
      ATTENTION: "Atenção",
      WARNING: "Alerta",
      BUTTON_RESET_SETTINGS: "Limpar Configurações(Reset Settings)",
      SETTINGS_RESET: "Configurações foram limpas, recarregue o site para efetivar a alteração",
      LANGUAGE_CHANGED: "Idioma foi alterado, recarregue o site para efetivar a alteração",
      AUTO_DOWNLOAD: "Proxima vez que abrir um capitulo download iniciara automaticamente",
      LAZY_LOAD: "Páginas que ainda não carregaram ficam fora do download ZIP e são listadas em failed_pages.txt.<br/> Sugestão: <span style='color:red;font-weight:bold'>Desative Miniaturas</span> para economizar memoria e cota de internet.",
      LAZY_LOAD_IMAGES_ENABLE: "Ativar Carregamento de imagens preguiçoso",
      LAZY_LOAD_IMAGES: "Carregamento de paginas preguiçoso começa a partir de (entre 5 e 100)",
      RETURN_CHAPTER_LIST: "Voltar a lista de Capitulos",
      PAGES_LOADED: "Paginas Carregadas",
      GO_TO_PAGE: "Pular para",
      ENLARGE: "Aumentar",
      RESTORE: "Restaurar",
      REDUCE: "Diminuir",
      FIT_WIDTH: "Preencher Largura",
      FIT_HEIGHT: "Preencher Altura ",
      PERCENT: "Percentual",
      TOGGLE_CONTROLS: "Mostar controles de pagina",
      ZOOM_IN: "Mais Zoom",
      ZOOM_OUT: "Menos Zoom",
      ZOOM_RESET: "Resetar Zoom",
      ZOOM_WIDTH: "Zoom para Largura",
      ZOOM_HEIGHT: "Zoom para Altura",
      HIDE: "Esconder",
      RELOAD: "Recarregar",
      SLOWLY: "Devagar",
      NORMAL: "Normal",
      FAST: "Rapido",
      EXTREME: "Extremo",
      ALL_PAGES: "Todas as Paginas",
      SPEED_WARNING: "Velocidade de Carregamento muito alta",
      SPEED_WARNING_MESSAGE: "Essa velocidade não é recomendada.<br/> Ela pode derrubar um servidor or marcar voce como um ataque hacker de DDoS.<br/> Use com cuidado!",
      SCROLL_UP: "Subir Pagina",
      SCROLL_DOWN: "Descer Pagina",
      CLOSE: "Fechar",
      CANCEL: "Cancelar",
      LIST_EMPTY: "Lista Vazia",
      SCROLL_START: "Ativar Rolagem Automatica",
      INCREASE_SPEED: "Aumentar Valocidade da Rolagem",
      DECREASE_SPEED: "Diminuir Valocidade da Rolagem",
      AUTO_SCROLL_HEIGHT: "Velocidade da Rolagem Automatica em Pixels",
      VERTICAL_SEPARATOR: "Mostrar Separadores Verticais",
      END: "Fin",
      SCOPE: "Escopo",
      GLOBAL: "Global",
      GENERAL: "Geral",
      LOADING: "Carregamento",
      ZOOM: "Zoom",
      OTHERS: "Outros",
      NAVBAR_TYPE: "Mudar barra de navegação",
      NAVBAR_BOTTOM: "Embaixo",
      NAVBAR_LEFT: "Esquerda",
      NAVBAR_RIGHT: "Direita",
      NAVBAR_DISABLED: "Desativado",
      PAGINATION_TYPE: "Tipo de Paginação",
      PAGINATION_DISABLED: "Desativado",
      PAGINATION_SLIDER: "Controle deslizante",
      PAGINATION_ARROWS: "Setas Laterais",
      PAGINATION_BOTH: "Ambos",
      FILE_MENU: "Menu Principal",
      VIEW_MENU: "Menu de Visualizações",
      ZOOM_MENU: "Menu de Zoom",
      DOUBLE_PAGE: "Alternar Página Dupla",
      CHOOSE_FILE: "Escolher arquivo",
      NO_FILES_SELECTED: "Nenhum arquivo selecionado",
      // Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
      COLOR_SCHEME_DARK: "Escuro",
      COLOR_SCHEME_LIGHT: "Claro",
      LAZY_LOAD_RULE: "Regra de carregamento preguiçoso",
      LAZY_LOAD_RULE_DISTANCE: "Perto da tela (2000 px)",
      LAZY_LOAD_RULE_LOOKAHEAD: "Próximas 5 páginas",
      LAZY_LOAD_RULE_BOTH: "Ambas",
      KEYBIND_INVALID: "Tecla desconhecida: ##key##",
      KEYBIND_DUPLICATE: "##key## já está em uso por: ##action##",
      // Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
      ENABLE_COMMENTS: "Capturar comentários (quando disponível)",
      DISPLAY_COMMENTS: "Mostrar comentários",
      COMMENTS: "Seção de comentários",
      COMMENTS_COLOR_SCHEME: "Comentários: claro / escuro",
      BOOKMARK_OPEN: "Abrir marcador",
      BOOKMARK_DELETE: "Excluir marcador",
      BOOKMARK_PAGE: "Página",
      BOOKMARK_TOGGLE_CURRENT: "Marcar a página atual",
      BOOKMARKS_CLEAR: "Remover todos os marcadores",
      BOOKMARKS_CLEAR_CONFIRM: "Remover todos os ##count## marcadores? Isso não pode ser desfeito.",
      SETTING_CHANGED: "##setting## alterado",
      SETTING_CHANGED_DETAIL: "de ##old## para ##new##",
      SCOPE_CHANGED: "Escopo das configurações: ##scope##",
      DOWNLOAD_PARTIAL_STARTED: "Baixando ##loaded## de ##total## páginas; ##pending## ainda estão carregando",
      DOWNLOAD_PARTIAL_NOTE: "Páginas ainda não carregadas ficam de fora e são listadas em failed_pages.txt dentro do ZIP.",
      DOWNLOAD_NOTHING_LOADED: "Nenhuma página terminou de carregar ainda. Tente novamente em instantes.",
      DOWNLOAD_BUSY: "Já há um download em andamento",
      DOWNLOAD_CANCELLED: "Download cancelado",
      DOWNLOAD_SAVED: "##file## salvo",
      DOWNLOAD_FAILED: "Falha no download: ##error##",
      DOWNLOAD_SKIPPED_PAGES: "Páginas puladas: ##pages##",
      LOCAL_FILES_OPEN: "Abrir arquivos locais",
      LOCAL_FILES_IMAGES: "Imagens",
      LOCAL_FILES_FOLDER: "Pasta",
      LOCAL_FILES_ARCHIVE: "Arquivo ZIP / CBZ",
      LOCAL_FILES_HINT: "Escolha imagens, uma pasta de imagens ou um arquivo ZIP/CBZ. Nada é enviado: seu navegador lê os arquivos.",
      LOCAL_FILES_READING: "Lendo ##file##...",
      LOCAL_FILES_UNSUPPORTED: "Arquivo não suportado: ##file## (suportados: imagens, pastas, ZIP/CBZ; não RAR/CBR nem 7z)",
      LOCAL_FILES_EMPTY: "Nenhuma imagem encontrada em ##file##",
      LOCAL_FILES_ERROR: "Não foi possível ler ##file##: ##error##",
      LOCAL_FILES_TITLE: "Imagens locais",
      LOCAL_FILES_ALREADY_OPEN: "O leitor já está aberto. Recarregue a página para abrir outros arquivos.",
      PAGE_RANGE_NEED_NUMBERS: "Digite dois números de página.",
      PAGE_RANGE_INVALID: "As páginas devem estar entre 1 e ##total##, primeira <= última.",
      // Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
      SCROLL_PREPARING: "Preparando páginas...",
      SCROLL_PROGRESS: "Rolando e aguardando as páginas carregarem: ##num## / ##total##",
      CLICK_PROGRESS: "Abrindo as páginas uma a uma: ##num## / ##total##"
    },
    {
      ID: "zh_CN",
      NAME: "中文 (简体)",
      STARTING: "正在启动 Manga Viewer MM",
      RESUME: "从页面继续阅读 ",
      WAITING: "请等待3秒钟...",
      CHOOSE_BEGINNING: "选择要开始的页数:",
      BUTTON_START: "启动Manga Viewer MM",
      SETTINGS: "设置",
      LANGUAGE: "语言",
      COLOR_SCHEME: "配色方案",
      THEME: "主题",
      THEME_COLOR: "颜色",
      THEME_HUE: "色相",
      THEME_SHADE: "色度",
      DEFAULT_LOAD_MODE: "默认加载模式",
      DEVICE_MODE: "设备模式",
      DEVICE_MODE_AUTO: "自动",
      DEVICE_MODE_DESKTOP: "桌面",
      DEVICE_MODE_TABLET: "平板",
      DEVICE_MODE_MOBILE: "手机",
      LOAD_MODE_NORMAL: "等待模式(等待3秒自动加载 )",
      LOAD_MODE_ALWAYS: "自动模式(无需等待)",
      LOAD_MODE_NEVER: "手动模式(点击启动)",
      LOAD_SPEED: "加载速度",
      DEFAULT_ZOOM: "默认缩放 (最小 5 最大 200)",
      DEFAULT_ZOOM_MODE: "默认缩放模式",
      MINIMUM_ZOOM: "相对于屏幕宽度的最小缩放 (最小 30 最大 100)",
      ZOOM_STEP: "缩放级别 (最小 5 最大 50)",
      DEFAULT_VIEW_MODE: "默认视图模式",
      VIEW_MODE_VERTICAL: "垂直有缝",
      VIEW_MODE_LEFT: "横向 - 从左到右",
      VIEW_MODE_RIGHT: "横向 - 从右到左",
      VIEW_MODE_WEBCOMIC: "垂直无缝",
      VIEW_MODE_BOOK: "书籍 - 从左到右",
      VIEW_MODE_MANGA: "漫画 - 从右到左",
      VIEW_MODE_GALLERY: "图库",
      FIT_WIDTH_OVERSIZED: "如果尺寸过大、则适合宽度",
      SHOW_THUMBNAILS: "显示缩略图",
      HIDE_CONTROLS: "始终隐藏页面控件",
      HEADER_TYPE: "更改标题显示方式",
      HEADER_HOVER: "悬停",
      HEADER_SCROLL: "滚动",
      HEADER_CLICK: "点击",
      HEADER_FIXED: "固定",
      HEADER_SIMPLE: "简单",
      BUTTON_DOWNLOAD: "下载",
      DOWNLOAD_ZIP: "下载压缩文件",
      DOWNLOAD_IMAGES: "自动将图片下载成ZIP",
      DOWNLOAD_PROGRESS: "正在下载:第 ##num## 页,共 ##total## 页",
      GENERATING_ZIP: "正在生成 Zip 文件...",
      DOWNLOAD_INCOMPLETE: "下载不完整",
      DOWNLOAD_INCOMPLETE_MESSAGE: "部分页面下载失败并已跳过。失败页面列表已添加到 ZIP 文件中。",
      BUTTON_NEXT: "下一页",
      NEXT_CHAPTER: "下一章",
      BUTTON_PREVIOUS: "上一页",
      PREVIOUS_CHAPTER: "上一章",
      BOOKMARKS: "书签",
      BOOKMARK: "Bookmark",
      BOOKMARK_REMOVED: "删除书签",
      BOOKMARK_SAVED: "保存书签",
      BOOKMARK_MESSAGE: "下次打开本章时,将从: 页码 ##num## (仅一次 每个书签)",
      KEYBINDINGS: "快捷键",
      EDIT_KEYBINDS: "编辑键绑定",
      SAVE_KEYBINDS: "保存键绑定",
      BUTTON_EDIT: "编辑",
      BUTTON_SAVE: "æ•‘",
      KEYBIND_RULES: "\n    <h3>支持的密钥</h3>\n    允许的修饰符: shift, option, alt, ctrl, control, command. <br/>\n    特殊键: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide.<br/>\n    例子: <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n  ",
      ATTENTION: "注意",
      WARNING: "警告",
      BUTTON_RESET_SETTINGS: "重置设置(Reset Settings)",
      SETTINGS_RESET: "设置已重置、重新加载页面才能生效",
      LANGUAGE_CHANGED: "语言已更改、重新加载页面才能生效",
      AUTO_DOWNLOAD: "下次章节加载完成时、系统将提示您自动保存",
      LAZY_LOAD: "尚未加载的页面不会包含在ZIP下载中,并会列在 failed_pages.txt 里.<br/> 建议: <span style='color:red;font-weight:bold'>禁用缩略图</span> 以节省流量和内存.",
      LAZY_LOAD_IMAGES_ENABLE: "启用延迟加载图像",
      LAZY_LOAD_IMAGES: "惰性加载从页面 (最小 5 最大 100)",
      RETURN_CHAPTER_LIST: "返回章节列表",
      PAGES_LOADED: "已加载的页数",
      GO_TO_PAGE: "转到页数",
      ENLARGE: "放大",
      RESTORE: "还原",
      REDUCE: "缩小",
      FIT_WIDTH: "适合宽度",
      FIT_HEIGHT: "适合高度",
      PERCENT: "百分之",
      TOGGLE_CONTROLS: "显示隐藏页面控件",
      ZOOM_IN: "放大",
      ZOOM_OUT: "缩小",
      ZOOM_RESET: "还原",
      ZOOM_WIDTH: "适合宽度",
      ZOOM_HEIGHT: "适合高度",
      HIDE: "显示隐藏页面控件",
      RELOAD: "重新加载",
      SLOWLY: "慢速",
      NORMAL: "正常",
      FAST: "快速",
      EXTREME: "极端",
      ALL_PAGES: "所有页面",
      SPEED_WARNING: "加载速度过高",
      SPEED_WARNING_MESSAGE: "不建议使用此速度.<br/>它可能会伤害某些服务器或将您的 IP 标记为 DDoS 攻击者.<br/>请谨慎使用!",
      SCROLL_UP: "向上滚动",
      SCROLL_DOWN: "向下滚动",
      CLOSE: "关闭",
      CANCEL: "取消",
      LIST_EMPTY: "没有收藏书签",
      SCROLL_START: "切换自动滚动",
      INCREASE_SPEED: "增加滚动速度",
      DECREASE_SPEED: "降低滚动速度",
      AUTO_SCROLL_HEIGHT: "自动滚动速度(以像素为单位)",
      VERTICAL_SEPARATOR: "显示垂直分隔符",
      END: "结尾",
      SCOPE: "范围",
      GLOBAL: "全球",
      GENERAL: "常规",
      LOADING: "装载",
      ZOOM: "缩放",
      OTHERS: "别人",
      NAVBAR_TYPE: "更改导航栏类型",
      NAVBAR_BOTTOM: "底部",
      NAVBAR_LEFT: "左边",
      NAVBAR_RIGHT: "正确的",
      NAVBAR_DISABLED: "已禁用",
      PAGINATION_TYPE: "分页类型",
      PAGINATION_DISABLED: "已禁用",
      PAGINATION_SLIDER: "滑块",
      PAGINATION_ARROWS: "侧边箭头",
      PAGINATION_BOTH: "两者",
      FILE_MENU: "主菜单",
      VIEW_MENU: "查看菜单",
      ZOOM_MENU: "缩放菜单",
      DOUBLE_PAGE: "切换双页",
      CHOOSE_FILE: "选择文件",
      NO_FILES_SELECTED: "未选择任何文件",
      // Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
      COLOR_SCHEME_DARK: "深色",
      COLOR_SCHEME_LIGHT: "浅色",
      LAZY_LOAD_RULE: "延迟加载规则",
      LAZY_LOAD_RULE_DISTANCE: "接近屏幕时 (2000 px)",
      LAZY_LOAD_RULE_LOOKAHEAD: "后面 5 页",
      LAZY_LOAD_RULE_BOTH: "两者",
      KEYBIND_INVALID: "未知按键:##key##",
      KEYBIND_DUPLICATE: "##key## 已被以下操作使用:##action##",
      // Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
      ENABLE_COMMENTS: "捕获评论(如果可用)",
      DISPLAY_COMMENTS: "显示注释",
      COMMENTS: "评论部分",
      COMMENTS_COLOR_SCHEME: "评论:浅色 / 深色",
      BOOKMARK_OPEN: "打开书签",
      BOOKMARK_DELETE: "删除书签",
      BOOKMARK_PAGE: "页",
      BOOKMARK_TOGGLE_CURRENT: "为当前页添加书签",
      BOOKMARKS_CLEAR: "删除所有书签",
      BOOKMARKS_CLEAR_CONFIRM: "删除全部 ##count## 个书签?此操作无法撤销。",
      SETTING_CHANGED: "##setting## 已更改",
      SETTING_CHANGED_DETAIL: "从 ##old## 改为 ##new##",
      SCOPE_CHANGED: "设置范围:##scope##",
      DOWNLOAD_PARTIAL_STARTED: "正在下载 ##total## 页中的 ##loaded## 页;还有 ##pending## 页仍在加载",
      DOWNLOAD_PARTIAL_NOTE: "尚未加载的页面不会下载,并列在 ZIP 内的 failed_pages.txt 中。",
      DOWNLOAD_NOTHING_LOADED: "还没有页面加载完成,请稍后再试。",
      DOWNLOAD_BUSY: "已有下载正在进行",
      DOWNLOAD_CANCELLED: "下载已取消",
      DOWNLOAD_SAVED: "已保存 ##file##",
      DOWNLOAD_FAILED: "下载失败:##error##",
      DOWNLOAD_SKIPPED_PAGES: "已跳过的页面:##pages##",
      LOCAL_FILES_OPEN: "打开本地文件",
      LOCAL_FILES_IMAGES: "图片",
      LOCAL_FILES_FOLDER: "文件夹",
      LOCAL_FILES_ARCHIVE: "ZIP / CBZ 压缩包",
      LOCAL_FILES_HINT: "选择图片、图片文件夹或 ZIP/CBZ 压缩包。不会上传任何内容:文件由浏览器读取。",
      LOCAL_FILES_READING: "正在读取 ##file##...",
      LOCAL_FILES_UNSUPPORTED: "不支持的文件:##file##(支持:图片、文件夹、ZIP/CBZ;不支持 RAR/CBR 或 7z)",
      LOCAL_FILES_EMPTY: "在 ##file## 中未找到图片",
      LOCAL_FILES_ERROR: "无法读取 ##file##:##error##",
      LOCAL_FILES_TITLE: "本地图片",
      LOCAL_FILES_ALREADY_OPEN: "阅读器已打开。请刷新页面以打开其他文件。",
      PAGE_RANGE_NEED_NUMBERS: "请输入两个页码。",
      PAGE_RANGE_INVALID: "页码必须在 1 到 ##total## 之间,且起始页 <= 结束页。",
      // Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
      SCROLL_PREPARING: "正在准备页面...",
      SCROLL_PROGRESS: "正在滚动并等待页面加载:##num## / ##total##",
      CLICK_PROGRESS: "正在逐页打开:##num## / ##total##"
    },
    {
      ID: "de_DE",
      NAME: "Deutsch",
      STARTING: "Starte Manga Viewer MM",
      RESUME: "Fortsetzen ab Seite ",
      WAITING: "Bitte warten, 3 Sekunden...",
      CHOOSE_BEGINNING: "Wähle die Startseite:",
      BUTTON_START: "Manga Viewer MM starten",
      SETTINGS: "Einstellungen",
      LANGUAGE: "Sprache",
      COLOR_SCHEME: "Farbschema",
      THEME: "Design",
      THEME_COLOR: "Farbe",
      THEME_HUE: "Farbton",
      THEME_SHADE: "Schattierung",
      DEFAULT_LOAD_MODE: "Standard-Lademodus",
      DEVICE_MODE: "Gerätemodus",
      DEVICE_MODE_AUTO: "Automatisch",
      DEVICE_MODE_DESKTOP: "Desktop",
      DEVICE_MODE_TABLET: "Tablet",
      DEVICE_MODE_MOBILE: "Mobil",
      LOAD_MODE_NORMAL: "Normal (3 Sek. warten)",
      LOAD_MODE_ALWAYS: "Immer (sofort)",
      LOAD_MODE_NEVER: "Nie (manuell)",
      LOAD_SPEED: "Ladegeschwindigkeit",
      DEFAULT_ZOOM: "Standard-Zoom (zwischen 5 und 200)",
      DEFAULT_ZOOM_MODE: "Standard-Zoommodus",
      MINIMUM_ZOOM: "Minimaler Zoom relativ zur Bildschirmbreite (zwischen 30 und 100)",
      ZOOM_STEP: "Zoom-Schrittgröße (zwischen 5 und 50)",
      DEFAULT_VIEW_MODE: "Standard-Ansichtsmodus",
      VIEW_MODE_VERTICAL: "Vertikal",
      VIEW_MODE_LEFT: "Horizontal - Links nach Rechts",
      VIEW_MODE_RIGHT: "Horizontal - Rechts nach Links",
      VIEW_MODE_WEBCOMIC: "WebComic",
      VIEW_MODE_BOOK: "Buch - Links nach Rechts",
      VIEW_MODE_MANGA: "Manga - Rechts nach Links",
      VIEW_MODE_GALLERY: "Galerie",
      FIT_WIDTH_OVERSIZED: "Breite anpassen bei Übergröße",
      SHOW_THUMBNAILS: "Miniaturansichten anzeigen",
      HIDE_CONTROLS: "Seitensteuerung immer ausblenden",
      HEADER_TYPE: "Kopfbereichstyp ändern",
      HEADER_HOVER: "Hover",
      HEADER_SCROLL: "Scrollen",
      HEADER_CLICK: "Klicken",
      HEADER_FIXED: "Fixiert",
      HEADER_SIMPLE: "Einfach",
      BUTTON_DOWNLOAD: "Herunterladen",
      DOWNLOAD_ZIP: "Zip-Datei herunterladen",
      DOWNLOAD_IMAGES: "Bilder automatisch als Zip herunterladen",
      DOWNLOAD_PROGRESS: "Herunterladen: ##num## von ##total##",
      GENERATING_ZIP: "Zip-Datei wird erstellt...",
      DOWNLOAD_INCOMPLETE: "Download unvollständig",
      DOWNLOAD_INCOMPLETE_MESSAGE: "Einige Seiten konnten nicht heruntergeladen werden und wurden übersprungen. Eine Liste der fehlgeschlagenen Seiten wurde der ZIP-Datei hinzugefügt.",
      BUTTON_NEXT: "Weiter",
      NEXT_CHAPTER: "Nächstes Kapitel",
      BUTTON_PREVIOUS: "Zurück",
      PREVIOUS_CHAPTER: "Vorheriges Kapitel",
      BOOKMARKS: "Lesezeichen",
      BOOKMARK: "Lesezeichen",
      BOOKMARK_REMOVED: "Lesezeichen entfernt",
      BOOKMARK_SAVED: "Lesezeichen gespeichert",
      BOOKMARK_MESSAGE: "Beim nächsten Öffnen dieses Kapitels wird ab fortgesetzt: Seite ##num## (Nur EINMAL pro Lesezeichen)",
      KEYBINDINGS: "Tastenkürzel",
      EDIT_KEYBINDS: "Tastenkürzel bearbeiten",
      SAVE_KEYBINDS: "Tastenkürzel speichern",
      BUTTON_EDIT: "Bearbeiten",
      BUTTON_SAVE: "Speichern",
      KEYBIND_RULES: "\n    <h3>Unterstützte Tasten</h3>\n    Erlaubte Modifikatoren: shift, option, alt, ctrl, control, command. <br/>\n    Spezielle Tasten: backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n    Beispiele: <kbd>a</kbd>, <kbd>ctrl+a</kbd>, <kbd>shift+a</kbd>, <kbd>num_2</kbd>, <kbd>2</kbd>\n  ",
      ATTENTION: "Achtung",
      WARNING: "Warnung",
      BUTTON_RESET_SETTINGS: "Einstellungen zurücksetzen(Reset Settings)",
      SETTINGS_RESET: "Die Einstellungen wurden zurückgesetzt, bitte Seite neu laden",
      LANGUAGE_CHANGED: "Die Sprache wurde geändert, bitte Seite neu laden",
      AUTO_DOWNLOAD: "Beim nächsten Laden eines Kapitels wirst du automatisch gefragt, ob du speichern möchtest",
      LAZY_LOAD: "Seiten, die noch nicht geladen sind, fehlen im Zip-Download und werden in failed_pages.txt aufgeführt.<br/> Empfehlung: <span style='color:red;font-weight:bold'>Miniaturansichten deaktivieren</span> um Bandbreite/Speicher zu sparen.",
      LAZY_LOAD_IMAGES_ENABLE: "Lazy Load Bilder aktivieren",
      LAZY_LOAD_IMAGES: "Lazy Start ab Seite (zwischen 5 und 100)",
      RETURN_CHAPTER_LIST: "Zur Kapitelübersicht zurückkehren",
      PAGES_LOADED: "Seiten geladen",
      GO_TO_PAGE: "Gehe zu Seite",
      ENLARGE: "Vergrößern",
      RESTORE: "Wiederherstellen",
      REDUCE: "Wiederherstellen",
      FIT_WIDTH: "Breite anpassen",
      FIT_HEIGHT: "Höhe anpassen",
      PERCENT: "Prozent",
      TOGGLE_CONTROLS: "Seitensteuerung umschalten",
      ZOOM_IN: "Hineinzoomen",
      ZOOM_OUT: "Herauszoomen",
      ZOOM_RESET: "Zoom zurücksetzen",
      ZOOM_WIDTH: "Auf Breite zoomen",
      ZOOM_HEIGHT: "Auf Höhe zoomen",
      HIDE: "Ausblenden",
      RELOAD: "Neu laden",
      SLOWLY: "Langsam",
      NORMAL: "Normal",
      FAST: "Schnell",
      EXTREME: "Extrem",
      ALL_PAGES: "Alle Seiten",
      SPEED_WARNING: "Ladegeschwindigkeit zu hoch",
      SPEED_WARNING_MESSAGE: "Diese Geschwindigkeit wird nicht empfohlen.<br/> Sie kann einige Server überlasten oder deine IP als DDoS-Angreifer markieren.<br/> Bitte mit Vorsicht verwenden!",
      SCROLL_UP: "Nach oben scrollen",
      SCROLL_DOWN: "Nach unten scrollen",
      CLOSE: "Schließen",
      CANCEL: "Abbrechen",
      LIST_EMPTY: "Liste leer",
      SCROLL_START: "Auto-Scroll umschalten",
      INCREASE_SPEED: "Scrollgeschwindigkeit erhöhen",
      DECREASE_SPEED: "Scrollgeschwindigkeit verringern",
      AUTO_SCROLL_HEIGHT: "Auto-Scroll-Geschwindigkeit in Pixel",
      VERTICAL_SEPARATOR: "Vertikale Trenner anzeigen",
      END: "Ende",
      SCOPE: "Bereich",
      GLOBAL: "Global",
      GENERAL: "Allgemein",
      LOADING: "Lädt",
      ZOOM: "Zoom",
      OTHERS: "Sonstiges",
      NAVBAR_TYPE: "Navigationsleistentyp ändern",
      NAVBAR_BOTTOM: "Unten",
      NAVBAR_LEFT: "Links",
      NAVBAR_RIGHT: "Rechts",
      NAVBAR_DISABLED: "Deaktiviert",
      PAGINATION_TYPE: "Paginierungstyp",
      PAGINATION_DISABLED: "Deaktiviert",
      PAGINATION_SLIDER: "Schieberegler",
      PAGINATION_ARROWS: "Seitenpfeile",
      PAGINATION_BOTH: "Beides",
      FILE_MENU: "Hauptmenü",
      VIEW_MENU: "Menü „Ansicht“",
      ZOOM_MENU: "Zoom-Menü",
      DOUBLE_PAGE: "Doppelseite umschalten",
      CHOOSE_FILE: "Datei auswählen",
      NO_FILES_SELECTED: "Keine Dateien ausgewählt",
      // Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
      COLOR_SCHEME_DARK: "Dunkel",
      COLOR_SCHEME_LIGHT: "Hell",
      LAZY_LOAD_RULE: "Lazy-Load-Regel",
      LAZY_LOAD_RULE_DISTANCE: "Nahe am Bildschirm (2000 px)",
      LAZY_LOAD_RULE_LOOKAHEAD: "Nächste 5 Seiten",
      LAZY_LOAD_RULE_BOTH: "Beide",
      KEYBIND_INVALID: "Unbekannte Taste: ##key##",
      KEYBIND_DUPLICATE: "##key## wird bereits verwendet von: ##action##",
      // Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
      ENABLE_COMMENTS: "Kommentare erfassen (wenn verfügbar)",
      DISPLAY_COMMENTS: "Kommentare anzeigen",
      COMMENTS: "Kommentarbereich",
      COMMENTS_COLOR_SCHEME: "Kommentare: hell / dunkel",
      BOOKMARK_OPEN: "Lesezeichen öffnen",
      BOOKMARK_DELETE: "Lesezeichen löschen",
      BOOKMARK_PAGE: "Seite",
      BOOKMARK_TOGGLE_CURRENT: "Aktuelle Seite als Lesezeichen",
      BOOKMARKS_CLEAR: "Alle Lesezeichen entfernen",
      BOOKMARKS_CLEAR_CONFIRM: "Alle ##count## Lesezeichen entfernen? Dies kann nicht rückgängig gemacht werden.",
      SETTING_CHANGED: "##setting## geändert",
      SETTING_CHANGED_DETAIL: "von ##old## auf ##new##",
      SCOPE_CHANGED: "Gültigkeit der Einstellungen: ##scope##",
      DOWNLOAD_PARTIAL_STARTED: "Lade ##loaded## von ##total## Seiten herunter; ##pending## werden noch geladen",
      DOWNLOAD_PARTIAL_NOTE: "Noch nicht geladene Seiten fehlen und sind in failed_pages.txt im ZIP aufgeführt.",
      DOWNLOAD_NOTHING_LOADED: "Noch keine Seite ist fertig geladen. Versuche es gleich noch einmal.",
      DOWNLOAD_BUSY: "Ein Download läuft bereits",
      DOWNLOAD_CANCELLED: "Download abgebrochen",
      DOWNLOAD_SAVED: "##file## gespeichert",
      DOWNLOAD_FAILED: "Download fehlgeschlagen: ##error##",
      DOWNLOAD_SKIPPED_PAGES: "Übersprungene Seiten: ##pages##",
      LOCAL_FILES_OPEN: "Lokale Dateien öffnen",
      LOCAL_FILES_IMAGES: "Bilder",
      LOCAL_FILES_FOLDER: "Ordner",
      LOCAL_FILES_ARCHIVE: "ZIP- / CBZ-Archiv",
      LOCAL_FILES_HINT: "Wähle Bilder, einen Ordner mit Bildern oder ein ZIP/CBZ-Archiv. Nichts wird hochgeladen: dein Browser liest die Dateien.",
      LOCAL_FILES_READING: "Lese ##file##...",
      LOCAL_FILES_UNSUPPORTED: "Nicht unterstützte Datei: ##file## (unterstützt: Bilder, Ordner, ZIP/CBZ; kein RAR/CBR oder 7z)",
      LOCAL_FILES_EMPTY: "Keine Bilder in ##file## gefunden",
      LOCAL_FILES_ERROR: "##file## konnte nicht gelesen werden: ##error##",
      LOCAL_FILES_TITLE: "Lokale Bilder",
      LOCAL_FILES_ALREADY_OPEN: "Der Reader ist bereits geöffnet. Lade die Seite neu, um andere Dateien zu öffnen.",
      PAGE_RANGE_NEED_NUMBERS: "Bitte gib zwei Seitenzahlen ein.",
      PAGE_RANGE_INVALID: "Seiten müssen zwischen 1 und ##total## liegen, erste <= letzte.",
      // Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
      SCROLL_PREPARING: "Seiten werden vorbereitet...",
      SCROLL_PROGRESS: "Scrollen und auf das Laden der Seiten warten: ##num## / ##total##",
      CLICK_PROGRESS: "Seiten werden einzeln geöffnet: ##num## / ##total##"
    },
    {
      ID: "fr_FR",
      NAME: "Français (FR)",
      STARTING: "Démarrage Manga Viewer MM",
      RESUME: "Reprise de la lecture à partir de la Page ",
      WAITING: "Veuillez patienter, 3 secondes...",
      CHOOSE_BEGINNING: "Choisissez la page par laquelle commencer :",
      BUTTON_START: "Démarrer Manga Viewer MM",
      SETTINGS: "Paramètres",
      LANGUAGE: "Langue",
      COLOR_SCHEME: "Palette de couleurs",
      THEME: "Thème",
      THEME_COLOR: "Couleur",
      THEME_HUE: "Teinte de couleur",
      THEME_SHADE: "Nuance de couleur",
      DEFAULT_LOAD_MODE: "Mode de chargement par défaut",
      DEVICE_MODE: "Mode d'appareil",
      DEVICE_MODE_AUTO: "Automatique",
      DEVICE_MODE_DESKTOP: "Bureau",
      DEVICE_MODE_TABLET: "Tablette",
      DEVICE_MODE_MOBILE: "Mobile",
      LOAD_MODE_NORMAL: "Normal (attendre 3 s)",
      LOAD_MODE_ALWAYS: "Toujours (immédiatement)",
      LOAD_MODE_NEVER: "Jamais (manuellement)",
      LOAD_SPEED: "Vitesse de chargement",
      DEFAULT_ZOOM: "Zoom par défaut (entre 5 et 200)",
      DEFAULT_ZOOM_MODE: "Mode de zoom par défaut",
      MINIMUM_ZOOM: "Zoom minimum par rapport à la largeur de l'écran (entre 30 et 100)",
      ZOOM_STEP: "Pas de changement de zoom (entre 5 et 50)",
      DEFAULT_VIEW_MODE: "Mode d'affichage par défaut",
      VIEW_MODE_VERTICAL: "Vertical",
      VIEW_MODE_LEFT: "Horizontal - De gauche à droite",
      VIEW_MODE_RIGHT: "Horizontal - De droite à gauche",
      VIEW_MODE_WEBCOMIC: "WebComic",
      VIEW_MODE_BOOK: "Livre - De gauche à droite",
      VIEW_MODE_MANGA: "Manga - De droite à gauche",
      VIEW_MODE_GALLERY: "Galerie",
      FIT_WIDTH_OVERSIZED: "Ajuster à la largeur si surdimensionné",
      SHOW_THUMBNAILS: "Afficher les vignettes",
      HIDE_CONTROLS: "Toujours masquer les contrôles de page",
      HEADER_TYPE: "Changer le type d'en-tête",
      HEADER_HOVER: "Survol",
      HEADER_SCROLL: "Défilement",
      HEADER_CLICK: "Clic",
      HEADER_FIXED: "Fixe",
      HEADER_SIMPLE: "Simple",
      BUTTON_DOWNLOAD: "Télécharger",
      DOWNLOAD_ZIP: "Télécharger le fichier Zip",
      DOWNLOAD_IMAGES: "Télécharger les images en Zip automatiquement",
      DOWNLOAD_PROGRESS: "Téléchargement : ##num## sur ##total##",
      GENERATING_ZIP: "Génération du fichier Zip...",
      DOWNLOAD_INCOMPLETE: "Téléchargement incomplet",
      DOWNLOAD_INCOMPLETE_MESSAGE: "Certaines pages n'ont pas pu être téléchargées et ont été ignorées. Une liste des pages concernées a été ajoutée au fichier ZIP.",
      BUTTON_NEXT: "Suivant",
      NEXT_CHAPTER: "Chapitre suivant",
      BUTTON_PREVIOUS: "Précédent",
      PREVIOUS_CHAPTER: "Chapitre précédent",
      BOOKMARKS: "Favoris",
      BOOKMARK: "Favori",
      BOOKMARK_REMOVED: "Favori supprimé",
      BOOKMARK_SAVED: "Favori enregistré",
      BOOKMARK_MESSAGE: "La prochaine fois que vous ouvrirez ce chapitre, il reprendra à partir de: Page ##num## (Seulement UNE FOIS par favori)",
      KEYBINDINGS: "Raccourcis clavier",
      EDIT_KEYBINDS: "Modifier les raccourcis clavier",
      SAVE_KEYBINDS: "Enregistrer les raccourcis clavier",
      BUTTON_EDIT: "Modifier",
      BUTTON_SAVE: "Enregistrer",
      KEYBIND_RULES: "\n    <h3>Touches prises en charge</h3>\n    Modificateurs autorisés : shift, option, alt, ctrl, control, command. <br/>\n    Touches spéciales : backspace, tab, clear, enter, return, esc, escape, space, up, down, left, right, home, end, pageup, pagedown, del, delete, f1 - f19, num_0 - num_9, num_multiply, num_add, num_enter, num_subtract, num_decimal, num_divide. <br/>\n    Exemples : <kbd>a</kbd>, <kbd>ctrl+a</kbd> , <kbd>shift+a</kbd> , <kbd>num_2</kbd> , <kbd>2</kbd>\n  ",
      ATTENTION: "Attention",
      WARNING: "Avertissement",
      BUTTON_RESET_SETTINGS: "Réinitialiser les paramètres",
      SETTINGS_RESET: "Les paramètres ont été réinitialisés, rechargez la page pour prendre effet",
      LANGUAGE_CHANGED: "La langue a été modifiée, rechargez la page pour prendre effet",
      AUTO_DOWNLOAD: "La prochaine fois qu'un chapitre finira de se charger, il vous sera proposé de l'enregistrer automatiquement",
      LAZY_LOAD: "Les pages pas encore chargées sont exclues du téléchargement zip et listées dans failed_pages.txt.<br/> Suggestion : <span style='color:red;font-weight:bold'>Désactivez les vignettes</span> pour économiser de la bande passante/mémoire.",
      LAZY_LOAD_IMAGES_ENABLE: "Activer le chargement paresseux des images",
      LAZY_LOAD_IMAGES: "Début du chargement paresseux à partir de la page (entre 5 et 100)",
      RETURN_CHAPTER_LIST: "Retour à la liste des chapitres",
      PAGES_LOADED: "Pages chargées",
      GO_TO_PAGE: "Aller à la page",
      ENLARGE: "Agrandir",
      RESTORE: "Restaurer",
      REDUCE: "Réduire",
      FIT_WIDTH: "Ajuster à la largeur",
      FIT_HEIGHT: "Ajuster à la hauteur",
      PERCENT: "Pourcentage",
      TOGGLE_CONTROLS: "Basculer les contrôles de page",
      ZOOM_IN: "Zoom avant",
      ZOOM_OUT: "Zoom arrière",
      ZOOM_RESET: "Réinitialiser le zoom",
      ZOOM_WIDTH: "Zoomer à la largeur",
      ZOOM_HEIGHT: "Zoomer à la hauteur",
      HIDE: "Masquer",
      RELOAD: "Recharger",
      SLOWLY: "Lentement",
      NORMAL: "Normal",
      FAST: "Rapide",
      EXTREME: "Extrême",
      ALL_PAGES: "Toutes les pages",
      SPEED_WARNING: "Vitesse de chargement trop élevée",
      SPEED_WARNING_MESSAGE: "Cette vitesse n'est pas recommandée.<br/> Elle peut nuire à certains serveurs ou marquer votre IP comme un attaquant DDoS.<br/> Veuillez l'utiliser avec prudence !",
      SCROLL_UP: "Faire défiler vers le haut",
      SCROLL_DOWN: "Faire défiler vers le bas",
      CLOSE: "Fermer",
      CANCEL: "Annuler",
      LIST_EMPTY: "Liste vide",
      SCROLL_START: "Basculer le défilement automatique",
      INCREASE_SPEED: "Augmenter la vitesse de défilement",
      DECREASE_SPEED: "Diminuer la vitesse de défilement",
      AUTO_SCROLL_HEIGHT: "Vitesse de défilement automatique en pixels",
      VERTICAL_SEPARATOR: "Afficher les séparateurs verticaux",
      END: "Fin",
      SCOPE: "Portée",
      GLOBAL: "Global",
      GENERAL: "Général",
      LOADING: "Chargement",
      ZOOM: "Zoom",
      OTHERS: "Autres",
      NAVBAR_TYPE: "Changer le type de barre de navigation",
      NAVBAR_BOTTOM: "Bas",
      NAVBAR_LEFT: "Gauche",
      NAVBAR_RIGHT: "Droite",
      NAVBAR_DISABLED: "Désactivé",
      PAGINATION_TYPE: "Type de pagination",
      PAGINATION_DISABLED: "Désactivé",
      PAGINATION_SLIDER: "Curseur",
      PAGINATION_ARROWS: "Flèches latérales",
      PAGINATION_BOTH: "Les deux",
      FILE_MENU: "Menu principal",
      VIEW_MENU: "Menu Affichage",
      ZOOM_MENU: "Menu Zoom",
      DOUBLE_PAGE: "Basculer Double Page",
      CHOOSE_FILE: "Choisir un fichier",
      NO_FILES_SELECTED: "Aucun fichier sélectionné",
      // Added by the rewrite (not in the 2026 version): color scheme names, lazy load rule (decision D3), keybinding editor validation.
      COLOR_SCHEME_DARK: "Sombre",
      COLOR_SCHEME_LIGHT: "Clair",
      LAZY_LOAD_RULE: "Règle de chargement paresseux",
      LAZY_LOAD_RULE_DISTANCE: "Près de l'écran (2000 px)",
      LAZY_LOAD_RULE_LOOKAHEAD: "5 pages suivantes",
      LAZY_LOAD_RULE_BOTH: "Les deux",
      KEYBIND_INVALID: "Touche inconnue : ##key##",
      KEYBIND_DUPLICATE: "##key## est déjà utilisée par : ##action##",
      // Added by the rewrite in M3/M4 (comments: texts of the 2025 version where they existed; the rest translated by the rewrite).
      ENABLE_COMMENTS: "Capturer les commentaires (si disponibles)",
      DISPLAY_COMMENTS: "Afficher les commentaires",
      COMMENTS: "Section des commentaires",
      COMMENTS_COLOR_SCHEME: "Commentaires : clair / sombre",
      BOOKMARK_OPEN: "Ouvrir le signet",
      BOOKMARK_DELETE: "Supprimer le signet",
      BOOKMARK_PAGE: "Page",
      BOOKMARK_TOGGLE_CURRENT: "Ajouter un signet à la page actuelle",
      BOOKMARKS_CLEAR: "Supprimer tous les signets",
      BOOKMARKS_CLEAR_CONFIRM: "Supprimer les ##count## signets ? Cette action est irréversible.",
      SETTING_CHANGED: "##setting## modifié",
      SETTING_CHANGED_DETAIL: "de ##old## à ##new##",
      SCOPE_CHANGED: "Portée des paramètres : ##scope##",
      DOWNLOAD_PARTIAL_STARTED: "Téléchargement de ##loaded## pages sur ##total## ; ##pending## sont encore en cours de chargement",
      DOWNLOAD_PARTIAL_NOTE: "Les pages pas encore chargées sont exclues et listées dans failed_pages.txt dans le ZIP.",
      DOWNLOAD_NOTHING_LOADED: "Aucune page n'a encore fini de charger. Réessayez dans un instant.",
      DOWNLOAD_BUSY: "Un téléchargement est déjà en cours",
      DOWNLOAD_CANCELLED: "Téléchargement annulé",
      DOWNLOAD_SAVED: "##file## enregistré",
      DOWNLOAD_FAILED: "Échec du téléchargement : ##error##",
      DOWNLOAD_SKIPPED_PAGES: "Pages ignorées : ##pages##",
      LOCAL_FILES_OPEN: "Ouvrir des fichiers locaux",
      LOCAL_FILES_IMAGES: "Images",
      LOCAL_FILES_FOLDER: "Dossier",
      LOCAL_FILES_ARCHIVE: "Archive ZIP / CBZ",
      LOCAL_FILES_HINT: "Choisissez des images, un dossier d'images ou une archive ZIP/CBZ. Rien n'est envoyé : votre navigateur lit les fichiers.",
      LOCAL_FILES_READING: "Lecture de ##file##...",
      LOCAL_FILES_UNSUPPORTED: "Fichier non pris en charge : ##file## (pris en charge : images, dossiers, ZIP/CBZ ; pas RAR/CBR ni 7z)",
      LOCAL_FILES_EMPTY: "Aucune image trouvée dans ##file##",
      LOCAL_FILES_ERROR: "Impossible de lire ##file## : ##error##",
      LOCAL_FILES_TITLE: "Images locales",
      LOCAL_FILES_ALREADY_OPEN: "Le lecteur est déjà ouvert. Rechargez la page pour ouvrir d'autres fichiers.",
      PAGE_RANGE_NEED_NUMBERS: "Veuillez saisir deux numéros de page.",
      PAGE_RANGE_INVALID: "Les pages doivent être comprises entre 1 et ##total##, première <= dernière.",
      // Added by the M4 follow-up: progress overlay of bruteforceScroll (section 22).
      SCROLL_PREPARING: "Préparation des pages...",
      SCROLL_PROGRESS: "Défilement et attente du chargement des pages : ##num## / ##total##",
      CLICK_PROGRESS: "Ouverture des pages une par une : ##num## / ##total##"
    }
  ];

  // ==========================================================================================
  // ==== 08. SETTINGS STORE ===================================================================
  // WHERE settings live (identical to the 2025 and 2026 versions, so users keep their settings):
  //   - userscript manager key "settings"         -> GLOBAL settings (all sites)
  //   - userscript manager key "<site hostname>"  -> LOCAL settings (one site), active only when
  //                                                  the local object has `enabled: true`
  // Only values that DIFFER from the defaults are stored ("diff-only" persistence).
  // Keys in NEVER_LOCAL_KEYS are always global (language, bookmarks, keyboard shortcuts).
  //
  // HOW to use it from other sections:
  //   getSetting(key)               - current effective value
  //   setSessionSetting(key, value) - change for this page only (not stored)
  //   saveSetting(key, value)       - change AND store (always writes; fixes the 2026 bug where a
  //                                   previous session change made "save" skip storing)
  //   onSettingChange(key, fn)      - fn(newValue, oldValue, key) after a change; key '*' = all
  // Full description: docs/04-settings-and-storage.md.
  // ==========================================================================================

  /** Defaults for every setting. Values from the 2026 version, plus product decisions D1-D7 (docs/04). */
  const DEFAULT_SETTINGS = {
    bookmarks: [],
    colorScheme: 'dark',
    deviceMode: 'auto',
    downloadZip: false,
    enableComments: true,
    enabled: false,
    fitWidthIfOversize: true,
    header: 'scroll',
    hidePageControls: false,
    lazyLoadImages: false,
    lazyLoadRule: 'distance',
    lazyStart: 50,
    loadMode: 'wait',
    loadSpeed: 'Safe',
    locale: 'en_US',
    maxReload: 5,
    minZoom: 30,
    navbar: 'bottom',
    pagination: 'disabled',
    scrollHeight: 25,
    theme: '#29487D',
    viewMode: 'WebComic',
    zoomMode: 'percent',
    zoomStep: 30,
    zoomValue: 100,
    keybinds: {
      SCROLL_UP: ['up', 'W', 'num_8'],
      SCROLL_DOWN: ['down', 'S', 'num_2'],
      NEXT_CHAPTER: ['right', '/', 'D', 'num_6'],
      PREVIOUS_CHAPTER: ['left', ';', 'A', 'num_4'],
      RETURN_CHAPTER_LIST: ['backspace', 'del', 'num_decimal'],
      ENLARGE: ['=', 'num_add', 'E'],
      REDUCE: ['-', 'num_subtract', 'Q'],
      RESTORE: ['9', 'R'],
      FIT_WIDTH: ['0', 'num_multiply', 'F'],
      FIT_HEIGHT: ['H', 'num_0'],
      SETTINGS: ['num_divide', 'num_5', 'X'],
      VIEW_MODE_WEBCOMIC: ['C'],
      VIEW_MODE_VERTICAL: ['V'],
      VIEW_MODE_LEFT: ['N'],
      VIEW_MODE_RIGHT: ['B'],
      VIEW_MODE_GALLERY: ['G'],
      SCROLL_START: ['space'],
      INCREASE_SPEED: ['.'],
      DECREASE_SPEED: [','],
      TOGGLE_CONTROLS: ['L']
    }
  };

  /** Overrides applied on phones and tablets. */
  const MOBILE_SETTING_OVERRIDES = {
    lazyLoadImages: true,
    fitWidthIfOversize: true,
    navbar: 'disabled',
    viewMode: 'WebComic',
    header: 'scroll',
    hidePageControls: true,
    pagination: 'disabled'
  };

  /** Overrides applied in the standalone local-file reader. */
  const STANDALONE_SETTING_OVERRIDES = {
    loadSpeed: 'All',
    lazyLoadImages: false,
    downloadZip: false
  };

  /** Settings that are always global, even when local (per-site) settings are on. */
  const NEVER_LOCAL_KEYS = ['locale', 'bookmarks', 'keybinds'];

  /** Storage key of the global settings. */
  const GLOBAL_SETTINGS_KEY = 'settings';

  /** Storage key of this site's local settings. */
  const LOCAL_SETTINGS_KEY = window.location.hostname;

  /**
   * Whether the mobile defaults apply, decided ONCE at startup. Device detection also looks at
   * the window width, so deciding at every save would make the stored diff depend on the window
   * size at that moment (docs/04 section 8.2 item 10).
   */
  const DEFAULTS_FOR_MOBILE = isMobileDevice();

  /**
   * Builds a FRESH defaults object (never shared, so nobody can change the defaults by accident).
   * @param {boolean} forGlobal - True for global defaults, false for local (per-site) defaults.
   * @returns {Object}
   */
  function buildDefaultSettings(forGlobal) {
    let defaults = deepClone(DEFAULT_SETTINGS);
    defaults.theme = forGlobal ? '#29487D' : '#004526';
    if (DEFAULTS_FOR_MOBILE) {
      defaults = Object.assign(defaults, deepClone(MOBILE_SETTING_OVERRIDES));
    }
    if (isStandalone()) {
      defaults = Object.assign(defaults, deepClone(STANDALONE_SETTING_OVERRIDES));
    }
    return defaults;
  }

  /**
   * Keys of the 2025 version whose meaning maps 1:1 onto a key of this version (only the name
   * changed). Migrated ONCE when a stored settings object is read (migrateOldSettingKeys).
   * Not listed on purpose (no 1:1 meaning, see docs/analysis/m4-followup-notes.md):
   * showThumbnails (-> navbar), throttlePageLoad (-> loadSpeed), customTheme / themeShade
   * (-> theme), verticalSeparator (no equivalent).
   */
  const OLD_SETTING_RENAMES = {
    defaultZoom: 'zoomValue'
  };

  /**
   * True when a value of an old key can be copied as the value of its new key: same type as
   * the new key's default, and for numbers a finite number above 0.
   * @param {string} newKey - Key of this version.
   * @param {*} value - Stored value of the old key.
   * @returns {boolean}
   */
  function isUsableOldSettingValue(newKey, value) {
    const defaultValue = DEFAULT_SETTINGS[newKey];
    if (typeof value !== typeof defaultValue) {
      return false;
    }
    if (typeof value === 'number') {
      return isFinite(value) && value > 0;
    }
    return true;
  }

  /**
   * One-time migration of renamed keys of the 2025 version (OLD_SETTING_RENAMES). For each old
   * key in `stored`: if the new key is absent and the value is usable, the value is copied to
   * the new key; the old key is removed in every case (this version never reads it, and the
   * 2026 version re-saved it forever, docs/04 D8). Pure: does not read or write storage.
   * @param {Object} stored - Stored settings object (not changed).
   * @param {string} storageKey - Storage key, only for the log.
   * @returns {{settings: Object, changes: string[]}} Migrated copy and one text per change.
   */
  function migrateOldSettingKeys(stored, storageKey) {
    const settings = deepClone(stored);
    const changes = [];
    Object.keys(OLD_SETTING_RENAMES).forEach(function (oldKey) {
      if (!Object.prototype.hasOwnProperty.call(settings, oldKey)) {
        return;
      }
      const newKey = OLD_SETTING_RENAMES[oldKey];
      const oldValue = settings[oldKey];
      if (Object.prototype.hasOwnProperty.call(settings, newKey)) {
        changes.push(oldKey + ' removed (' + newKey + ' already stored: ' + JSON.stringify(settings[newKey]) + ', old value ' + JSON.stringify(oldValue) + ' ignored)');
      } else if (isUsableOldSettingValue(newKey, oldValue)) {
        settings[newKey] = oldValue;
        changes.push(oldKey + ' ' + JSON.stringify(oldValue) + ' -> ' + newKey);
      } else {
        changes.push(oldKey + ' removed (value ' + JSON.stringify(oldValue) + ' is not a valid ' + newKey + ')');
      }
      delete settings[oldKey];
    });
    if (changes.length > 0) {
      logDebug('settings migration (' + storageKey + '): ' + changes.join('; '));
    }
    return { settings: settings, changes: changes };
  }

  /**
   * Applies migrateOldSettingKeys to a stored object and, when something changed, writes the
   * migrated object back (so the migration runs once) and logs it once. Never throws: on any
   * error the stored object is used unchanged.
   * @param {Object} stored - Stored settings object.
   * @param {string} storageKey - GLOBAL_SETTINGS_KEY or LOCAL_SETTINGS_KEY.
   * @returns {Object} The object to use (migrated, or `stored` on error / no change).
   */
  function migrateStoredSettings(stored, storageKey) {
    return safeRun('settings.migrate', function () {
      const result = migrateOldSettingKeys(stored, storageKey);
      if (result.changes.length === 0) {
        return stored;
      }
      const saved = writeStoredValue(storageKey, result.settings);
      log('Settings of the 2025 version migrated in "' + storageKey + '"' + (saved ? '' : ' (not saved: no storage available)') + ': ' + result.changes.join('; '));
      return result.settings;
    }, stored, { storageKey: storageKey, oldKeys: Object.keys(OLD_SETTING_RENAMES).join(', ') });
  }

  /**
   * Reads one stored settings object, migrates renamed keys of the 2025 version once, and
   * completes it with defaults.
   * @param {string} storageKey - GLOBAL_SETTINGS_KEY or LOCAL_SETTINGS_KEY.
   * @param {boolean} forGlobal - Which defaults to use.
   * @returns {Object}
   */
  function loadSettingsObject(storageKey, forGlobal) {
    const stored = readStoredValue(storageKey, {});
    if (stored !== null && stored !== undefined && !isPlainObject(stored)) {
      logWarn('settings: stored value is not an object, ignoring it', storageKey, stored);
      return buildDefaultSettings(forGlobal);
    }
    const migrated = isPlainObject(stored) ? migrateStoredSettings(stored, storageKey) : stored;
    return fillDefaults(migrated, buildDefaultSettings(forGlobal));
  }

  /** The internal state of the settings store. Only this section touches it directly. */
  const SETTINGS_STATE = {
    global: loadSettingsObject(GLOBAL_SETTINGS_KEY, true),
    local: loadSettingsObject(LOCAL_SETTINGS_KEY, false),
    effective: null,
    listeners: {}
  };

  /** True when this site uses its own (local) settings. */
  function isLocalScopeEnabled() {
    return SETTINGS_STATE.local.enabled === true;
  }

  /**
   * True when `key` is read from / written to the local settings right now.
   * @param {string} key - Setting name.
   * @returns {boolean}
   */
  function usesLocalValue(key) {
    return isLocalScopeEnabled() && NEVER_LOCAL_KEYS.indexOf(key) === -1;
  }

  /**
   * Recomputes the effective settings (global, or local + always-global keys).
   * @returns {Object}
   */
  function computeEffectiveSettings() {
    if (!isLocalScopeEnabled()) {
      return deepClone(SETTINGS_STATE.global);
    }
    const effective = deepClone(SETTINGS_STATE.local);
    NEVER_LOCAL_KEYS.forEach(function (key) {
      effective[key] = deepClone(SETTINGS_STATE.global[key]);
    });
    return effective;
  }

  SETTINGS_STATE.effective = computeEffectiveSettings();

  /**
   * Current value of a setting. Returns a copy for objects/arrays, so callers cannot change the
   * store by accident.
   * @param {string} key - Setting name.
   * @returns {*}
   */
  function getSetting(key) {
    if (!Object.prototype.hasOwnProperty.call(SETTINGS_STATE.effective, key)) {
      logWarn('settings: unknown setting requested', key);
      return undefined;
    }
    return deepClone(SETTINGS_STATE.effective[key]);
  }

  /**
   * Registers a listener called after a setting changes.
   * @param {string} key - Setting name, or '*' for every change.
   * @param {function(*, *, string): void} listener - Receives (newValue, oldValue, key).
   */
  function onSettingChange(key, listener) {
    if (!SETTINGS_STATE.listeners[key]) {
      SETTINGS_STATE.listeners[key] = [];
    }
    SETTINGS_STATE.listeners[key].push(listener);
  }

  /**
   * Calls the listeners of one key and the '*' listeners. A failing listener is logged and does
   * not stop the others.
   * @param {string} key - Setting name.
   * @param {*} newValue - New value.
   * @param {*} oldValue - Previous value.
   */
  function notifySettingListeners(key, newValue, oldValue) {
    const specific = SETTINGS_STATE.listeners[key] || [];
    const general = SETTINGS_STATE.listeners['*'] || [];
    specific.concat(general).forEach(function (listener) {
      safeRun('settings.listener', function () {
        listener(deepClone(newValue), deepClone(oldValue), key);
      }, undefined, { key: key });
    });
  }

  /**
   * Changes a setting for THIS PAGE ONLY (not stored). Example: header zoom buttons.
   * @param {string} key - Setting name.
   * @param {*} value - New value.
   * @returns {boolean} True when the value actually changed.
   */
  function setSessionSetting(key, value) {
    const oldValue = SETTINGS_STATE.effective[key];
    if (deepEqual(oldValue, value)) {
      return false;
    }
    SETTINGS_STATE.effective[key] = deepClone(value);
    logDebug('settings: session change', key, value);
    notifySettingListeners(key, value, oldValue);
    return true;
  }

  /**
   * Stores the global or local settings object (only the values that differ from defaults).
   * @param {boolean} toGlobal - True for global, false for local.
   */
  function persistSettingsObject(toGlobal) {
    if (toGlobal) {
      writeStoredValue(GLOBAL_SETTINGS_KEY, diffObjects(SETTINGS_STATE.global, buildDefaultSettings(true)));
    } else {
      const localDiff = diffObjects(SETTINGS_STATE.local, buildDefaultSettings(false));
      localDiff.enabled = SETTINGS_STATE.local.enabled === true;
      writeStoredValue(LOCAL_SETTINGS_KEY, localDiff);
    }
  }

  /**
   * Changes a setting AND stores it (in local settings when this site uses them and the key may
   * be local, otherwise in global settings). Always writes, even if the screen already shows it.
   * @param {string} key - Setting name.
   * @param {*} value - New value.
   */
  function saveSetting(key, value) {
    const toLocal = usesLocalValue(key);
    const previousStored = deepClone(toLocal ? SETTINGS_STATE.local[key] : SETTINGS_STATE.global[key]);
    if (toLocal) {
      SETTINGS_STATE.local[key] = deepClone(value);
    } else {
      SETTINGS_STATE.global[key] = deepClone(value);
    }
    persistSettingsObject(!toLocal);
    log('Setting saved (' + (toLocal ? 'this site' : 'global') + '):', key, value);
    setSessionSetting(key, value);
    notifySettingSavedListeners(key, value, previousStored);
  }

  /** Listeners called after a setting was SAVED by the user (not on session-only changes). */
  const SETTING_SAVED_LISTENERS = [];

  /**
   * Registers a listener called after saveSetting() stored a value. Used for the "setting
   * changed" toasts, which the 2026 version showed for every change, saved or not (docs/04
   * section 8.2 item 12 asks for saved changes only).
   * @param {function(string, *, *): void} listener - Receives (key, newValue, previousStoredValue).
   */
  function onSettingSaved(listener) {
    SETTING_SAVED_LISTENERS.push(listener);
  }

  /**
   * Calls the "saved" listeners. A failing listener is logged and does not stop the others.
   * @param {string} key - Setting name.
   * @param {*} newValue - Saved value.
   * @param {*} previousStored - Value stored before.
   */
  function notifySettingSavedListeners(key, newValue, previousStored) {
    SETTING_SAVED_LISTENERS.forEach(function (listener) {
      safeRun('settings.savedListener', function () {
        listener(key, deepClone(newValue), previousStored);
      }, undefined, { key: key });
    });
  }

  /**
   * The STORED value of a setting (what a reload would show), ignoring changes made for this page
   * only with setSessionSetting. Reads the local settings when this site uses them for that key.
   * @param {string} key - Setting name.
   * @returns {*} A copy of the stored value.
   */
  function getStoredSetting(key) {
    const source = usesLocalValue(key) ? SETTINGS_STATE.local : SETTINGS_STATE.global;
    return deepClone(source[key]);
  }

  /**
   * Undoes a session-only change: the setting gets its stored value back (listeners run when the
   * value changes). Example: leaving a horizontal view mode restores the stored zoom and header.
   * @param {string} key - Setting name.
   * @returns {boolean} True when the value changed.
   */
  function restoreStoredSetting(key) {
    return setSessionSetting(key, getStoredSetting(key));
  }

  /**
   * Switches this site between its own settings and the global settings, then refreshes every
   * setting so the screen follows (fixes the 2026 bug where nothing refreshed).
   * @param {boolean} useLocal - True to use per-site settings.
   */
  function setLocalScope(useLocal) {
    SETTINGS_STATE.local.enabled = useLocal === true;
    persistSettingsObject(false);
    reloadEffectiveSettings('scope changed to ' + (useLocal ? 'local' : 'global'));
  }

  /**
   * Resets the settings of the current scope to defaults (bookmarks and keybinds are kept when
   * resetting local settings, because they are global).
   */
  function resetSettings() {
    if (isLocalScopeEnabled()) {
      SETTINGS_STATE.local = buildDefaultSettings(false);
      SETTINGS_STATE.local.enabled = true;
      persistSettingsObject(false);
    } else {
      SETTINGS_STATE.global = buildDefaultSettings(true);
      deleteStoredValue(GLOBAL_SETTINGS_KEY);
    }
    reloadEffectiveSettings('reset');
  }

  /**
   * Recomputes the effective settings and notifies every key that changed.
   * @param {string} reason - For the log.
   */
  function reloadEffectiveSettings(reason) {
    const previous = SETTINGS_STATE.effective;
    SETTINGS_STATE.effective = computeEffectiveSettings();
    log('Settings reloaded:', reason);
    Object.keys(SETTINGS_STATE.effective).forEach(function (key) {
      if (!deepEqual(previous[key], SETTINGS_STATE.effective[key])) {
        notifySettingListeners(key, SETTINGS_STATE.effective[key], previous[key]);
      }
    });
  }

  // Cross-tab sync: another tab saved settings -> load them here too.
  listenToStoredValue(GLOBAL_SETTINGS_KEY, debounce(function (newValue) {
    SETTINGS_STATE.global = fillDefaults(newValue, buildDefaultSettings(true));
    reloadEffectiveSettings('global settings changed in another tab');
  }, CONFIG.settingsSyncDebounceMs));
  listenToStoredValue(LOCAL_SETTINGS_KEY, debounce(function (newValue) {
    SETTINGS_STATE.local = fillDefaults(newValue, buildDefaultSettings(false));
    reloadEffectiveSettings('site settings changed in another tab');
  }, CONFIG.settingsSyncDebounceMs));

  // ---- Bookmarks (the script's own "resume reading" marks, always global) ----

  /**
   * Page number bookmarked for a chapter URL, or 0 when there is none.
   * @param {string} [url=window.location.href] - Chapter URL.
   * @returns {number}
   */
  function getBookmarkedPage(url) {
    const chapterUrl = url || window.location.href;
    const bookmarks = getSetting('bookmarks') || [];
    for (let i = 0; i < bookmarks.length; i++) {
      if (bookmarks[i] && bookmarks[i].url === chapterUrl) {
        return parseInt(bookmarks[i].page, 10) || 0;
      }
    }
    return 0;
  }

  /**
   * Saves (or replaces) the bookmark of a chapter. Stored immediately (fixes both old versions,
   * which only kept bookmarks in memory). Format of both old versions (docs/04 section 4.1):
   * { name, url, page, date: 'YYYY-MM-DD' }.
   * @param {number} page - Page number to resume from.
   * @param {string} [url=window.location.href] - Chapter URL.
   * @param {string} [name] - Chapter title shown in the bookmarks list (default: the hostname).
   */
  function saveBookmark(page, url, name) {
    const chapterUrl = url || window.location.href;
    const others = (getSetting('bookmarks') || []).filter(function (bookmark) {
      return bookmark && bookmark.url !== chapterUrl;
    });
    const title = typeof name === 'string' && name.trim() !== '' ? name.trim() : window.location.hostname;
    others.push({ name: title, url: chapterUrl, page: page, date: new Date().toISOString().slice(0, 10) });
    saveSetting('bookmarks', others);
  }

  /**
   * Removes the bookmark of a chapter, if any.
   * @param {string} [url=window.location.href] - Chapter URL.
   * @returns {boolean} True when a bookmark was removed.
   */
  function removeBookmark(url) {
    const chapterUrl = url || window.location.href;
    const bookmarks = getSetting('bookmarks') || [];
    const remaining = bookmarks.filter(function (bookmark) {
      return bookmark && bookmark.url !== chapterUrl;
    });
    if (remaining.length === bookmarks.length) {
      return false;
    }
    saveSetting('bookmarks', remaining);
    return true;
  }

  // ---- Locale lookup (needs getSetting, so it lives after the settings store) ----

  /**
   * Returns the UI text for `key` in the user's language, falling back to English, then to a
   * visible placeholder (so a missing translation is easy to spot on screen).
   * @param {string} key - Text key, e.g. 'BUTTON_START'.
   * @returns {string}
   */
  function getLocaleString(key) {
    const wantedId = getSetting('locale');
    let language = null;
    for (let i = 0; i < LOCALES.length; i++) {
      if (LOCALES[i].ID === wantedId) {
        language = LOCALES[i];
      }
    }
    if (language && typeof language[key] === 'string') {
      return language[key];
    }
    const english = LOCALES[0];
    if (typeof english[key] === 'string') {
      if (language) {
        logDebug('locale: missing "' + key + '" in ' + wantedId + ', using English');
      }
      return english[key];
    }
    logWarn('locale: unknown text key', key);
    return '##MISSING_STRING_' + key + '##';
  }

  // ==========================================================================================
  // ==== 09. APP STATE ========================================================================
  // Runtime values that are NOT settings and are never stored: which site won, the manga object,
  // the status of every page image, the current page. One object, with small accessors.
  // ==========================================================================================

  /** Possible states of one page image. */
  const IMAGE_STATUS = {
    WAITING: 'waiting',
    LOADING: 'loading',
    LOADED: 'loaded',
    FAILED: 'failed'
  };

  /** The runtime state. Only change it through the functions below. */
  const APP_STATE = {
    site: null,
    manga: null,
    device: detectDevice(),
    currentPage: 0,
    images: {},
    pageLayouts: {},
    panel: 'none',
    keybindsEditing: false,
    // M3: the bookmark page used to resume this chapter (0 = none), removed once the last page
    // is reached (section 20); comments element captured from the site (section 20); true while
    // openViewer() prepares the takeover (protects against opening twice).
    resumedBookmarkPage: 0,
    comments: null,
    opening: false,
    listeners: []
  };

  /**
   * Registers a listener for app state changes.
   * @param {function(string, *): void} listener - Receives (what, value), e.g. ('image', {page, status}).
   */
  function onAppStateChange(listener) {
    APP_STATE.listeners.push(listener);
  }

  /**
   * Notifies app state listeners. A failing listener is logged and does not stop the others.
   * @param {string} what - Name of the change.
   * @param {*} value - Data of the change.
   */
  function notifyAppState(what, value) {
    APP_STATE.listeners.forEach(function (listener) {
      safeRun('appState.listener', function () {
        listener(what, value);
      }, undefined, { what: what });
    });
  }

  /**
   * Returns the record of one page image, creating it when missing.
   * @param {number} page - Page number (1-based).
   * @returns {{page: number, status: string, src: string, attempts: number}}
   */
  function getImageRecord(page) {
    if (!APP_STATE.images[page]) {
      APP_STATE.images[page] = { page: page, status: IMAGE_STATUS.WAITING, src: '', attempts: 0 };
    }
    return APP_STATE.images[page];
  }

  /**
   * Updates the record of one page image and notifies listeners.
   * @param {number} page - Page number.
   * @param {Object} changes - Fields to change (status, src, attempts).
   */
  function updateImageRecord(page, changes) {
    const record = Object.assign(getImageRecord(page), changes);
    notifyAppState('image', record);
  }

  /**
   * Returns the layout record of one page (size, zoom, hidden, double page), creating it when
   * missing. Kept apart from the image record so layout changes never trigger 'image' listeners.
   * width/height null = automatic size.
   * @param {number} page - Page number (1-based).
   * @returns {{naturalWidth: number, naturalHeight: number, width: (number|null), height: (number|null), hidden: boolean, doublePage: boolean, processedSource: string}}
   */
  function getPageLayout(page) {
    if (!APP_STATE.pageLayouts[page]) {
      APP_STATE.pageLayouts[page] = { naturalWidth: 0, naturalHeight: 0, width: null, height: null, hidden: false, doublePage: false, processedSource: '' };
    }
    return APP_STATE.pageLayouts[page];
  }

  /**
   * Sets the page that is in view and notifies listeners ('currentPage', n) when it changed.
   * @param {number} page - Page number.
   * @returns {boolean} True when it changed.
   */
  function setCurrentPage(page) {
    if (typeof page !== 'number' || isNaN(page) || page === APP_STATE.currentPage) {
      return false;
    }
    APP_STATE.currentPage = page;
    logDebug('current page', page);
    notifyAppState('currentPage', page);
    return true;
  }

  /**
   * Counts page images by status.
   * @returns {{total: number, loaded: number, failed: number, loading: number}}
   */
  function countImages() {
    const counts = { total: 0, loaded: 0, failed: 0, loading: 0 };
    Object.keys(APP_STATE.images).forEach(function (page) {
      const status = APP_STATE.images[page].status;
      counts.total += 1;
      if (status === IMAGE_STATUS.LOADED) {
        counts.loaded += 1;
      } else if (status === IMAGE_STATUS.FAILED) {
        counts.failed += 1;
      } else if (status === IMAGE_STATUS.LOADING) {
        counts.loading += 1;
      }
    });
    return counts;
  }

  // ==========================================================================================
  // ==== 10. COLORS AND THEMES ================================================================
  // Pure color math (no DOM access) and the CSS variables of the active theme.
  //   - A color is a hex string '#RRGGBB', or an {r, g, b} object with channels 0-255.
  //   - The 2026 version wrote its palettes in oklch(). Older browsers cannot read oklch() in CSS,
  //     so the palettes below were converted ONCE to hex with okLchToHex() (same math as here,
  //     including the CSS Color 4 gamut mapping). okLchToHex() is also used at run time to build
  //     the 11 shades of the user's theme color, exactly like the 2026 version did.
  //   - resolveThemeColor() accepts every value the `theme` setting may hold: '#RGB', '#RRGGBB',
  //     'rgb()', 'hsl()', 'oklch()' (saved by the 2026 version) and the palette names saved by the
  //     2025 version ('darkblue', 'darkgreen', ...). Anything else falls back to navy (as 2026).
  //   - buildThemeCss() returns the CSS-variable block for the theme color + color scheme. The
  //     rest of the CSS (section 11) only uses these variables, never a fixed theme color.
  // ==========================================================================================

  /** Recommended dark theme colors (2026 version "sample" colors, NEW:4423-4435). */
  const RECOMMENDED_THEME_COLORS = {
    navy: '#001F3F',
    darkblue: '#1E4F7A',
    blue: '#1A2F4B',
    darkgreen: '#062925',
    green: '#1A3636',
    grass: '#1B3C53',
    teal: '#044A42',
    darkpurple: '#1B0044',
    purple: '#363062',
    grape: '#31326F',
    maroon: '#44000D'
  };

  /**
   * Palette names stored by the 2025 version in the `theme` setting, mapped to the color the 2025
   * version showed with its default shade 600 (OLD:2310-2583, default `themeShade` 600 OLD:2091).
   */
  const LEGACY_THEME_COLORS = {
      dark: '#25262B',
      gray: '#868E96',
      red: '#FA5252',
      wine: '#B71D15',
      pink: '#E64980',
      grape: '#BE4BDB',
      violet: '#7950F2',
      purple: '#4D21AB',
      indigo: '#4C6EF5',
      blue: '#228BE6',
      darkblue: '#314E89',
      cyan: '#15AABF',
      teal: '#12B886',
      green: '#40C057',
      darkgreen: '#263E3A',
      moss: '#62AF7A',
      greener: '#408E3E',
      lime: '#82C91E',
      yellow: '#FAB005',
      golden: '#BF990D',
      orange: '#FD7E14'
  };

  /**
   * The 2026 version's color palettes (NEW:4441-4738) converted from oklch() to hex.
   * Family -> shade (50 lightest ... 950 darkest) -> '#RRGGBB'.
   */
  const COLOR_PALETTES = {
      dark: { 50: '#C1C2C5', 100: '#A6A7AB', 200: '#909296', 300: '#5C5F66', 400: '#373A40', 500: '#2C2E33', 600: '#25262B', 700: '#1A1B1E', 800: '#141517', 900: '#101113', 950: '#000000' },
      slate: { 50: '#F8FAFC', 100: '#F1F5F9', 200: '#E2E8F0', 300: '#CAD5E2', 400: '#90A1B9', 500: '#62748E', 600: '#45556C', 700: '#314158', 800: '#1D293D', 900: '#0F172B', 950: '#020618' },
      gray: { 50: '#F9FAFB', 100: '#F3F4F6', 200: '#E5E7EB', 300: '#D1D5DC', 400: '#99A1AF', 500: '#6A7282', 600: '#4A5565', 700: '#364153', 800: '#1E2939', 900: '#101828', 950: '#030712' },
      zinc: { 50: '#FAFAFA', 100: '#F4F4F5', 200: '#E4E4E7', 300: '#D4D4D8', 400: '#9F9FA9', 500: '#71717B', 600: '#52525C', 700: '#3F3F46', 800: '#27272A', 900: '#18181B', 950: '#09090B' },
      neutral: { 50: '#FAFAFA', 100: '#F5F5F5', 200: '#E5E5E5', 300: '#D4D4D4', 400: '#A1A1A1', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0A0A0A' },
      stone: { 50: '#FAFAF9', 100: '#F5F5F4', 200: '#E7E5E4', 300: '#D6D3D1', 400: '#A6A09B', 500: '#79716B', 600: '#57534D', 700: '#44403B', 800: '#292524', 900: '#1C1917', 950: '#0C0A09' },
      red: { 50: '#FEF2F2', 100: '#FFE2E2', 200: '#FFC9C9', 300: '#FFA2A2', 400: '#FF6467', 500: '#FB2C36', 600: '#E7000B', 700: '#C10007', 800: '#9F0712', 900: '#82181A', 950: '#460809' },
      orange: { 50: '#FFF7ED', 100: '#FFEDD4', 200: '#FFD6A7', 300: '#FFB86A', 400: '#FF8904', 500: '#FF6900', 600: '#F54900', 700: '#CA3500', 800: '#9F2D00', 900: '#7E2A0C', 950: '#441306' },
      amber: { 50: '#FFFBEB', 100: '#FEF3C6', 200: '#FEE685', 300: '#FFD230', 400: '#FFBA00', 500: '#FD9A00', 600: '#E17100', 700: '#BB4D00', 800: '#973C00', 900: '#7B3306', 950: '#461901' },
      yellow: { 50: '#FEFCE8', 100: '#FEF9C2', 200: '#FFF085', 300: '#FFDF20', 400: '#FCC800', 500: '#EFB100', 600: '#D08700', 700: '#A65F00', 800: '#894B00', 900: '#733E0A', 950: '#432004' },
      lime: { 50: '#F7FEE7', 100: '#ECFCCA', 200: '#D8F999', 300: '#BBF451', 400: '#9AE600', 500: '#7CCF00', 600: '#5EA500', 700: '#497D00', 800: '#3C6300', 900: '#35530E', 950: '#192E03' },
      green: { 50: '#F0FDF4', 100: '#DCFCE7', 200: '#B9F8CF', 300: '#7BF1A8', 400: '#05DF72', 500: '#00C950', 600: '#00A63E', 700: '#008236', 800: '#016630', 900: '#0D542B', 950: '#032E15' },
      emerald: { 50: '#ECFDF5', 100: '#D0FAE5', 200: '#A4F4CF', 300: '#5EE9B5', 400: '#00D492', 500: '#00BC7D', 600: '#009966', 700: '#007A55', 800: '#006045', 900: '#004F3B', 950: '#002C22' },
      teal: { 50: '#F0FDFA', 100: '#CBFBF1', 200: '#96F7E4', 300: '#46ECD5', 400: '#00D5BE', 500: '#00BBA7', 600: '#009689', 700: '#00786F', 800: '#005F5A', 900: '#0B4F4A', 950: '#022F2E' },
      cyan: { 50: '#ECFEFF', 100: '#CEFAFE', 200: '#A2F4FD', 300: '#53EAFD', 400: '#00D3F2', 500: '#00B8DB', 600: '#0092B8', 700: '#007595', 800: '#005F78', 900: '#104E64', 950: '#053345' },
      sky: { 50: '#F0F9FF', 100: '#DFF2FE', 200: '#B8E6FE', 300: '#74D4FF', 400: '#00BCFF', 500: '#00A6F4', 600: '#0084D1', 700: '#0069A8', 800: '#00598A', 900: '#024A70', 950: '#052F4A' },
      blue: { 50: '#EFF6FF', 100: '#DBEAFE', 200: '#BEDBFF', 300: '#8EC5FF', 400: '#51A2FF', 500: '#2B7FFF', 600: '#155DFC', 700: '#1447E6', 800: '#193CB8', 900: '#1C398E', 950: '#162456' },
      indigo: { 50: '#EEF2FF', 100: '#E0E7FF', 200: '#C6D2FF', 300: '#A3B3FF', 400: '#7C86FF', 500: '#615FFF', 600: '#4F39F6', 700: '#432DD7', 800: '#372AAC', 900: '#312C85', 950: '#1E1A4D' },
      violet: { 50: '#F5F3FF', 100: '#EDE9FE', 200: '#DDD6FF', 300: '#C4B4FF', 400: '#A684FF', 500: '#8E51FF', 600: '#7F22FE', 700: '#7008E7', 800: '#5D0EC0', 900: '#4D179A', 950: '#2F0D68' },
      purple: { 50: '#FAF5FF', 100: '#F3E8FF', 200: '#E9D4FF', 300: '#DAB2FF', 400: '#C27AFF', 500: '#AD46FF', 600: '#9810FA', 700: '#8200DB', 800: '#6E11B0', 900: '#59168B', 950: '#3C0366' },
      fuchsia: { 50: '#FDF4FF', 100: '#FAE8FF', 200: '#F6CFFF', 300: '#F4A8FF', 400: '#ED6AFF', 500: '#E12AFB', 600: '#C800DE', 700: '#A800B7', 800: '#8A0194', 900: '#721378', 950: '#4B004F' },
      pink: { 50: '#FDF2F8', 100: '#FCE7F3', 200: '#FCCEE8', 300: '#FDA5D5', 400: '#FB64B6', 500: '#F6339A', 600: '#E60076', 700: '#C6005C', 800: '#A3004C', 900: '#861043', 950: '#510424' },
      rose: { 50: '#FFF1F2', 100: '#FFE4E6', 200: '#FFCCD3', 300: '#FFA1AD', 400: '#FF637E', 500: '#FF2056', 600: '#EC003F', 700: '#C70036', 800: '#A50036', 900: '#8B0836', 950: '#4D0218' }
  };

  /** Shade keys of every palette, lightest first. */
  const PALETTE_SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];

  /** Used when a stored theme color cannot be read (same fallback as the 2026 version). */
  const FALLBACK_THEME_COLOR = RECOMMENDED_THEME_COLORS.navy;

  /** Text colors chosen by getReadableTextColor(). */
  const TEXT_ON_DARK = '#FFFFFF';
  const TEXT_ON_LIGHT = '#000000';

  /**
   * Rounds and limits a color channel to 0-255.
   * @param {number} value - Channel value.
   * @returns {number}
   */
  function clampChannel(value) {
    return clamp(Math.round(value), 0, 255);
  }

  /**
   * Turns an {r, g, b} color (0-255) into '#RRGGBB' (upper case).
   * @param {{r: number, g: number, b: number}} rgb - The color.
   * @returns {string}
   */
  function rgbToHex(rgb) {
    return '#' + [rgb.r, rgb.g, rgb.b].map(function (channel) {
      const text = clampChannel(channel).toString(16).toUpperCase();
      return text.length === 1 ? '0' + text : text;
    }).join('');
  }

  /**
   * Reads '#RGB' or '#RRGGBB' (with or without '#'). Returns null for anything else.
   * @param {string} text - Hex color.
   * @returns {{r: number, g: number, b: number}|null}
   */
  function parseHexColor(text) {
    if (typeof text !== 'string') {
      return null;
    }
    let hex = text.trim().replace(/^#/, '');
    if (/^[0-9a-f]{3}$/i.test(hex)) {
      hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
    }
    if (!/^[0-9a-f]{6}$/i.test(hex)) {
      return null;
    }
    return { r: parseInt(hex.slice(0, 2), 16), g: parseInt(hex.slice(2, 4), 16), b: parseInt(hex.slice(4, 6), 16) };
  }

  /**
   * Converts {r, g, b} (0-255) to {h, s, l} (hue 0-360, saturation and lightness 0-100).
   * @param {{r: number, g: number, b: number}} rgb - The color.
   * @returns {{h: number, s: number, l: number}}
   */
  function rgbToHsl(rgb) {
    const r = rgb.r / 255;
    const g = rgb.g / 255;
    const b = rgb.b / 255;
    const max = Math.max(r, g, b);
    const min = Math.min(r, g, b);
    const lightness = (max + min) / 2;
    if (max === min) {
      return { h: 0, s: 0, l: lightness * 100 };
    }
    const delta = max - min;
    const saturation = lightness > 0.5 ? delta / (2 - max - min) : delta / (max + min);
    let hue = 0;
    if (max === r) {
      hue = (g - b) / delta + (g < b ? 6 : 0);
    } else if (max === g) {
      hue = (b - r) / delta + 2;
    } else {
      hue = (r - g) / delta + 4;
    }
    return { h: hue * 60, s: saturation * 100, l: lightness * 100 };
  }

  /**
   * Converts {h, s, l} (hue 0-360, saturation and lightness 0-100) to {r, g, b} (0-255).
   * @param {{h: number, s: number, l: number}} hsl - The color.
   * @returns {{r: number, g: number, b: number}}
   */
  function hslToRgb(hsl) {
    const hue = (((hsl.h % 360) + 360) % 360) / 360;
    const saturation = clamp(hsl.s, 0, 100) / 100;
    const lightness = clamp(hsl.l, 0, 100) / 100;
    if (saturation === 0) {
      return { r: lightness * 255, g: lightness * 255, b: lightness * 255 };
    }
    const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation;
    const p = 2 * lightness - q;

    /** One channel of the standard HSL formula. */
    function hueToChannel(t) {
      let position = t;
      if (position < 0) {
        position += 1;
      }
      if (position > 1) {
        position -= 1;
      }
      if (position < 1 / 6) {
        return p + (q - p) * 6 * position;
      }
      if (position < 1 / 2) {
        return q;
      }
      if (position < 2 / 3) {
        return p + (q - p) * (2 / 3 - position) * 6;
      }
      return p;
    }

    return { r: hueToChannel(hue + 1 / 3) * 255, g: hueToChannel(hue) * 255, b: hueToChannel(hue - 1 / 3) * 255 };
  }

  /**
   * Changes the HSL lightness of a hex color by `delta` points (-100..100).
   * @param {string} hex - '#RRGGBB'.
   * @param {number} delta - Points to add (negative darkens).
   * @returns {string} New hex color; the input unchanged when it is not a hex color.
   */
  function adjustLightness(hex, delta) {
    const rgb = parseHexColor(hex);
    if (!rgb) {
      return hex;
    }
    const hsl = rgbToHsl(rgb);
    hsl.l = clamp(hsl.l + delta, 0, 100);
    return rgbToHex(hslToRgb(hsl));
  }

  /**
   * Lighter version of a color (HSL lightness + amount, like tinycolor's lighten).
   * @param {string} hex - '#RRGGBB'.
   * @param {number} amount - Points 0-100.
   * @returns {string}
   */
  function lightenColor(hex, amount) {
    return adjustLightness(hex, Math.abs(amount));
  }

  /**
   * Darker version of a color (HSL lightness - amount, like tinycolor's darken).
   * @param {string} hex - '#RRGGBB'.
   * @param {number} amount - Points 0-100.
   * @returns {string}
   */
  function darkenColor(hex, amount) {
    return adjustLightness(hex, -Math.abs(amount));
  }

  /**
   * sRGB channel (0-1, gamma encoded) to linear light.
   * @param {number} value - Channel 0-1.
   * @returns {number}
   */
  function srgbToLinear(value) {
    return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
  }

  /**
   * Linear light to sRGB channel (0-1, gamma encoded).
   * @param {number} value - Linear channel.
   * @returns {number}
   */
  function linearToSrgb(value) {
    if (value <= 0.0031308) {
      return 12.92 * value;
    }
    return 1.055 * Math.pow(value, 1 / 2.4) - 0.055;
  }

  /**
   * {r, g, b} (0-255) to OKLab [L, a, b] (Bjorn Ottosson's published matrices).
   * @param {{r: number, g: number, b: number}} rgb - The color.
   * @returns {number[]}
   */
  function rgbToOkLab(rgb) {
    const r = srgbToLinear(rgb.r / 255);
    const g = srgbToLinear(rgb.g / 255);
    const b = srgbToLinear(rgb.b / 255);
    const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
    const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
    const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
    return [
      0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
      1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
      0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s
    ];
  }

  /**
   * {r, g, b} (0-255) to OKLCH {l (0-1), c, h (degrees)}.
   * @param {{r: number, g: number, b: number}} rgb - The color.
   * @returns {{l: number, c: number, h: number}}
   */
  function rgbToOkLch(rgb) {
    const lab = rgbToOkLab(rgb);
    const chroma = Math.sqrt(lab[1] * lab[1] + lab[2] * lab[2]);
    let hue = Math.atan2(lab[2], lab[1]) * 180 / Math.PI;
    if (hue < 0) {
      hue += 360;
    }
    return { l: lab[0], c: chroma, h: chroma < 0.0000001 ? 0 : hue };
  }

  /**
   * OKLCH to gamma-encoded sRGB channels 0-1 (may be outside 0-1 when out of gamut).
   * @param {number} lightness - L, 0-1.
   * @param {number} chroma - C, 0-0.4.
   * @param {number} hue - h in degrees.
   * @returns {number[]} [r, g, b]
   */
  function okLchToUnitRgb(lightness, chroma, hue) {
    const radians = hue * Math.PI / 180;
    const a = chroma * Math.cos(radians);
    const b = chroma * Math.sin(radians);
    const l = Math.pow(lightness + 0.3963377774 * a + 0.2158037573 * b, 3);
    const m = Math.pow(lightness - 0.1055613458 * a - 0.0638541728 * b, 3);
    const s = Math.pow(lightness - 0.0894841775 * a - 1.2914855480 * b, 3);
    return [
      4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
      -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
      -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s
    ].map(linearToSrgb);
  }

  /**
   * True when every channel is inside 0-1 (with a tiny tolerance for rounding).
   * @param {number[]} unitRgb - [r, g, b] 0-1.
   * @returns {boolean}
   */
  function isUnitRgbInGamut(unitRgb) {
    return unitRgb.every(function (channel) {
      return channel >= -0.000075 && channel <= 1.000075;
    });
  }

  /**
   * Limits every channel to 0-1.
   * @param {number[]} unitRgb - [r, g, b].
   * @returns {number[]}
   */
  function clipUnitRgb(unitRgb) {
    return unitRgb.map(function (channel) {
      return clamp(channel, 0, 1);
    });
  }

  /**
   * Distance in OKLab between an sRGB color and an OKLCH color (deltaE OK).
   * @param {number[]} unitRgb - [r, g, b] 0-1.
   * @param {number} lightness - L.
   * @param {number} chroma - C.
   * @param {number} hue - h in degrees.
   * @returns {number}
   */
  function oklabDistance(unitRgb, lightness, chroma, hue) {
    const lab = rgbToOkLab({ r: unitRgb[0] * 255, g: unitRgb[1] * 255, b: unitRgb[2] * 255 });
    const radians = hue * Math.PI / 180;
    const dl = lab[0] - lightness;
    const da = lab[1] - chroma * Math.cos(radians);
    const db = lab[2] - chroma * Math.sin(radians);
    return Math.sqrt(dl * dl + da * da + db * db);
  }

  /**
   * OKLCH to '#RRGGBB'. Colors outside sRGB are brought inside with the CSS Color 4 gamut-mapping
   * algorithm (lower the chroma until clipping changes the color less than 0.02 deltaE OK), which
   * is what browsers and Color.js do, so the result matches what the 2026 version showed.
   * @param {number} lightness - L, 0-1.
   * @param {number} chroma - C.
   * @param {number} hue - h in degrees.
   * @returns {string}
   */
  function okLchToHex(lightness, chroma, hue) {
    /**
     * Hex color of an RGB triple with channels from 0 to 1.
     * @param {number[]} unitRgb - [r, g, b] in 0..1.
     * @returns {string}
     */
    const toHex = function (unitRgb) {
      return rgbToHex({ r: unitRgb[0] * 255, g: unitRgb[1] * 255, b: unitRgb[2] * 255 });
    };
    if (lightness >= 1) {
      return '#FFFFFF';
    }
    if (lightness <= 0) {
      return '#000000';
    }
    let unitRgb = okLchToUnitRgb(lightness, chroma, hue);
    if (isUnitRgbInGamut(unitRgb)) {
      return toHex(clipUnitRgb(unitRgb));
    }
    const justNoticeable = 0.02;
    const epsilon = 0.0001;
    let low = 0;
    let high = chroma;
    let lowInGamut = true;
    let clipped = clipUnitRgb(unitRgb);
    if (oklabDistance(clipped, lightness, chroma, hue) < justNoticeable) {
      return toHex(clipped);
    }
    while (high - low > epsilon) {
      const middle = (low + high) / 2;
      unitRgb = okLchToUnitRgb(lightness, middle, hue);
      if (lowInGamut && isUnitRgbInGamut(unitRgb)) {
        low = middle;
        continue;
      }
      clipped = clipUnitRgb(unitRgb);
      const distance = oklabDistance(clipped, lightness, middle, hue);
      if (distance < justNoticeable) {
        if (justNoticeable - distance < epsilon) {
          return toHex(clipped);
        }
        lowInGamut = false;
        low = middle;
      } else {
        high = middle;
      }
    }
    return toHex(clipped);
  }

  /**
   * Reads a number that may end with '%'. With `percentScale`, '50%' becomes 0.5 * percentScale.
   * @param {string} text - e.g. '44.6%' or '0.446'.
   * @param {number} percentScale - Value of 100%.
   * @returns {number} NaN when not a number.
   */
  function parseCssNumber(text, percentScale) {
    const trimmed = String(text).trim();
    if (/%$/.test(trimmed)) {
      return parseFloat(trimmed) / 100 * percentScale;
    }
    return parseFloat(trimmed);
  }

  /**
   * Splits the inside of 'name(a b c)' or 'name(a, b, c / alpha)' into its first three parts.
   * @param {string} text - CSS color function.
   * @param {string} name - Function name, e.g. 'rgb'.
   * @returns {string[]|null}
   */
  function readCssFunctionArguments(text, name) {
    const match = new RegExp('^' + name + 'a?\\(([^)]*)\\)$', 'i').exec(text.trim());
    if (!match) {
      return null;
    }
    const parts = match[1].split('/')[0].trim().split(/[\s,]+/);
    return parts.length >= 3 ? parts.slice(0, 3) : null;
  }

  /**
   * Reads a CSS color written as hex, rgb()/rgba(), hsl()/hsla() or oklch(). Returns null for
   * anything else (CSS color names are not supported; 2025 palette names are handled by
   * resolveThemeColor).
   * @param {string} text - CSS color.
   * @returns {{r: number, g: number, b: number}|null}
   */
  function parseCssColor(text) {
    if (typeof text !== 'string') {
      return null;
    }
    const hex = parseHexColor(text);
    if (hex) {
      return hex;
    }
    const rgbParts = readCssFunctionArguments(text, 'rgb');
    if (rgbParts) {
      const rgb = { r: parseCssNumber(rgbParts[0], 255), g: parseCssNumber(rgbParts[1], 255), b: parseCssNumber(rgbParts[2], 255) };
      return isNaN(rgb.r) || isNaN(rgb.g) || isNaN(rgb.b) ? null : { r: clampChannel(rgb.r), g: clampChannel(rgb.g), b: clampChannel(rgb.b) };
    }
    const hslParts = readCssFunctionArguments(text, 'hsl');
    if (hslParts) {
      const hsl = { h: parseFloat(hslParts[0]), s: parseCssNumber(hslParts[1], 100), l: parseCssNumber(hslParts[2], 100) };
      return isNaN(hsl.h) || isNaN(hsl.s) || isNaN(hsl.l) ? null : hslToRgb(hsl);
    }
    const okLchParts = readCssFunctionArguments(text, 'OKLCH');
    if (okLchParts) {
      const lightness = parseCssNumber(okLchParts[0], 1);
      const chroma = parseCssNumber(okLchParts[1], 0.4);
      const hue = parseFloat(okLchParts[2]);
      return isNaN(lightness) || isNaN(chroma) || isNaN(hue) ? null : parseHexColor(okLchToHex(lightness, chroma, hue));
    }
    return null;
  }

  /**
   * Turns a stored `theme` value into '#RRGGBB'. Accepts CSS colors (see parseCssColor) and the
   * 2025 version's palette names. Falls back to navy and logs a warning for anything else.
   * @param {*} theme - Value of the `theme` setting.
   * @returns {string}
   */
  function resolveThemeColor(theme) {
    if (typeof theme === 'string') {
      const legacyName = theme.trim().toLowerCase();
      if (Object.prototype.hasOwnProperty.call(LEGACY_THEME_COLORS, legacyName)) {
        return LEGACY_THEME_COLORS[legacyName];
      }
      const rgb = parseCssColor(theme);
      if (rgb) {
        return rgbToHex(rgb);
      }
    }
    logWarn('theme: unreadable theme color, using navy instead', theme);
    return FALLBACK_THEME_COLOR;
  }

  /**
   * CIE lightness L* (0 black - 100 white) of a hex color.
   * @param {string} hex - '#RRGGBB'.
   * @returns {number}
   */
  function getLightnessStar(hex) {
    const rgb = parseHexColor(hex) || { r: 0, g: 0, b: 0 };
    const luminance = 0.2126 * srgbToLinear(rgb.r / 255) + 0.7152 * srgbToLinear(rgb.g / 255) + 0.0722 * srgbToLinear(rgb.b / 255);
    return luminance > 216 / 24389 ? 116 * Math.cbrt(luminance) - 16 : luminance * 24389 / 27;
  }

  /**
   * True when white text contrasts better than black text on this color. Same rule as the 2026
   * version (Color.js "Lstar" contrast = difference of L*), i.e. L* below 50.
   * @param {string} hex - '#RRGGBB'.
   * @returns {boolean}
   */
  function isDarkColor(hex) {
    const lightness = getLightnessStar(hex);
    return 100 - lightness > lightness;
  }

  /**
   * Black or white, whichever is more readable on the given background.
   * @param {string} hex - Background '#RRGGBB'.
   * @returns {string} '#FFFFFF' or '#000000'.
   */
  function getReadableTextColor(hex) {
    return isDarkColor(hex) ? TEXT_ON_DARK : TEXT_ON_LIGHT;
  }

  /**
   * 11 shades (lightest first) of a color, built like the 2026 version's default "steps" palette
   * (NEW:4915-4946): the same chroma and hue at OKLCH lightness 95%, 90%, 80% ... 10%, 5%; the
   * step closest to the base color's own lightness is replaced by the base color itself.
   * @param {string} hex - Base '#RRGGBB'.
   * @returns {string[]} 11 hex colors.
   */
  function generateColorGradient(hex) {
    const base = parseHexColor(hex) ? hex.toUpperCase() : FALLBACK_THEME_COLOR;
    const okLch = rgbToOkLch(parseHexColor(base));
    const lightnessSteps = [0.95, 0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2, 0.1, 0.05];
    let closestIndex = 0;
    lightnessSteps.forEach(function (step, index) {
      if (Math.abs(step - okLch.l) < Math.abs(lightnessSteps[closestIndex] - okLch.l)) {
        closestIndex = index;
      }
    });
    return lightnessSteps.map(function (step, index) {
      return index === closestIndex ? base : okLchToHex(step, okLch.c, okLch.h);
    });
  }

  /**
   * The CSS variables of a theme, as a plain object (name -> value). Values follow the 2026
   * version's themesCSS (NEW:11542-11602) with its light-scheme defect fixed (the light block
   * had an invalid selector there, NEW:11578-11579).
   * @param {string} theme - Value of the `theme` setting (any format resolveThemeColor reads).
   * @param {string} colorScheme - 'dark' or 'light'.
   * @returns {Object<string, string>}
   */
  function buildThemeVariables(theme, colorScheme) {
    const primary = resolveThemeColor(theme);
    const gradient = generateColorGradient(primary);
    const isLight = colorScheme === 'light';
    const secondary = isLight ? gradient[2] : gradient[8];
    const base = isLight ? COLOR_PALETTES.gray : COLOR_PALETTES.dark;
    return {
      '--mvm-primary': primary,
      '--mvm-primary-text': getReadableTextColor(primary),
      '--mvm-secondary': secondary,
      '--mvm-secondary-text': getReadableTextColor(secondary),
      '--mvm-background': isLight ? base['50'] : base['600'],
      '--mvm-surface': isLight ? base['100'] : base['700'],
      '--mvm-highlight': base['500'],
      '--mvm-border': isLight ? base['100'] : base['400'],
      '--mvm-text': isLight ? base['900'] : base['50'],
      '--mvm-fill-quiet': isLight ? gradient[0] : gradient[9],
      '--mvm-border-quiet': isLight ? gradient[1] : gradient[8],
      '--mvm-border-normal': isLight ? gradient[2] : gradient[7],
      '--mvm-border-loud': isLight ? gradient[4] : gradient[6],
      '--mvm-on-quiet': isLight ? gradient[6] : gradient[4]
    };
  }

  /**
   * CSS text of the theme: the variables on the reader root (also when it has the light/dark
   * class, so these rules win over the defaults in section 11) and the page background.
   * @param {string} theme - Value of the `theme` setting.
   * @param {string} colorScheme - 'dark' or 'light'.
   * @returns {string}
   */
  function buildThemeCss(theme, colorScheme) {
    const variables = buildThemeVariables(theme, colorScheme);
    const declarations = Object.keys(variables).map(function (name) {
      return '  ' + name + ': ' + variables[name] + ';';
    });
    return [
      '#MangaViewerMM, #MangaViewerMM.light, #MangaViewerMM.dark {',
      declarations.join('\n'),
      '  color-scheme: ' + (colorScheme === 'light' ? 'light' : 'dark') + ';',
      '}',
      'html[mvm], html[mvm] body { background-color: ' + variables['--mvm-background'] + '; }'
    ].join('\n');
  }

  // ==========================================================================================
  // ==== 11. STYLES ===========================================================================
  // CSS as arrays of lines. Rules for this file (browser support, see docs/01 section 6):
  //   no :has(), no nested CSS rules, no oklch(), no color-mix(). Every reader rule starts with
  //   #MangaViewerMM so it never leaks into other pages; popup rules use the .mvm-popup prefix.
  // ==========================================================================================

  // ---- Icons (Tabler icons, MIT license, as used by the 2025 and 2026 versions) ----
  // Only the drawing (the SVG children) is stored; buildIconSvg() adds the <svg> wrapper. The
  // first child of every Tabler icon is an invisible 24x24 box; it is added back by the wrapper so
  // the icon color rules (ICON_COLOR_CSS, copied from NEW:2481) still count children correctly.

  /** Icon name -> { strokeWidth, paths }. Extracted from NEW:2481-2780. */
  const ICONS = {
      bookmark: { strokeWidth: '2', paths: '<path d="M18 7v14l-6 -4l-6 4v-14a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4z"/>' },
      bookmarkOff: { strokeWidth: '2', paths: '<path d="M7.708 3.721a3.982 3.982 0 0 1 2.292 -.721h4a4 4 0 0 1 4 4v7m0 4v3l-6 -4l-6 4v-14c0 -.308 .035 -.609 .1 -.897"/><path d="M3 3l18 18"/>' },
      zoomIn: { strokeWidth: '2', paths: '<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M7 10l6 0"/><path d="M10 7l0 6"/><path d="M21 21l-6 -6"/>' },
      zoomCancel: { strokeWidth: '2', paths: '<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M8 8l4 4"/><path d="M12 8l-4 4"/><path d="M21 21l-6 -6"/>' },
      zoomOut: { strokeWidth: '2', paths: '<path d="M10 10m-7 0a7 7 0 1 0 14 0a7 7 0 1 0 -14 0"/><path d="M7 10l6 0"/><path d="M21 21l-6 -6"/>' },
      arrowAutofitHeight: { strokeWidth: '2', paths: '<path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h6"/><path d="M18 14v7"/><path d="M18 3v7"/><path d="M15 18l3 3l3 -3"/><path d="M15 6l3 -3l3 3"/>' },
      arrowAutofitWidth: { strokeWidth: '2', paths: '<path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v6"/><path d="M10 18h-7"/><path d="M21 18h-7"/><path d="M6 15l-3 3l3 3"/><path d="M18 15l3 3l-3 3"/>' },
      spacingHorizontal: { strokeWidth: '2', paths: '<path d="M20 20h-2a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h2"/><path d="M4 20h2a2 2 0 0 0 2 -2v-12a2 2 0 0 0 -2 -2h-2"/><path d="M12 8v8"/>' },
      eye: { strokeWidth: '2', paths: '<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/>' },
      eyeOff: { strokeWidth: '2', paths: '<path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"/><path d="M3 3l18 18"/>' },
      refresh: { strokeWidth: '2', paths: '<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"/>' },
      listNumbers: { strokeWidth: '2', paths: '<path d="M11 6h9"/><path d="M11 12h9"/><path d="M12 18h8"/><path d="M4 16a2 2 0 1 1 4 0c0 .591 -.5 1 -1 1.5l-3 2.5h4"/><path d="M6 10v-6l-2 2"/>' },
      spacingVertical: { strokeWidth: '2', paths: '<path d="M4 20v-2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v2"/><path d="M4 4v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2"/><path d="M16 12h-8"/>' },
      arrowAutofitDown: { strokeWidth: '2', paths: '<path d="M12 20h-6a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h8"/><path d="M18 4v17"/><path d="M15 18l3 3l3 -3"/>' },
      arrowAutofitRight: { strokeWidth: '2', paths: '<path d="M20 12v-6a2 2 0 0 0 -2 -2h-12a2 2 0 0 0 -2 2v8"/><path d="M4 18h17"/><path d="M18 15l3 3l-3 3"/>' },
      arrowAutofitLeft: { strokeWidth: '2', paths: '<path d="M4 12v-6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v8"/><path d="M20 18h-17"/><path d="M6 15l-3 3l3 3"/>' },
      bookArrowRight: { strokeWidth: '2', paths: '<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 1.006 -.5"/><path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"/><path d="M3 6v13"/><path d="M12 6v13"/><path d="M21 6v6"/><path d="M16 19h6"/><path d="M19 16l3 3l-3 3"/>' },
      bookArrowLeft: { strokeWidth: '2', paths: '<path d="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 1.006 -.5"/><path d="M3 6a9 9 0 0 1 9 0a9 9 0 0 1 9 0"/><path d="M3 6v13"/><path d="M12 6v13"/><path d="M21 6v6"/><path d="M16 19h6"/><path d="M19 16l-3 3l3 3"/>' },
      layoutDashboard: { strokeWidth: '1.5', paths: '<path d="M5 4h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1"/><path d="M5 16h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1"/><path d="M15 12h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-6a1 1 0 0 1 1 -1"/><path d="M15 4h4a1 1 0 0 1 1 1v2a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1v-2a1 1 0 0 1 1 -1"/>' },
      zoomInArea: { strokeWidth: '2', paths: '<path d="M15 13v4"/><path d="M13 15h4"/><path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/><path d="M22 22l-3 -3"/><path d="M6 18h-1a2 2 0 0 1 -2 -2v-1"/><path d="M3 11v-1"/><path d="M3 6v-1a2 2 0 0 1 2 -2h1"/><path d="M10 3h1"/><path d="M15 3h1a2 2 0 0 1 2 2v1"/>' },
      zoomPan: { strokeWidth: '2', paths: '<path d="M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/><path d="M17 17l-2.5 -2.5"/><path d="M10 5l2 -2l2 2"/><path d="M19 10l2 2l-2 2"/><path d="M5 10l-2 2l2 2"/><path d="M10 19l2 2l2 -2"/>' },
      zoomOutArea: { strokeWidth: '2', paths: '<path d="M13 15h4"/><path d="M15 15m-5 0a5 5 0 1 0 10 0a5 5 0 1 0 -10 0"/><path d="M22 22l-3 -3"/><path d="M6 18h-1a2 2 0 0 1 -2 -2v-1"/><path d="M3 11v-1"/><path d="M3 6v-1a2 2 0 0 1 2 -2h1"/><path d="M10 3h1"/><path d="M15 3h1a2 2 0 0 1 2 2v1"/>' },
      booksReturn: { strokeWidth: '2', paths: '<defs><mask id="arrow-mask"><rect width="24" height="24" fill="white"/><rect x="15" y="15" width="8" height="8" fill="black"/></mask></defs><path d="M5 5a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1l0 -14"/><path d="M9 5a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1l0 -14"/><path d="M5 8h4"/><path d="M9 16h4"/><g mask="url(#arrow-mask)"><path d="M13.803 4.56l2.184 -.53c.562 -.135 1.133 .19 1.282 .732l3.695 13.418a1.02 1.02 0 0 1 -.634 1.219l-.133 .041l-2.184 .53c-.562 .135 -1.133 -.19 -1.282 -.732l-3.695 -13.418a1.02 1.02 0 0 1 .634 -1.219l.133 -.041"/><path d="M14 9l4 -1"/><path d="M16 16l3.923 -.98"/></g><path d="M16 19h6"/><path d="M19 16l-3 3l3 3"/>' },
      arrowBigLeft: { strokeWidth: '2', paths: '<path d="M20 15h-8v3.586a1 1 0 0 1 -1.707 .707l-6.586 -6.586a1 1 0 0 1 0 -1.414l6.586 -6.586a1 1 0 0 1 1.707 .707v3.586h8a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1z"/>' },
      arrowBigRight: { strokeWidth: '2', paths: '<path d="M4 9h8v-3.586a1 1 0 0 1 1.707 -.707l6.586 6.586a1 1 0 0 1 0 1.414l-6.586 6.586a1 1 0 0 1 -1.707 -.707v-3.586h-8a1 1 0 0 1 -1 -1v-4a1 1 0 0 1 1 -1z"/>' },
      chevronLeft: { strokeWidth: '2', paths: '<path d="M15 6l-6 6l6 6"/>' },
      chevronRight: { strokeWidth: '2', paths: '<path d="M9 6l6 6l-6 6"/>' },
      menu2: { strokeWidth: '2', paths: '<path d="M4 6l16 0"/><path d="M4 12l16 0"/><path d="M4 18l16 0"/>' },
      category: { strokeWidth: '2', paths: '<path d="M4 4h6v6h-6z"/><path d="M14 4h6v6h-6z"/><path d="M4 14h6v6h-6z"/><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"/>' },
      photo: { strokeWidth: '2', paths: '<path d="M15 8h.01"/><path d="M3 6a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3h-12a3 3 0 0 1 -3 -3v-12z"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l3 3"/>' },
      photoOff: { strokeWidth: '2', paths: '<path d="M15 8h.01"/><path d="M7 3h11a3 3 0 0 1 3 3v11m-.856 3.099a2.991 2.991 0 0 1 -2.144 .901h-12a3 3 0 0 1 -3 -3v-12c0 -.845 .349 -1.608 .91 -2.153"/><path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l5 5"/><path d="M16.33 12.338c.574 -.054 1.155 .166 1.67 .662l3 3"/><path d="M3 3l18 18" color="orange"/>' },
      // Panels (M2 part B), from NEW:2598-2748.
      settings: { strokeWidth: '2', paths: '<path d="M10.325 4.317c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35a1.724 1.724 0 0 0 -1.066 2.573c.94 1.543 -.826 3.31 -2.37 2.37a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.94 -1.543 .826 -3.31 2.37 -2.37c1 .608 2.296 .07 2.572 -1.065z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/>' },
      settingsOff: { strokeWidth: '2', paths: '<path d="M9.451 5.437c.418 -.218 .75 -.609 .874 -1.12c.426 -1.756 2.924 -1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543 -.94 3.31 .826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756 .426 1.756 2.924 0 3.35c-.486 .118 -.894 .44 -1.123 .878m-.188 3.803c-.517 .523 -1.349 .734 -2.125 .262a1.724 1.724 0 0 0 -2.572 1.065c-.426 1.756 -2.924 1.756 -3.35 0a1.724 1.724 0 0 0 -2.573 -1.066c-1.543 .94 -3.31 -.826 -2.37 -2.37a1.724 1.724 0 0 0 -1.065 -2.572c-1.756 -.426 -1.756 -2.924 0 -3.35a1.724 1.724 0 0 0 1.066 -2.573c-.472 -.774 -.262 -1.604 .259 -2.121"/><path d="M9.889 9.869a3 3 0 1 0 4.226 4.26m.592 -3.424a3.012 3.012 0 0 0 -1.419 -1.415"/><path d="M3 3l18 18"/>' },
      keyboard: { strokeWidth: '2', paths: '<path d="M2 6m0 2a2 2 0 0 1 2 -2h16a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-16a2 2 0 0 1 -2 -2z"/><path d="M6 10l0 .01"/><path d="M10 10l0 .01"/><path d="M14 10l0 .01"/><path d="M18 10l0 .01"/><path d="M6 14l0 .01"/><path d="M18 14l0 .01"/><path d="M10 14l4 .01"/>' },
      x: { strokeWidth: '2', paths: '<path d="M18 6l-12 12"/><path d="M6 6l12 12"/>' },
      pencil: { strokeWidth: '2', paths: '<path d="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4"/><path d="M13.5 6.5l4 4"/>' },
      deviceFloppy: { strokeWidth: '2', paths: '<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2"/><path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0"/><path d="M14 4l0 4l-6 0l0 -4"/>' },
      moon: { strokeWidth: '2', paths: '<path d="M12 3c.132 0 .263 0 .393 0a7.5 7.5 0 0 0 7.92 12.446a9 9 0 1 1 -8.313 -12.454z"/>' },
      sun: { strokeWidth: '2', paths: '<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/><path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"/>' },
      // Features (M3), from NEW:2487-2742 (alert-circle 2487, book-upload 2541, bookmarks 2550,
      // circle-check 2589, circle-x 2592, external-link 2622, file-download 2631, folder-open
      // 2637, info-circle 2646, message 2688, player-pause 2718, player-play 2721, trash 2742).
      playerPlay: { strokeWidth: '2', paths: '<path d="M7 4v16l13 -8z"/>' },
      playerPause: { strokeWidth: '2', paths: '<path d="M6 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"/><path d="M14 5m0 1a1 1 0 0 1 1 -1h2a1 1 0 0 1 1 1v12a1 1 0 0 1 -1 1h-2a1 1 0 0 1 -1 -1z"/>' },
      bookmarks: { strokeWidth: '2', paths: '<path d="M15 10v11l-5 -3l-5 3v-11a3 3 0 0 1 3 -3h4a3 3 0 0 1 3 3z"/><path d="M11 3h5a3 3 0 0 1 3 3v11"/>' },
      fileDownload: { strokeWidth: '2', paths: '<path d="M14 3v4a1 1 0 0 0 1 1h4"/><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z"/><path d="M12 17v-6"/><path d="M9.5 14.5l2.5 2.5l2.5 -2.5"/>' },
      message: { strokeWidth: '2', paths: '<path d="M8 9h8"/><path d="M8 13h6"/><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z"/>' },
      trash: { strokeWidth: '2', paths: '<path d="M4 7l16 0"/><path d="M10 11l0 6"/><path d="M14 11l0 6"/><path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12"/><path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3"/>' },
      externalLink: { strokeWidth: '2', paths: '<path d="M12 6h-6a2 2 0 0 0 -2 2v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-6"/><path d="M11 13l9 -9"/><path d="M15 4h5v5"/>' },
      folderOpen: { strokeWidth: '1.5', paths: '<path d="M5 19l2.757 -7.351a1 1 0 0 1 .936 -.649h12.307a1 1 0 0 1 .986 1.164l-.996 5.211a2 2 0 0 1 -1.964 1.625h-14.026a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v2"/>' },
      circleCheck: { strokeWidth: '2', paths: '<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M9 12l2 2l4 -4"/>' },
      alertCircle: { strokeWidth: '2', paths: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 8v4"/><path d="M12 16h.01"/>' },
      infoCircle: { strokeWidth: '2', paths: '<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"/><path d="M12 9h.01"/><path d="M11 12h1v4h1"/>' },
      circleX: { strokeWidth: '2', paths: '<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"/><path d="M10 10l4 4m0 -4l-4 4"/>' },
      bookUpload: { strokeWidth: '2', paths: '<path d="M14 20h-8a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h12v5"/><path d="M11 16h-5a2 2 0 0 0 -2 2"/><path d="M15 16l3 -3l3 3"/><path d="M18 13v9"/>' }
  };

  /** Kebab-case class name of an icon, e.g. 'zoomInArea' -> 'zoom-in-area'. */
  function getIconClassName(name) {
    return name.replace(/([a-z])([A-Z0-9])/g, '$1-$2').replace(/([0-9])([A-Za-z])/g, '$1-$2').toLowerCase();
  }

  /**
   * SVG markup of one icon (class "icon-tabler icon-tabler-<name>"). Unknown names give '' and a
   * warning, so a typo never breaks the reader.
   * @param {string} name - Key of ICONS, e.g. 'zoomIn'.
   * @returns {string}
   */
  function buildIconSvg(name) {
    const icon = ICONS[name];
    if (!icon) {
      logWarn('icon: unknown icon name', name);
      return '';
    }
    return [
      '<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-', getIconClassName(name), '" width="24" height="24" viewBox="0 0 24 24"',
      ' stroke-width="', icon.strokeWidth, '" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">',
      '<path stroke="none" d="M0 0h24v24H0z" fill="none"/>', icon.paths, '</svg>'
    ].join('');
  }

  /**
   * An icon as a CSS url() value (used for image placeholders).
   * @param {string} name - Key of ICONS.
   * @param {string} color - Stroke color, e.g. '#C0C0C0'.
   * @returns {string}
   */
  function buildIconCssUrl(name, color) {
    const svg = buildIconSvg(name).replace('stroke="currentColor"', 'stroke="' + color + '"');
    return 'url("data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '")';
  }

  /** Two-tone icon colors (NEW:2481), scoped to the reader. */
  const ICON_COLOR_CSS = [
    '#MangaViewerMM .icon-tabler { width: 1rem; height: 1rem; vertical-align: sub; flex-shrink: 0; }',
    '#MangaViewerMM .icon-tabler-arrow-autofit-width > :nth-child(n + 3), #MangaViewerMM .icon-tabler-arrow-autofit-height > :nth-child(n + 3) { color: yellow; }',
    '#MangaViewerMM .icon-tabler-zoom-in-area > :nth-child(2), #MangaViewerMM .icon-tabler-zoom-in-area > :nth-child(3) { color: lime; }',
    '#MangaViewerMM .icon-tabler-zoom-out-area > :nth-child(2) { color: red; }',
    '#MangaViewerMM .icon-tabler-zoom-pan > :nth-child(n + 4) { color: #9966ff; }',
    '#MangaViewerMM .icon-tabler-arrow-autofit-down > :nth-child(n + 3), #MangaViewerMM .icon-tabler-arrow-autofit-left > :nth-child(n + 3), #MangaViewerMM .icon-tabler-arrow-autofit-right > :nth-child(n + 3) { color: #28ffbf; }',
    '#MangaViewerMM .icon-tabler-spacing-vertical > :nth-child(4), #MangaViewerMM .icon-tabler-spacing-horizontal > :nth-child(4) { color: fuchsia; }',
    '#MangaViewerMM .icon-tabler-list-numbers > :nth-child(n + 5) { color: #e48900; }',
    '#MangaViewerMM .icon-tabler-bookmark > :nth-child(2), #MangaViewerMM .icon-tabler-bookmark-off > :nth-child(2) { color: orange; }',
    '#MangaViewerMM .icon-tabler-bookmark-off > :nth-child(3), #MangaViewerMM .icon-tabler-eye-off > :nth-child(4) { color: red; }',
    '#MangaViewerMM .icon-tabler-zoom-cancel > :nth-child(3), #MangaViewerMM .icon-tabler-zoom-cancel > :nth-child(4) { color: #9966ff; }',
    '#MangaViewerMM .icon-tabler-zoom-in > :nth-child(3), #MangaViewerMM .icon-tabler-zoom-in > :nth-child(4) { color: lime; }',
    '#MangaViewerMM .icon-tabler-zoom-out > :nth-child(3) { color: red; }',
    '#MangaViewerMM .icon-tabler-refresh > :nth-child(n + 2) { color: cyan; }',
    '#MangaViewerMM .icon-tabler-book-arrow-left > :nth-child(7), #MangaViewerMM .icon-tabler-book-arrow-left > :nth-child(8), #MangaViewerMM .icon-tabler-book-arrow-right > :nth-child(7), #MangaViewerMM .icon-tabler-book-arrow-right > :nth-child(8) { color: greenyellow; }',
    '#MangaViewerMM .icon-tabler-books-return > :nth-child(8), #MangaViewerMM .icon-tabler-books-return > :nth-child(9) { color: greenyellow; }'
  ].join('\n');

  /** Colors per color scheme, as CSS variables on the reader root. */
  const READER_BASE_CSS = [
    '#MangaViewerMM {',
    '  --mvm-background: #25262b;',
    '  --mvm-surface: #1a1b1e;',
    '  --mvm-highlight: #2c2e33;',
    '  --mvm-border: #373a40;',
    '  --mvm-text: #c1c2c5;',
    '  --mvm-primary: #29487D;',
    '  --mvm-primary-text: #ffffff;',
    '  min-height: 100vh;',
    '  color: var(--mvm-text);',
    '  background-color: var(--mvm-background);',
    '  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;',
    '  font-size: 14px;',
    '  line-height: 20px;',
    '}',
    '#MangaViewerMM.light {',
    '  --mvm-background: #f1f3f5;',
    '  --mvm-surface: #ffffff;',
    '  --mvm-highlight: #e9ecef;',
    '  --mvm-border: #ced4da;',
    '  --mvm-text: #212529;',
    '}',
    'html[mvm], html[mvm] body { margin: 0; padding: 0; background-color: #25262b; }',
    '#MangaViewerMM a, #MangaViewerMM a:visited { color: var(--mvm-text); text-decoration: none; }',
    '#MangaViewerMM .mvm-button {',
    '  display: inline-flex; align-items: center; justify-content: center; gap: 4px;',
    '  min-height: 32px; padding: 3px 10px; margin: 2px; cursor: pointer;',
    '  border: 1px solid var(--mvm-border); border-radius: 5px;',
    '  color: var(--mvm-primary-text); background-color: var(--mvm-primary); font-size: 14px;',
    '}',
    '#MangaViewerMM a.mvm-button, #MangaViewerMM a.mvm-button:visited { color: var(--mvm-primary-text); text-decoration: none; }',
    '#MangaViewerMM .mvm-button:hover { opacity: 0.85; }',
    '#MangaViewerMM .mvm-button.disabled, #MangaViewerMM .mvm-button[disabled] { opacity: 0.4; pointer-events: none; }',
    '#MangaViewerMM #ProgressBar { position: fixed; top: 0; left: 0; width: 100%; height: 4px; z-index: 2000; pointer-events: none; }',
    '#MangaViewerMM #ProgressBar .mvm-progress-fill { width: 0; height: 100%; background-color: #29d; transition: width 0.2s linear; }',
    '#MangaViewerMM #ProgressBar.complete { display: none; }',
    '#MangaViewerMM #Header {',
    '  display: flex; flex-flow: row wrap; justify-content: space-between; align-items: center;',
    '  padding: 5px 10px; background-color: var(--mvm-surface); border-bottom: 1px solid var(--mvm-border);',
    '}',
    '#MangaViewerMM .ViewerTitle { flex: 1 1 300px; text-align: center; padding: 5px; }',
    '#MangaViewerMM #MangaTitle { margin: 0; font-size: 1.2rem; font-weight: 400; word-wrap: break-word; }',
    '#MangaViewerMM #ChapterNavigation { display: flex; flex-flow: column nowrap; align-items: flex-end; padding: 5px; }',
    '#MangaViewerMM #Counters { padding: 0 5px; font-family: monospace; }',
    '#MangaViewerMM #Chapter { display: block; text-align: center; min-width: 225px; }',
    '#MangaViewerMM .MangaPage { position: relative; display: block; width: 100%; text-align: center; line-height: 0; min-height: 22px; }',
    '#MangaViewerMM #Chapter.Vertical .MangaPage { margin: 8px 0; }',
    '#MangaViewerMM .PageContent { display: inline-block; max-width: 100%; overflow-x: auto; }',
    '#MangaViewerMM .PageImg { height: auto; vertical-align: middle; border: 0; }',
    '#MangaViewerMM #Chapter.fitWidthIfOversize .PageImg { max-width: 100%; }',
    '#MangaViewerMM .PageImg:not([src]), #MangaViewerMM .PageImg.imgBroken {',
    '  display: inline-block; width: 40vw; height: 80vh; background-color: var(--mvm-highlight);',
    '}',
    '#MangaViewerMM .PageFunctions {',
    '  position: absolute; top: 0; right: 0; z-index: 1; display: flex; gap: 3px; align-items: center;',
    '  line-height: 1rem; font-family: monospace; opacity: 0.5;',
    '}',
    '#MangaViewerMM .PageFunctions:hover { opacity: 1; }',
    '#MangaViewerMM.hideControls .PageFunctions { visibility: hidden; }',
    '#MangaViewerMM .PageIndex { padding: 3px 6px; border-radius: 5px; color: var(--mvm-primary-text); background-color: var(--mvm-primary); }',
    '#MangaViewerMM .PageFunctions .mvm-button { min-height: 22px; padding: 2px 6px; margin: 0; font-size: 12px; }',
    '#MangaViewerMM .MangaPage.failed .PageIndex { background-color: #c92a2a; }'
  ].join('\n');

  /**
   * Reader layout: header modes, view modes, page controls, thumbnails bar, pagination and the
   * phone/tablet rules. Same behavior as the 2026 version's CSS (NEW:9204, 9207, 9973, 7890-8019,
   * 11512-11531), rewritten without nesting, :has(), :where(), selector lists inside :not(),
   * oklch() and color-mix(). Colors come only from the theme variables (section 10).
   */
  const READER_LAYOUT_CSS = [
    // -- buttons and button groups --
    '#MangaViewerMM .mvm-button .icon-tabler { width: 20px; height: 20px; }',
    '#MangaViewerMM .mvm-button.icon-only { padding: 3px 6px; }',
    '#MangaViewerMM .mvm-button.selected { color: var(--mvm-secondary-text); background-color: var(--mvm-secondary); }',
    '#MangaViewerMM .mvm-button-group { display: inline-flex; flex-flow: row nowrap; margin: 2px; }',
    '#MangaViewerMM .mvm-button-group .mvm-button { margin: 0; border-radius: 0; }',
    '#MangaViewerMM .mvm-button-group .mvm-button:first-child { border-radius: 5px 0 0 5px; }',
    '#MangaViewerMM .mvm-button-group .mvm-button:last-child { border-radius: 0 5px 5px 0; }',
    // -- header and its modes (visibility is decided in JS, see section 14 "Header") --
    '#MangaViewerMM #Header {',
    '  position: -webkit-sticky; position: sticky; top: 0; z-index: 900;',
    '  box-shadow: 0 0 25px rgba(0, 0, 0, 0.5); transition: transform 0.3s ease-in;',
    '}',
    '#MangaViewerMM #Header.simple { position: static; }',
    '#MangaViewerMM #Header.header-hidden { transform: translateY(-100%); }',
    '#MangaViewerMM #Header.click { padding-left: 60px; }',
    '#MangaViewerMM #menu { display: none; position: fixed; top: 25px; left: 5px; z-index: 901; opacity: 0.8; width: 40px; height: 40px; }',
    '#MangaViewerMM #menu.click { display: inline-flex; }',
    '#MangaViewerMM #menu.click.hide { display: none; }',
    '#MangaViewerMM #Toolbar { display: flex; flex-flow: row wrap; align-items: center; justify-content: center; }',
    '#MangaViewerMM #ZoomControl { display: flex; flex-direction: column; align-items: center; padding: 5px; min-width: 100px; line-height: 1rem; }',
    '#MangaViewerMM #ZoomSlider { width: 140px; margin: 4px 0 0; }',
    '#MangaViewerMM #GoToPageControl { padding: 0 5px; }',
    '#MangaViewerMM #GoToPage { min-width: 50px; margin: 2px; color: inherit; background-color: var(--mvm-background); border: 1px solid var(--mvm-border); }',
    // -- chapter area, pages, placeholders --
    '#MangaViewerMM #Chapter { box-sizing: border-box; }',
    '#MangaViewerMM .PageContent { overflow-y: hidden; transition: height 0.3s ease-in-out; }',
    '#MangaViewerMM .MangaPage.hide .PageContent { height: 0; }',
    '#MangaViewerMM .PageImg:not([src]), #MangaViewerMM .PageImg.imgBroken {',
    '  background-position: center; background-repeat: no-repeat; background-size: 20%;',
    '}',
    '#MangaViewerMM .PageImg:not([src]) { background-image: __PHOTO_URL__; }',
    '#MangaViewerMM .PageImg.imgBroken { background-image: __PHOTO_OFF_URL__; }',
    '#MangaViewerMM .PageFunctions .mvm-button { padding: 3px; }',
    '#MangaViewerMM .PageFunctions .mvm-button .icon-tabler { width: 1rem; height: 1rem; }',
    '#MangaViewerMM .PageFunctions .ToggleDoublePage { display: none; }',
    '#MangaViewerMM #Chapter.Book .PageFunctions .ToggleDoublePage, #MangaViewerMM #Chapter.Manga .PageFunctions .ToggleDoublePage { display: inline-flex; }',
    '#MangaViewerMM #Chapter.Book .PageFunctions .ZoomWidth, #MangaViewerMM #Chapter.Manga .PageFunctions .ZoomWidth { display: none; }',
    '#MangaViewerMM #Chapter.FluidLTR .PageFunctions .ZoomWidth, #MangaViewerMM #Chapter.FluidRTL .PageFunctions .ZoomWidth { display: none; }',
    // two-icon buttons: bookmark / bookmark-off, eye-off (hide) / eye (show)
    '#MangaViewerMM .Bookmark .icon-tabler-bookmark-off { display: none; }',
    '#MangaViewerMM.bookmarked .Bookmark .icon-tabler-bookmark { display: none; }',
    '#MangaViewerMM.bookmarked .Bookmark .icon-tabler-bookmark-off { display: inline-block; }',
    '#MangaViewerMM .Hide .icon-tabler-eye { display: none; }',
    '#MangaViewerMM .MangaPage.hide .Hide .icon-tabler-eye { display: inline-block; }',
    '#MangaViewerMM .MangaPage.hide .Hide .icon-tabler-eye-off { display: none; }',
    // -- view mode: Vertical (separators) --
    '#MangaViewerMM .separator { display: none; }',
    '#MangaViewerMM #Chapter.Vertical .separator { display: flex; align-items: center; text-align: center; font-style: italic; line-height: 20px; }',
    '#MangaViewerMM #Chapter.Vertical .separator::before, #MangaViewerMM #Chapter.Vertical .separator::after { content: ""; flex: 1; border-bottom: 1px solid var(--mvm-text); }',
    '#MangaViewerMM #Chapter.Vertical .separator::before { margin-right: 0.25em; }',
    '#MangaViewerMM #Chapter.Vertical .separator::after { margin-left: 0.25em; }',
    // -- view mode: Fluid (horizontal) --
    '#MangaViewerMM #Chapter.FluidLTR, #MangaViewerMM #Chapter.FluidRTL { display: flex; flex-wrap: nowrap; overflow-x: auto; min-width: 0; }',
    '#MangaViewerMM #Chapter.FluidLTR { flex-direction: row; }',
    '#MangaViewerMM #Chapter.FluidRTL { flex-direction: row-reverse; }',
    '#MangaViewerMM #Chapter.FluidLTR .MangaPage, #MangaViewerMM #Chapter.FluidRTL .MangaPage { width: auto; flex: 0 0 auto; min-width: -moz-fit-content; min-width: fit-content; }',
    '#MangaViewerMM #Chapter.FluidLTR .PageImg, #MangaViewerMM #Chapter.FluidRTL .PageImg { min-width: 0; }',
    '#MangaViewerMM #Chapter.FluidLTR.fitWidthIfOversize .PageImg, #MangaViewerMM #Chapter.FluidRTL.fitWidthIfOversize .PageImg { max-width: none; }',
    '#MangaViewerMM #Chapter.FluidLTR .PageContent, #MangaViewerMM #Chapter.FluidRTL .PageContent { max-width: none; }',
    '#MangaViewerMM #Chapter.FluidLTR .MangaPage .PageFunctions { right: auto; left: 0; direction: rtl; }',
    // -- view mode: Book and Manga (two columns) --
    '#MangaViewerMM #Chapter.Book, #MangaViewerMM #Chapter.Manga { display: grid; grid-template-columns: 1fr 1fr; grid-auto-flow: row; width: 100%; min-width: 0; }',
    '#MangaViewerMM #Chapter.Book .MangaPage, #MangaViewerMM #Chapter.Manga .MangaPage { width: 100%; display: block; overflow: hidden; }',
    '#MangaViewerMM #Chapter.Book .MangaPage.DoublePage, #MangaViewerMM #Chapter.Manga .MangaPage.DoublePage { grid-column: span 2; }',
    '#MangaViewerMM #Chapter.Book .MangaPage.LeftPage .PageFunctions, #MangaViewerMM #Chapter.Manga .MangaPage.LeftPage .PageFunctions { right: auto; left: 0; flex-direction: row-reverse; }',
    '#MangaViewerMM #Chapter.Book .PageContent, #MangaViewerMM #Chapter.Manga .PageContent { display: flex; justify-content: center; align-items: center; overflow: hidden; }',
    '#MangaViewerMM #Chapter.Book .LeftPage .PageContent, #MangaViewerMM #Chapter.Manga .LeftPage .PageContent { justify-content: flex-end; }',
    '#MangaViewerMM #Chapter.Book .RightPage .PageContent, #MangaViewerMM #Chapter.Manga .RightPage .PageContent { justify-content: flex-start; }',
    '#MangaViewerMM #Chapter.Manga { direction: rtl; }',
    '#MangaViewerMM #Chapter.Manga .MangaPage { direction: ltr; }',
    // -- view mode: Gallery (margins instead of flex gap, which old Safari lacks) --
    '#MangaViewerMM #Chapter.Gallery { display: flex; flex-wrap: wrap; justify-content: center; padding: 5px; }',
    '#MangaViewerMM #Chapter.Gallery .MangaPage { width: auto; min-width: 0; flex: 0 1 auto; margin: 5px; }',
    '#MangaViewerMM #Chapter.Gallery .PageImg { min-width: 0; }',
    '#MangaViewerMM #Chapter.Gallery .PageFunctions { display: none; }',
    // -- thumbnails bar (navbar) --
    '#MangaViewerMM.navbar-bottom { padding-bottom: 34px; }',
    '#MangaViewerMM.navbar-left { padding-left: 34px; }',
    '#MangaViewerMM.navbar-right { padding-right: 34px; }',
    '#MangaViewerMM #Navigation {',
    '  position: fixed; z-index: 1000; display: flex; box-sizing: border-box; overflow: hidden; white-space: nowrap;',
    '  text-align: center; line-height: 0; color: var(--mvm-text); background-color: var(--mvm-highlight);',
    '  transition: height 0.3s ease, width 0.3s ease, opacity 0.2s ease-in-out;',
    '}',
    '#MangaViewerMM #Navigation.disabled { display: none; }',
    '#MangaViewerMM #Navigation.hiding { opacity: 0; transition: none; }',
    '#MangaViewerMM #Navigation.horizontal { flex-direction: column; height: 34px; width: 100%; left: 0; right: 0; bottom: 0; }',
    '#MangaViewerMM #Navigation.horizontal:hover { height: 200px; }',
    '#MangaViewerMM #Navigation.vertical { flex-direction: row; width: 34px; top: 0; bottom: 0; }',
    '#MangaViewerMM #Navigation.vertical:hover { width: 200px; }',
    '#MangaViewerMM #Navigation.left { left: 0; flex-direction: row-reverse; }',
    '#MangaViewerMM #Navigation.right { right: 0; }',
    '#MangaViewerMM #NavigationCounters { flex-shrink: 0; padding: 5px; line-height: 1rem; display: flex; align-items: center; justify-content: center; }',
    '#MangaViewerMM #NavigationCounters > * { margin: 0 3px; }',
    '#MangaViewerMM #Navigation.vertical #NavigationCounters { -webkit-writing-mode: vertical-rl; writing-mode: vertical-rl; transform: rotate(180deg); }',
    '#MangaViewerMM #Navigation.right #NavigationCounters { transform: rotate(0deg); }',
    '#MangaViewerMM #Thumbnails { flex-grow: 1; display: none; }',
    '#MangaViewerMM #Navigation:hover #Thumbnails { display: flex; }',
    '#MangaViewerMM #Navigation.horizontal #Thumbnails { flex-direction: row; overflow-x: auto; overflow-y: hidden; }',
    '#MangaViewerMM #Navigation.vertical #Thumbnails { flex-direction: column; overflow-x: hidden; overflow-y: auto; }',
    '#MangaViewerMM #Navigation.left #Thumbnails { direction: rtl; }',
    '#MangaViewerMM .Thumbnail { position: relative; display: inline-flex; flex: 0 0 auto; justify-content: center; align-items: center; width: 150px; height: 150px; margin: 0 5px; cursor: pointer; }',
    '#MangaViewerMM #Navigation.vertical .Thumbnail { margin: 5px 0; }',
    '#MangaViewerMM .ThumbnailIndex {',
    '  position: absolute; left: 0; bottom: 30%; z-index: 1; display: block; width: 100%; opacity: 0.9;',
    '  line-height: 1.2rem; text-align: center; font-weight: 600; color: var(--mvm-primary-text); background-color: var(--mvm-primary);',
    '}',
    '#MangaViewerMM .ThumbnailImg { display: inline-block; max-height: 150px; min-height: 150px; min-width: 80px; max-width: 150px; background-repeat: no-repeat; background-position: center; background-size: 48px 48px; }',
    '#MangaViewerMM .ThumbnailImg:not([src]) { background-image: __PHOTO_URL__; }',
    '#MangaViewerMM .ThumbnailImg.imgBroken { background-image: __PHOTO_OFF_URL__; }',
    // -- pagination: slider bar and side arrows (shown by root classes pagination-*) --
    '#MangaViewerMM .slider-pagination { display: none; position: fixed; left: 0; right: 0; bottom: 30px; z-index: 100; justify-content: center; align-items: center; }',
    '#MangaViewerMM.navbar-bottom .slider-pagination { bottom: 40px; }',
    '#MangaViewerMM.pagination-slider .slider-pagination, #MangaViewerMM.pagination-both .slider-pagination { display: flex; }',
    '#MangaViewerMM .slider-pagination .mvm-button { min-width: 36px; height: 36px; }',
    '#MangaViewerMM #PaginationSlider { flex: 1 1 auto; max-width: 1000px; margin: 0 5px; }',
    '#MangaViewerMM .side-arrow {',
    '  display: none; position: fixed; top: 50px; bottom: 0; z-index: 99; width: 10vw; padding: 0; border: 0;',
    '  align-items: center; justify-content: center; cursor: pointer; opacity: 0; color: var(--mvm-primary-text);',
    '  background-color: transparent; transition: opacity 0.2s ease-in-out; -webkit-tap-highlight-color: transparent;',
    '}',
    '#MangaViewerMM.pagination-side-arrows .side-arrow, #MangaViewerMM.pagination-both .side-arrow { display: flex; }',
    '#MangaViewerMM .side-arrow:hover { opacity: 1; background-color: rgba(0, 0, 0, 0.15); }',
    '#MangaViewerMM .side-arrow.left { left: 0; }',
    '#MangaViewerMM .side-arrow.right { right: 0; }',
    '#MangaViewerMM .side-arrow .icon-tabler { width: 48px; height: 48px; filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.5)); }',
    '#MangaViewerMM.pagination-side-arrows .side-arrow.unavailable, #MangaViewerMM.pagination-both .side-arrow.unavailable { display: none; }',
    // -- phones and tablets (device class on the root, NEW:9207) --
    '#MangaViewerMM.mobile #Header, #MangaViewerMM.tablet #Header { justify-content: center; }',
    '#MangaViewerMM.mobile .ViewerTitle, #MangaViewerMM.tablet .ViewerTitle { order: 4; flex-basis: 100%; }',
    '#MangaViewerMM.mobile .PageFunctions .mvm-button { display: none; }',
    '#MangaViewerMM.mobile .PageFunctions .Bookmark { display: inline-flex; opacity: 1; }',
    '#MangaViewerMM.mobile #ZoomControl, #MangaViewerMM.mobile #ZoomGroup, #MangaViewerMM.mobile #ViewModeGroup { display: none; }',
    '#MangaViewerMM.tablet #ZoomSlider { display: none; }',
    '#MangaViewerMM.tablet #ViewModeGroup .view-Book, #MangaViewerMM.tablet #ViewModeGroup .view-Manga { display: none; }'
  ].join('\n').replace(/__PHOTO_URL__/g, buildIconCssUrl('photo', '#C0C0C0')).replace(/__PHOTO_OFF_URL__/g, buildIconCssUrl('photoOff', '#C0C0C0'));

  /**
   * Side panels (settings on the left, keybindings on the right), their overlay and the generic
   * settings controls (switch, segmented control, slider with value, select, color swatches).
   * Look of the 2025 version's panels (OLD:4752, OLD:4764: fieldsets, value badges, red/green
   * switch, key caps), colors from the theme variables. Hidden panels use visibility:hidden so
   * keyboard focus never enters them.
   */
  const PANELS_CSS = [
    // -- overlay and drawers --
    '#MangaViewerMM #Overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 1900; background-color: rgba(0, 0, 0, 0.5); }',
    '#MangaViewerMM #Overlay.visible { display: block; }',
    '#MangaViewerMM .mvm-panel {',
    '  position: fixed; top: 0; bottom: 0; z-index: 1950; box-sizing: border-box; width: 360px; max-width: 100vw;',
    '  padding: 10px; overflow-y: auto; line-height: 1.5em; color: var(--mvm-text); background-color: var(--mvm-surface);',
    '  box-shadow: 0 0 20px rgba(0, 0, 0, 0.5); visibility: hidden; transition: transform 0.3s ease-in-out, visibility 0s linear 0.3s;',
    '}',
    '#MangaViewerMM .mvm-panel.left { left: 0; transform: translateX(-100%); }',
    '#MangaViewerMM .mvm-panel.right { right: 0; transform: translateX(100%); }',
    '#MangaViewerMM .mvm-panel.visible { transform: translateX(0); visibility: visible; transition: transform 0.3s ease-in-out, visibility 0s; }',
    '#MangaViewerMM .mvm-panel:focus { outline: none; }',
    '#MangaViewerMM .mvm-panel-header { display: flex; flex-flow: row nowrap; align-items: center; justify-content: space-between; margin-bottom: 5px; }',
    '#MangaViewerMM .mvm-panel-header h2 { flex: 1 1 auto; margin: 0; font-size: 1.3rem; font-weight: 600; }',
    '#MangaViewerMM .mvm-panel-actions { display: flex; flex-flow: row nowrap; }',
    '#MangaViewerMM #ResetSettings { width: 100%; margin: 0 0 5px; }',
    // -- settings groups and rows --
    '#MangaViewerMM #SettingsPanel fieldset { margin: 5px 0; padding: 3px 6px; border: 1px solid var(--mvm-border); border-radius: 10px; }',
    '#MangaViewerMM #SettingsPanel legend { padding: 0 4px; font-weight: 600; }',
    '#MangaViewerMM #SettingsPanel .ControlLabel { display: flex; flex-flow: row wrap; justify-content: space-between; align-items: center; padding: 3px 2px; }',
    '#MangaViewerMM #SettingsPanel .ControlLabel.mvm-row-hidden { display: none; }',
    '#MangaViewerMM #SettingsPanel .ControlLabelText { margin-right: 6px; }',
    '#MangaViewerMM #SettingsPanel .mvm-slider-row .ControlLabelText { flex: 1 1 100%; }',
    '#MangaViewerMM.mobile #SettingsPanel .hide-on-mobile { display: none; }',
    '#MangaViewerMM #SettingsPanel input[type="range"] { width: 100%; margin: 4px 0; }',
    '#MangaViewerMM #SettingsPanel .RangeValue {',
    '  display: inline-block; margin-left: 8px; padding: 2px 5px; border-radius: 3px; line-height: 20px; text-align: center;',
    '  color: var(--mvm-primary-text); background-color: var(--mvm-primary);',
    '}',
    '#MangaViewerMM .mvm-select { max-width: 200px; padding: 2px 4px; border: 1px solid var(--mvm-border); border-radius: 4px; color: var(--mvm-text); background-color: var(--mvm-background); }',
    // -- segmented control --
    '#MangaViewerMM .mvm-segmented { display: inline-flex; flex-flow: row wrap; padding: 2px; border-radius: 0.5rem; background-color: var(--mvm-highlight); }',
    '#MangaViewerMM .mvm-segment {',
    '  display: inline-flex; flex: 1 1 auto; align-items: center; justify-content: center; margin: 1px; padding: 4px 8px;',
    '  border: 0; border-radius: 0.4rem; cursor: pointer; font-size: 13px; color: var(--mvm-text); background-color: transparent;',
    '}',
    '#MangaViewerMM .mvm-segment .icon-tabler { width: 18px; height: 18px; }',
    '#MangaViewerMM .mvm-segment .icon-tabler + span { margin-left: 4px; }',
    '#MangaViewerMM .mvm-segment.selected { font-weight: 600; color: var(--mvm-primary-text); background-color: var(--mvm-primary); }',
    // -- switch (2025 version "toggler" colors) --
    '#MangaViewerMM .mvm-switch { position: relative; display: inline-block; flex-shrink: 0; width: 38px; height: 20px; }',
    '#MangaViewerMM .mvm-switch input { position: absolute; top: 0; left: 0; z-index: 1; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; }',
    '#MangaViewerMM .mvm-switch-track { position: absolute; top: 0; left: 0; right: 0; bottom: 0; border: 1px solid #d6d6d6; border-radius: 20px; background-color: #e4e8e8; }',
    '#MangaViewerMM .mvm-switch-track::after {',
    '  content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%;',
    '  background-color: #d7062a; transition: left 0.15s ease-in-out, background-color 0.2s ease-out;',
    '}',
    '#MangaViewerMM .mvm-switch input:checked + .mvm-switch-track::after { left: 20px; background-color: #50ac5d; }',
    '#MangaViewerMM .mvm-switch input:focus + .mvm-switch-track { box-shadow: 0 0 0 2px var(--mvm-primary); }',
    // -- theme color picker --
    '#MangaViewerMM .mvm-theme-picker { flex: 1 1 100%; }',
    '#MangaViewerMM #ThemeColorInput { width: 44px; height: 28px; padding: 0; border: 1px solid var(--mvm-border); background-color: transparent; cursor: pointer; vertical-align: middle; }',
    '#MangaViewerMM .mvm-swatches { display: flex; flex-flow: row wrap; align-items: center; margin: 3px 0; }',
    '#MangaViewerMM .mvm-swatch { width: 22px; height: 22px; margin: 2px; padding: 0; border: 1px solid var(--mvm-border); border-radius: 50%; cursor: pointer; }',
    '#MangaViewerMM .mvm-palette-row .mvm-swatch { border-radius: 3px; }',
    '#MangaViewerMM .mvm-swatch.selected { outline: 2px solid var(--mvm-text); outline-offset: 1px; }',
    '#MangaViewerMM .mvm-palettes summary { cursor: pointer; }',
    '#MangaViewerMM .mvm-palette-row { display: flex; flex-flow: row nowrap; }',
    // -- keybindings panel --
    '#MangaViewerMM #KeybindingsList, #MangaViewerMM #KeybindingsEditor { display: grid; grid-template-columns: 1fr 2fr; grid-gap: 5px; gap: 5px; align-items: center; }',
    '#MangaViewerMM .KeybindInput { box-sizing: border-box; width: 100%; padding: 2px 4px; border: 1px solid var(--mvm-border); border-radius: 4px; color: var(--mvm-text); background-color: var(--mvm-background); }',
    '#MangaViewerMM .KeybindInput.invalid { border-color: #d33; }',
    '#MangaViewerMM .mvm-keybind-error { font-size: 12px; line-height: 1.2em; color: #ff6b6b; }',
    '#MangaViewerMM .mvm-keybind-error:empty { display: none; }',
    '#MangaViewerMM #HotKeysRules { margin-top: 10px; font-size: 13px; }',
    '#MangaViewerMM #HotKeysRules h3 { margin: 5px 0; font-size: 1rem; }',
    '#MangaViewerMM kbd {',
    '  display: inline-block; min-width: 1em; margin: 1px 0; padding: 0.3em 0.4em 0.2em 0.3em; border-radius: 0.3em; white-space: nowrap;',
    '  font-family: "Lucida Grande", Lucida, Arial, sans-serif; font-size: 0.85em; font-style: normal; line-height: 1; text-align: center;',
    '  color: #fafafa; background-color: #505050; text-shadow: -1px -1px 0 #464646;',
    '  box-shadow: inset 0 0 1px #969696, inset 0 -0.05em 0.4em #505050, 0 0.1em 0 #1e1e1e, 0 0.1em 0.1em rgba(0, 0, 0, 0.3);',
    '}'
  ].join('\n');

  /**
   * Milestone M3 features inside the reader: header feature buttons (auto-scroll play/pause,
   * download progress), the bookmarks panel and the comments panel (2025 version look,
   * OLD:4740: a large panel with its own light/dark area). Same CSS rules as the rest of the file.
   */
  const FEATURES_CSS = [
    // -- header feature buttons --
    '#MangaViewerMM #AutoScroll .icon-tabler-player-pause { display: none; }',
    '#MangaViewerMM #AutoScroll.running .icon-tabler-player-pause { display: inline-block; }',
    '#MangaViewerMM #AutoScroll.running .icon-tabler-player-play { display: none; }',
    '#MangaViewerMM #download.working { cursor: progress; }',
    '#MangaViewerMM #download.partial { opacity: 0.6; }',
    '#MangaViewerMM #download .mvm-download-progress { margin-left: 4px; font-family: monospace; font-size: 12px; }',
    '#MangaViewerMM #download .mvm-download-progress:empty { display: none; }',
    // -- bookmarks panel --
    '#MangaViewerMM #BookmarksPanel { width: 480px; }',
    '#MangaViewerMM #BookmarksClear { width: 100%; margin: 5px 0; }',
    '#MangaViewerMM .mvm-bookmark-list { display: flex; flex-direction: column; }',
    '#MangaViewerMM .mvm-bookmark-item { display: flex; flex-flow: row nowrap; align-items: center; padding: 6px 4px; border-bottom: 1px solid var(--mvm-border); }',
    '#MangaViewerMM .mvm-bookmark-item.current { background-color: var(--mvm-highlight); }',
    '#MangaViewerMM .mvm-bookmark-info { flex: 1 1 auto; min-width: 0; }',
    '#MangaViewerMM .mvm-bookmark-name { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
    '#MangaViewerMM .mvm-bookmark-url { display: block; font-size: 12px; opacity: 0.75; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
    '#MangaViewerMM .mvm-bookmark-details { flex-shrink: 0; width: 90px; margin: 0 6px; font-size: 12px; text-align: right; opacity: 0.75; }',
    '#MangaViewerMM .mvm-bookmark-actions { display: flex; flex-flow: row nowrap; flex-shrink: 0; }',
    '#MangaViewerMM .mvm-list-empty { padding: 10px; text-align: center; opacity: 0.75; }',
    // -- comments panel --
    '#MangaViewerMM #CommentsPanel { width: 720px; display: flex; flex-direction: column; }',
    '#MangaViewerMM #CommentsArea { flex: 1 1 auto; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 5px; border-radius: 5px; }',
    '#MangaViewerMM #CommentsArea.light { color: #222222; background-color: #ffffff; }',
    '#MangaViewerMM #CommentsArea.dark { color: #dddddd; background-color: #1a1b1e; }',
    '#MangaViewerMM #CommentsArea iframe { max-width: 100%; }'
  ].join('\n');

  /** Popup (dialog) and start button styles. Injected on the ORIGINAL site page too. */
  const POPUP_CSS = [
    '.mvm-popup-backdrop {',
    '  position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 2147483000;',
    '  display: flex; align-items: center; justify-content: center; background: rgba(0, 0, 0, 0.4);',
    '  font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;',
    '}',
    '.mvm-popup {',
    '  box-sizing: border-box; width: 32em; max-width: 92vw; padding: 1.25em; border-radius: 5px;',
    '  background: #ffffff; color: #545454; text-align: center; font-size: 16px; line-height: 1.4;',
    '  box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);',
    '}',
    '.mvm-popup-icon {',
    '  box-sizing: content-box; width: 4em; height: 4em; margin: 0.5em auto 0.6em; border: 0.25em solid #87adbd;',
    '  border-radius: 50%; color: #87adbd; font-size: 16px; line-height: 4em; font-weight: bold;',
    '}',
    '.mvm-popup-icon::before { content: "?"; font-size: 2.6em; }',
    '.mvm-popup-icon.info { border-color: #3fc3ee; color: #3fc3ee; }',
    '.mvm-popup-icon.info::before { content: "i"; }',
    '.mvm-popup-icon.warning { border-color: #facea8; color: #f8bb86; }',
    '.mvm-popup-icon.warning::before { content: "!"; }',
    '.mvm-popup-title { margin: 0 0 0.4em; font-size: 1.8em; font-weight: 600; color: #595959; }',
    '.mvm-popup-content { margin: 0 0 1em; font-size: 1.1em; }',
    '.mvm-popup-actions { display: flex; flex-flow: row-reverse wrap; justify-content: center; gap: 0.5em; }',
    '.mvm-popup-button {',
    '  padding: 0.6em 1.4em; border: 0; border-radius: 4px; font-size: 1em; color: #ffffff; cursor: pointer;',
    '  background: #7066e0;',
    '}',
    '.mvm-popup-button.cancel { background: #d33; }',
    '.mvm-popup-timer { height: 4px; margin-top: 1em; background: rgba(0, 0, 0, 0.2); transition-property: width; transition-timing-function: linear; }',
    '.mvm-popup-range { display: flex; justify-content: center; align-items: center; gap: 0.5em; margin-top: 0.8em; }',
    '.mvm-popup-range input { width: 5em; padding: 0.3em; font-size: 1em; text-align: center; border: 1px solid #d9d9d9; border-radius: 4px; }',
    '.mvm-popup-error { min-height: 1.2em; margin-top: 0.4em; color: #d33; font-size: 0.9em; }',
    // -- page-range popup: two-handle slider (two overlapping range inputs; only the thumbs take
    //    the pointer, so each handle can be dragged; the number inputs stay in sync) --
    '.mvm-range-display { margin-top: 0.6em; font-family: monospace; font-size: 1.1em; }',
    '.mvm-dual-range { position: relative; height: 28px; margin: 0.6em 0.6em 0; }',
    '.mvm-dual-range-track { position: absolute; left: 9px; right: 9px; top: 12px; height: 4px; border-radius: 2px; background: #d9d9d9; }',
    '.mvm-dual-range-fill { position: absolute; top: 0; bottom: 0; left: 0; right: 0; border-radius: 2px; background: #7066e0; }',
    '.mvm-dual-range input[type="range"] { position: absolute; left: 0; top: 0; z-index: 2; width: 100%; height: 28px; margin: 0; padding: 0; background: transparent; pointer-events: none; -webkit-appearance: none; -moz-appearance: none; appearance: none; }',
    '.mvm-dual-range input[type="range"].on-top { z-index: 3; }',
    '.mvm-dual-range input[type="range"]:focus { outline: none; }',
    '.mvm-dual-range input[type="range"]::-webkit-slider-runnable-track { height: 28px; border: 0; background: transparent; }',
    '.mvm-dual-range input[type="range"]::-moz-range-track { height: 28px; border: 0; background: transparent; }',
    '.mvm-dual-range input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; pointer-events: auto; width: 18px; height: 18px; margin-top: 5px; border: 2px solid #ffffff; border-radius: 50%; background: #7066e0; box-shadow: 0 0 3px rgba(0, 0, 0, 0.4); cursor: pointer; }',
    '.mvm-dual-range input[type="range"]::-moz-range-thumb { pointer-events: auto; width: 14px; height: 14px; border: 2px solid #ffffff; border-radius: 50%; background: #7066e0; box-shadow: 0 0 3px rgba(0, 0, 0, 0.4); cursor: pointer; }',
    '.mvm-dual-range input[type="range"]:focus::-webkit-slider-thumb { box-shadow: 0 0 0 3px rgba(112, 102, 224, 0.5); }',
    '.mvm-dual-range input[type="range"]:focus::-moz-range-thumb { box-shadow: 0 0 0 3px rgba(112, 102, 224, 0.5); }',
    // -- toasts (also used before the takeover, so they use the .mvm-toast prefix like popups) --
    '.mvm-toast-stack {',
    '  position: fixed; right: 12px; bottom: 50px; z-index: 2147482000; display: flex; flex-direction: column; align-items: flex-end;',
    '  max-width: 92vw; pointer-events: none; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;',
    '}',
    '.mvm-toast {',
    '  box-sizing: border-box; display: flex; flex-flow: row nowrap; align-items: flex-start; width: 340px; max-width: 100%; margin-top: 8px;',
    '  padding: 10px 12px; border-left: 5px solid #29487D; border-radius: 5px; color: #212529; background: #ffffff; text-align: left;',
    '  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35); font-size: 14px; line-height: 1.35; pointer-events: auto;',
    '  opacity: 0; transform: translateX(120%); transition: transform 0.3s ease, opacity 0.3s ease;',
    '}',
    '.mvm-toast.visible { opacity: 1; transform: translateX(0); }',
    '.mvm-toast.info { border-left-color: #3fc3ee; }',
    '.mvm-toast.success { border-left-color: #2f9e44; }',
    '.mvm-toast.warning { border-left-color: #f59f00; }',
    '.mvm-toast.error { border-left-color: #e03131; }',
    '.mvm-toast-icon { flex-shrink: 0; line-height: 0; }',
    '.mvm-toast-icon .icon-tabler { width: 20px; height: 20px; }',
    '.mvm-toast.info .mvm-toast-icon { color: #3fc3ee; }',
    '.mvm-toast.success .mvm-toast-icon { color: #2f9e44; }',
    '.mvm-toast.warning .mvm-toast-icon { color: #f59f00; }',
    '.mvm-toast.error .mvm-toast-icon { color: #e03131; }',
    '.mvm-toast-body { flex: 1 1 auto; min-width: 0; margin: 0 8px; word-wrap: break-word; }',
    '.mvm-toast-title { font-weight: 600; }',
    '.mvm-toast-message:empty { display: none; }',
    '.mvm-toast-progress { height: 4px; margin-top: 6px; background: rgba(0, 0, 0, 0.15); }',
    '.mvm-toast-progress-fill { width: 0; height: 100%; background: #29487D; transition: width 0.2s linear; }',
    '.mvm-toast-actions { margin-top: 6px; }',
    '.mvm-toast-button { padding: 3px 10px; border: 0; border-radius: 4px; color: #ffffff; background: #d33; cursor: pointer; font-size: 13px; }',
    '.mvm-toast-close { flex-shrink: 0; padding: 0 2px; border: 0; color: inherit; background: transparent; cursor: pointer; font-size: 18px; line-height: 1; }',
    // -- local files panel (standalone page, before the reader exists) --
    '.mvm-upload {',
    '  position: fixed; left: 50%; top: 50%; z-index: 2147482500; box-sizing: border-box; width: 30em; max-width: 92vw; padding: 1.25em;',
    '  -webkit-transform: translate(-50%, -50%); transform: translate(-50%, -50%); border-radius: 5px; color: #545454; background: #ffffff;',
    '  box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); text-align: center; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 16px; line-height: 1.4;',
    '}',
    '.mvm-upload h2 { margin: 0 0 0.5em; font-size: 1.5em; color: #595959; }',
    '.mvm-upload-hint { margin: 0 0 1em; font-size: 0.9em; }',
    '.mvm-upload-option { display: flex; flex-flow: row wrap; align-items: center; justify-content: space-between; margin: 0.4em 0; padding: 0.5em; border: 1px solid #d9d9d9; border-radius: 4px; text-align: left; }',
    '.mvm-upload-option input { max-width: 60%; font-size: 0.85em; }',
    '.mvm-upload-status { min-height: 1.2em; margin-top: 0.6em; font-size: 0.9em; }',
    '.mvm-upload-status.error { color: #d33; }',
    '#StartMOV {',
    '  position: fixed; right: 10px; bottom: 40px; z-index: 2147483000; padding: 8px 16px;',
    '  border: 0; border-radius: 5px; font-size: 16px; color: #ffffff; background: #29487D; cursor: pointer;',
    '  box-shadow: 0 0 6px rgba(0, 0, 0, 0.4);',
    '}'
  ].join('\n');

  /**
   * Adds (or replaces) a <style> element with a given id in <head>.
   * @param {string} styleId - Unique id for the style element.
   * @param {string} cssText - CSS.
   */
  function installStyle(styleId, cssText) {
    safeRun('installStyle', function () {
      let style = document.getElementById(styleId);
      if (!style) {
        style = document.createElement('style');
        style.id = styleId;
        (document.head || document.documentElement).appendChild(style);
      }
      style.textContent = cssText;
    }, undefined, { styleId: styleId });
  }

  // ==========================================================================================
  // ==== 12. UI TEMPLATES =====================================================================
  // Functions that return HTML strings. They never attach events: every clickable element has
  // data-action="<name>" (and data-page when it concerns one page, data-value for a choice), and
  // section 18 handles all clicks, slider moves and select changes with ONE listener each.
  // Everything that comes from a site is passed through escapeHtml() first. Buttons, button
  // groups and per-page buttons are built from small tables (VIEW_MODE_BUTTONS, ZOOM_BUTTONS,
  // PAGE_BUTTONS) by the generic buildButtonHtml / buildButtonGroupHtml. The settings panel is
  // built from the SETTINGS_PANEL_LAYOUT table by small generic control builders (switch,
  // segmented control, slider with value, select, color swatches).
  // ==========================================================================================

  /**
   * HTML of one button. Every option is optional.
   * @param {string} action - Name in the ACTIONS table (section 16).
   * @param {string} label - Text (escaped). With `iconOnly` it is used as tooltip and aria-label.
   * @param {Object} [options]
   * @param {string} [options.id] - Element id.
   * @param {string} [options.title] - Tooltip (default: the label when iconOnly).
   * @param {number} [options.page] - Page number (data-page).
   * @param {string} [options.value] - Extra value for the action (data-value).
   * @param {boolean} [options.disabled] - Disabled look and behavior.
   * @param {boolean} [options.selected] - Highlighted (current choice in a group).
   * @param {string} [options.className] - Extra classes.
   * @param {string|string[]} [options.icon] - Icon name(s) from ICONS (several = CSS picks one).
   * @param {boolean} [options.iconOnly] - Show only the icon(s).
   * @param {string} [options.href] - Makes it a real link (<a href>) so the browser can open it in
   *   a new tab (middle click, Ctrl/Cmd click, context menu). Must already be safe and absolute
   *   (resolveChapterHref). Ignored when empty or when `disabled` is set (a <button> is drawn).
   * @returns {string}
   */
  function buildButtonHtml(action, label, options) {
    const settings = options || {};
    const icons = settings.icon ? [].concat(settings.icon) : [];
    const title = settings.title || (settings.iconOnly ? label : '');
    const classes = ['mvm-button'];
    if (settings.className) {
      classes.push(settings.className);
    }
    if (settings.iconOnly) {
      classes.push('icon-only');
    }
    if (settings.selected) {
      classes.push('selected');
    }
    if (settings.disabled) {
      classes.push('disabled');
    }
    const isLink = typeof settings.href === 'string' && settings.href !== '' && !settings.disabled;
    return [
      isLink ? '<a href="' + escapeHtml(settings.href) + '"' : '<button type="button"',
      ' class="', escapeHtml(classes.join(' ')), '" data-action="', escapeHtml(action), '"',
      settings.id ? ' id="' + escapeHtml(settings.id) + '"' : '',
      title ? ' title="' + escapeHtml(title) + '"' : '',
      settings.iconOnly ? ' aria-label="' + escapeHtml(label) + '"' : '',
      settings.page ? ' data-page="' + settings.page + '"' : '',
      settings.value !== undefined ? ' data-value="' + escapeHtml(settings.value) + '"' : '',
      settings.disabled ? ' disabled' : '',
      '>',
      icons.map(buildIconSvg).join(''),
      settings.iconOnly ? '' : '<span class="mvm-button-label">' + escapeHtml(label) + '</span>',
      isLink ? '</a>' : '</button>'
    ].join('');
  }

  /**
   * HTML of a group of buttons drawn as one segmented control. Generic: used for the view
   * modes and the zoom buttons; the applier marks the current choice with markSelectedButton().
   * @param {string} groupId - Element id of the group.
   * @param {Object[]} buttons - Each: { action, labelKey (locale key), icon, value, className }.
   * @param {string} [selectedValue] - data-value of the button to show as selected.
   * @returns {string}
   */
  function buildButtonGroupHtml(groupId, buttons, selectedValue) {
    return [
      '<div class="mvm-button-group" role="group" id="', escapeHtml(groupId), '">',
      buttons.map(function (button) {
        return buildButtonHtml(button.action, getLocaleString(button.labelKey), {
          icon: button.icon,
          iconOnly: true,
          value: button.value,
          className: button.className,
          selected: button.value !== undefined && button.value === selectedValue
        });
      }).join(''),
      '</div>'
    ].join('');
  }

  /**
   * The view modes, in menu order (2026 header View menu, NEW:9332-9427). `action` is the ACTIONS
   * name (section 16); `keybind` the keyboard action of DEFAULT_SETTINGS.keybinds ('' = none).
   * VIEW_MODE_LEFT = Left to Right (FluidLTR) and VIEW_MODE_RIGHT = Right to Left (FluidRTL):
   * the 2025 and 2026 versions had these two swapped (docs/05 section 3.1).
   */
  const VIEW_MODE_BUTTONS = [
    { value: 'WebComic', action: 'view-webcomic', labelKey: 'VIEW_MODE_WEBCOMIC', icon: 'spacingVertical', keybind: 'VIEW_MODE_WEBCOMIC', className: 'view-WebComic' },
    { value: 'Vertical', action: 'view-vertical', labelKey: 'VIEW_MODE_VERTICAL', icon: 'arrowAutofitDown', keybind: 'VIEW_MODE_VERTICAL', className: 'view-Vertical' },
    { value: 'FluidLTR', action: 'view-fluid-ltr', labelKey: 'VIEW_MODE_LEFT', icon: 'arrowAutofitRight', keybind: 'VIEW_MODE_LEFT', className: 'view-FluidLTR' },
    { value: 'FluidRTL', action: 'view-fluid-rtl', labelKey: 'VIEW_MODE_RIGHT', icon: 'arrowAutofitLeft', keybind: 'VIEW_MODE_RIGHT', className: 'view-FluidRTL' },
    { value: 'Book', action: 'view-book', labelKey: 'VIEW_MODE_BOOK', icon: 'bookArrowRight', keybind: '', className: 'view-Book' },
    { value: 'Manga', action: 'view-manga', labelKey: 'VIEW_MODE_MANGA', icon: 'bookArrowLeft', keybind: '', className: 'view-Manga' },
    { value: 'Gallery', action: 'view-gallery', labelKey: 'VIEW_MODE_GALLERY', icon: 'layoutDashboard', keybind: 'VIEW_MODE_GALLERY', className: 'view-Gallery' }
  ];

  /** Global zoom buttons (2026 header Zoom menu, NEW:9428-9494). value = zoom mode it selects. */
  const ZOOM_BUTTONS = [
    { action: 'enlarge', labelKey: 'ENLARGE', icon: 'zoomInArea' },
    { action: 'restore', labelKey: 'RESTORE', icon: 'zoomPan' },
    { action: 'reduce', labelKey: 'REDUCE', icon: 'zoomOutArea' },
    { action: 'fit-width', labelKey: 'FIT_WIDTH', icon: 'arrowAutofitWidth', value: 'width' },
    { action: 'fit-height', labelKey: 'FIT_HEIGHT', icon: 'arrowAutofitHeight', value: 'height' }
  ];

  /**
   * Per-page buttons, in display order (NEW:11378-11455). Each carries the page number, so an
   * action always works on the page whose button was clicked. Two icons = CSS shows one of them.
   */
  const PAGE_BUTTONS = [
    { action: 'page-bookmark-toggle', className: 'Bookmark', labelKey: 'BOOKMARK', icon: ['bookmark', 'bookmarkOff'] },
    { action: 'page-zoom-in', className: 'ZoomIn', labelKey: 'ZOOM_IN', icon: 'zoomIn' },
    { action: 'page-zoom-restore', className: 'ZoomRestore', labelKey: 'ZOOM_RESET', icon: 'zoomCancel' },
    { action: 'page-zoom-out', className: 'ZoomOut', labelKey: 'ZOOM_OUT', icon: 'zoomOut' },
    { action: 'page-zoom-height', className: 'ZoomHeight', labelKey: 'ZOOM_HEIGHT', icon: 'arrowAutofitHeight' },
    { action: 'page-zoom-width', className: 'ZoomWidth', labelKey: 'ZOOM_WIDTH', icon: 'arrowAutofitWidth' },
    { action: 'page-double-toggle', className: 'ToggleDoublePage', labelKey: 'DOUBLE_PAGE', icon: 'spacingHorizontal' },
    { action: 'page-hide-toggle', className: 'Hide', labelKey: 'HIDE', icon: ['eyeOff', 'eye'] },
    { action: 'reload-page', className: 'Reload', labelKey: 'RELOAD', icon: 'refresh' }
  ];

  /**
   * True when a chapter link is usable (not empty, not '#').
   * @param {string} url - Link from the site module.
   * @returns {boolean}
   */
  function isUsableLink(url) {
    return typeof url === 'string' && url.trim() !== '' && url.trim() !== '#';
  }

  /**
   * Absolute address of a chapter link (prev, next, series), resolved against the current page,
   * or '' when the link is missing, invalid or uses a protocol other than http(s) or file (a
   * `javascript:` link from a site is never followed). Shared by the header link buttons (so the
   * browser can open them in a new tab) and navigateTo() (keyboard and plain clicks).
   * @param {string} url - Link from the site module (absolute or relative).
   * @returns {string}
   */
  function resolveChapterHref(url) {
    if (!isUsableLink(url)) {
      return '';
    }
    let target = null;
    try {
      target = new URL(url.trim(), window.location.href);
    } catch (error) {
      logDebug('resolveChapterHref: invalid URL ignored', url);
      return '';
    }
    if (target.protocol !== 'http:' && target.protocol !== 'https:' && target.protocol !== 'file:') {
      logWarn('resolveChapterHref: blocked link with protocol ' + target.protocol, url);
      return '';
    }
    return target.href;
  }

  /**
   * Text of the header zoom label: 'Zoom: 100%', or the name of the fit mode.
   * @param {string} zoomMode - 'percent', 'width' or 'height'.
   * @param {number} zoomValue - Percent.
   * @returns {string}
   */
  function getZoomLabel(zoomMode, zoomValue) {
    if (zoomMode === 'width') {
      return getLocaleString('FIT_WIDTH');
    }
    if (zoomMode === 'height') {
      return getLocaleString('FIT_HEIGHT');
    }
    return getLocaleString('ZOOM') + ': ' + Math.round(zoomValue) + '%';
  }

  /**
   * <option> list of a go-to-page select, from `begin` to the last page.
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function buildPageOptionsHtml(manga) {
    return sequence(manga.pages, manga.begin).map(function (page) {
      return '<option value="' + page + '">' + page + '</option>';
    }).join('');
  }

  /**
   * HTML of the "menu" (burger) button that shows/hides the header in header mode 'click'.
   * @returns {string}
   */
  function buildMenuButtonHtml() {
    return buildButtonHtml('toggle-header', getLocaleString('FILE_MENU'), { id: 'menu', icon: 'menu2', iconOnly: true, className: getSetting('header') });
  }

  /**
   * HTML of the header buttons of the M3 features: bookmarks panel, comments panel, auto-scroll
   * (play/pause icon, CSS shows one), download (with a progress text). Download and comments
   * start disabled; sections 19 and 20 enable them when they are usable.
   * @returns {string}
   */
  function buildFeatureButtonsHtml() {
    const downloadLabel = getLocaleString('DOWNLOAD_ZIP');
    return [
      buildButtonHtml('open-bookmarks', getLocaleString('BOOKMARKS'), { id: 'bookmarks', icon: 'bookmarks', iconOnly: true }),
      buildButtonHtml('open-comments', getLocaleString('DISPLAY_COMMENTS'), { id: 'CommentsButton', icon: 'message', iconOnly: true, disabled: true }),
      buildButtonHtml('toggle-auto-scroll', getLocaleString('SCROLL_START'), { id: 'AutoScroll', icon: ['playerPlay', 'playerPause'], iconOnly: true }),
      '<button type="button" class="mvm-button icon-only disabled" id="download" data-action="download" disabled',
      ' title="', escapeHtml(downloadLabel), '" aria-label="', escapeHtml(downloadLabel), '">',
      buildIconSvg('fileDownload'), '<span class="mvm-download-progress"></span>',
      '</button>'
    ].join('');
  }

  /**
   * HTML of the header: toolbar (view modes, zoom, page controls), title and series link,
   * zoom label and slider, counters, go-to-page select, previous/next chapter buttons.
   * @param {Object} manga - The manga object from the site module.
   * @returns {string}
   */
  function buildHeaderHtml(manga) {
    const zoomMode = getSetting('zoomMode');
    const zoomValue = getSetting('zoomValue');
    return [
      '<header id="Header" class="', escapeHtml(getSetting('header')), '">',
      '<div id="Toolbar">',
      buildButtonGroupHtml('ViewModeGroup', VIEW_MODE_BUTTONS, getSetting('viewMode')),
      buildButtonGroupHtml('ZoomGroup', ZOOM_BUTTONS, zoomMode),
      buildButtonHtml('toggle-page-controls', getLocaleString('TOGGLE_CONTROLS'), { id: 'pageControls', icon: 'listNumbers', iconOnly: true, selected: getSetting('hidePageControls') === true }),
      buildButtonHtml('open-settings', getLocaleString('SETTINGS'), { id: 'settings', icon: 'settings', iconOnly: true }),
      buildButtonHtml('open-keybindings', getLocaleString('KEYBINDINGS'), { id: 'keybindings', icon: 'keyboard', iconOnly: true }),
      buildFeatureButtonsHtml(),
      '</div>',
      '<div class="ViewerTitle">',
      '<h1 id="MangaTitle" title="', escapeHtml(manga.title), '">', escapeHtml(manga.title), '</h1>',
      buildButtonHtml('go-series', getLocaleString('RETURN_CHAPTER_LIST'), { id: 'series', icon: 'booksReturn', href: resolveChapterHref(manga.series), disabled: resolveChapterHref(manga.series) === '' }),
      '</div>',
      '<div id="ZoomControl">',
      '<output id="ZoomValue" for="ZoomSlider">', escapeHtml(getZoomLabel(zoomMode, zoomValue)), '</output>',
      '<input type="range" id="ZoomSlider" data-action="zoom-slider" min="', parseInt(getSetting('minZoom'), 10) || 1, '" max="', CONFIG.zoomSliderMax, '" value="', Math.round(zoomValue), '" aria-label="', escapeHtml(getLocaleString('ZOOM')), '" />',
      '</div>',
      '<div id="ChapterNavigation">',
      '<div id="Counters">', escapeHtml(getLocaleString('PAGES_LOADED')), ': <span class="mvm-loaded-count">0</span> / <span class="mvm-total-count">0</span></div>',
      '<div id="GoToPageControl"><label for="GoToPage">', escapeHtml(getLocaleString('GO_TO_PAGE')), ': </label>',
      '<select id="GoToPage" data-action="go-to-page">', buildPageOptionsHtml(manga), '</select></div>',
      '<div id="ChapterControl">',
      buildButtonHtml('go-previous-chapter', getLocaleString('BUTTON_PREVIOUS'), { id: 'prev', icon: 'arrowBigLeft', title: getLocaleString('PREVIOUS_CHAPTER'), href: resolveChapterHref(manga.prev), disabled: resolveChapterHref(manga.prev) === '' }),
      buildButtonHtml('go-next-chapter', getLocaleString('BUTTON_NEXT'), { id: 'next', icon: 'arrowBigRight', title: getLocaleString('NEXT_CHAPTER'), href: resolveChapterHref(manga.next), disabled: resolveChapterHref(manga.next) === '' }),
      '</div>',
      '</div>',
      '</header>'
    ].join('');
  }

  /**
   * HTML of one page: control buttons, page number, an <img> without src (filled by section 15),
   * then the separator line shown only in Vertical mode ("[ n / last ]" or "[ End ]").
   * @param {number} page - Page number (1-based).
   * @param {number} lastPage - Last page number of the chapter.
   * @returns {string}
   */
  function buildPageHtml(page, lastPage) {
    return [
      '<div class="MangaPage" id="Page', page, '" data-page="', page, '">',
      '<div class="PageFunctions">',
      PAGE_BUTTONS.map(function (button) {
        return buildButtonHtml(button.action, getLocaleString(button.labelKey), { page: page, icon: button.icon, iconOnly: true, className: button.className });
      }).join(''),
      '<span class="PageIndex">', page, '</span>',
      '</div>',
      '<div class="PageContent">',
      '<img class="PageImg" id="PageImg', page, '" data-page="', page, '" alt="Page ', page, '" />',
      '</div>',
      '</div>',
      '<div class="separator">[ ', page === lastPage ? escapeHtml(getLocaleString('END')) : page + ' / ' + lastPage, ' ]</div>'
    ].join('');
  }

  /**
   * HTML of the reader area with every page from `begin` to `manga.pages`.
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function buildChapterHtml(manga) {
    return [
      '<main id="Chapter">',
      sequence(manga.pages, manga.begin).map(function (page) {
        return buildPageHtml(page, manga.pages);
      }).join(''),
      '</main>'
    ].join('');
  }

  /**
   * HTML of the thumbnails bar (navbar). Always present; the `navbar` setting only changes its
   * classes. Thumbnail images get their src when the page has loaded (section 14 "Navbar").
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function buildNavbarHtml(manga) {
    return [
      '<nav id="Navigation" class="disabled">',
      '<div id="NavigationCounters">', buildIconSvg('category'),
      '<span><span class="mvm-loaded-count">0</span> / <span class="mvm-total-count">0</span></span>',
      '<span>', escapeHtml(getLocaleString('PAGES_LOADED')), ': <span class="mvm-current-page">', manga.begin, '</span></span>',
      '</div>',
      '<div id="Thumbnails">',
      sequence(manga.pages, manga.begin).map(function (page) {
        return [
          '<figure class="Thumbnail" id="Thumbnail', page, '" data-action="go-to-page" data-page="', page, '" role="button" tabindex="0" title="', escapeHtml(getLocaleString('GO_TO_PAGE') + ' ' + page), '">',
          '<img class="ThumbnailImg" id="ThumbnailImg', page, '" alt="" />',
          '<figcaption class="ThumbnailIndex">', page, '</figcaption>',
          '</figure>'
        ].join('');
      }).join(''),
      '</div>',
      '</nav>'
    ].join('');
  }

  /**
   * HTML of the pagination widgets: the bottom slider bar and the two side arrows. Always present;
   * the `pagination` setting decides what is visible (root classes pagination-*).
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function buildPaginationHtml(manga) {
    return [
      '<div id="Pagination">',
      '<div class="slider-pagination">',
      buildButtonHtml('go-previous-chapter', getLocaleString('PREVIOUS_CHAPTER'), { icon: 'arrowBigLeft', iconOnly: true, href: resolveChapterHref(manga.prev), disabled: resolveChapterHref(manga.prev) === '' }),
      buildButtonHtml('previous-page', getLocaleString('BUTTON_PREVIOUS'), { id: 'PaginationPrevious', icon: 'chevronLeft', iconOnly: true }),
      '<input type="range" id="PaginationSlider" data-action="go-to-page" min="', manga.begin, '" max="', manga.pages, '" value="', manga.begin, '" aria-label="', escapeHtml(getLocaleString('GO_TO_PAGE')), '" />',
      buildButtonHtml('next-page', getLocaleString('BUTTON_NEXT'), { id: 'PaginationNext', icon: 'chevronRight', iconOnly: true }),
      buildButtonHtml('go-next-chapter', getLocaleString('NEXT_CHAPTER'), { icon: 'arrowBigRight', iconOnly: true, href: resolveChapterHref(manga.next), disabled: resolveChapterHref(manga.next) === '' }),
      '</div>',
      '<button type="button" class="side-arrow left" id="SideArrowLeft" data-action="side-arrow-previous" aria-label="', escapeHtml(getLocaleString('BUTTON_PREVIOUS')), '">', buildIconSvg('chevronLeft'), '</button>',
      '<button type="button" class="side-arrow right" id="SideArrowRight" data-action="side-arrow-next" aria-label="', escapeHtml(getLocaleString('BUTTON_NEXT')), '">', buildIconSvg('chevronRight'), '</button>',
      '</div>'
    ].join('');
  }

  // ---- Settings controls (generic builders) ----
  // Every control carries data-action="setting-change" and data-setting="<setting key>", so ONE
  // delegated listener (section 18) handles them all and the action (section 16) saves the value
  // with saveSetting(). The screen then follows through SETTING_APPLIERS (section 14), and
  // syncSettingsControls (section 14) keeps the controls in step with the STORED values.

  /**
   * The choices of every setting that has a fixed list of values. Used by the segmented controls
   * and selects of the settings panel AND to validate a value before it is saved (section 16).
   * A choice is { value, labelKey } or { value, label } (literal text), optionally an icon.
   * `showValue` appends the stored value to the label, e.g. "Slowly (Safe)".
   */
  const SETTING_CHOICES = {
    colorScheme: [
      { value: 'dark', labelKey: 'COLOR_SCHEME_DARK', icon: 'moon' },
      { value: 'light', labelKey: 'COLOR_SCHEME_LIGHT', icon: 'sun' }
    ],
    deviceMode: [
      { value: 'auto', labelKey: 'DEVICE_MODE_AUTO' },
      { value: 'desktop', labelKey: 'DEVICE_MODE_DESKTOP' },
      { value: 'tablet', labelKey: 'DEVICE_MODE_TABLET' },
      { value: 'mobile', labelKey: 'DEVICE_MODE_MOBILE' }
    ],
    loadMode: [
      { value: 'wait', labelKey: 'LOAD_MODE_NORMAL' },
      { value: 'always', labelKey: 'LOAD_MODE_ALWAYS' },
      { value: 'never', labelKey: 'LOAD_MODE_NEVER' }
    ],
    loadSpeed: [
      { value: 'Safe', labelKey: 'SLOWLY', showValue: true },
      { value: 'Standard', labelKey: 'NORMAL', showValue: true },
      { value: 'Faster', labelKey: 'FAST', showValue: true },
      { value: 'Extreme', labelKey: 'EXTREME', showValue: true },
      { value: 'All', labelKey: 'ALL_PAGES', showValue: true }
    ],
    lazyLoadRule: [
      { value: 'distance', labelKey: 'LAZY_LOAD_RULE_DISTANCE' },
      { value: 'lookAhead', labelKey: 'LAZY_LOAD_RULE_LOOKAHEAD' },
      { value: 'both', labelKey: 'LAZY_LOAD_RULE_BOTH' }
    ],
    zoomMode: [
      { value: 'percent', labelKey: 'PERCENT', icon: 'zoomPan' },
      { value: 'width', labelKey: 'FIT_WIDTH', icon: 'arrowAutofitWidth' },
      { value: 'height', labelKey: 'FIT_HEIGHT', icon: 'arrowAutofitHeight' }
    ],
    header: [
      { value: 'hover', labelKey: 'HEADER_HOVER' },
      { value: 'scroll', labelKey: 'HEADER_SCROLL' },
      { value: 'click', labelKey: 'HEADER_CLICK' },
      { value: 'fixed', labelKey: 'HEADER_FIXED' },
      { value: 'simple', labelKey: 'HEADER_SIMPLE' }
    ],
    navbar: [
      { value: 'bottom', labelKey: 'NAVBAR_BOTTOM' },
      { value: 'left', labelKey: 'NAVBAR_LEFT' },
      { value: 'right', labelKey: 'NAVBAR_RIGHT' },
      { value: 'disabled', labelKey: 'NAVBAR_DISABLED' }
    ],
    pagination: [
      { value: 'disabled', labelKey: 'PAGINATION_DISABLED' },
      { value: 'slider', labelKey: 'PAGINATION_SLIDER' },
      { value: 'side-arrows', labelKey: 'PAGINATION_ARROWS' },
      { value: 'both', labelKey: 'PAGINATION_BOTH' }
    ],
    viewMode: VIEW_MODE_BUTTONS.map(function (button) {
      return { value: button.value, labelKey: button.labelKey, icon: button.icon };
    }),
    locale: LOCALES.map(function (language) {
      return { value: language.ID, label: language.NAME };
    })
  };

  /**
   * Range of every slider setting. `max` may be a function (computed when the panel is built).
   * Ranges from the 2026 settings panel (docs/04 section 2); the auto-scroll maximum depends on
   * the window height (NEW:10429-10431). minZoom and zoomStep use the ranges their labels state
   * in every language ("between 30 and 100", "between 5 and 50"); the 2026 sliders were 25-100
   * and 10-50, so text and control disagreed (docs/05 section 4.1 #17, fixed in M4).
   */
  const SETTING_SLIDERS = {
    lazyStart: { min: 5, max: 100, step: 5, unit: '' },
    zoomValue: { min: 5, max: 200, step: 5, unit: '%' },
    minZoom: { min: 30, max: 100, step: 5, unit: '%' },
    zoomStep: { min: 5, max: 50, step: 5, unit: '%' },
    scrollHeight: {
      min: 1,
      max: function () {
        return getAutoScrollMaximum(window.innerHeight);
      },
      step: 1,
      unit: 'px'
    }
  };

  /**
   * Highest auto-scroll speed for a window height (pure; NEW:7564, NEW:10431).
   * @param {number} viewportHeight - Window height in pixels.
   * @returns {number}
   */
  function getAutoScrollMaximum(viewportHeight) {
    return Math.ceil(viewportHeight / 200) * 100;
  }

  /**
   * Text of a choice: its literal label, or its localized label (plus the value when asked).
   * @param {{value: string, label: (string|undefined), labelKey: (string|undefined), showValue: (boolean|undefined)}} choice
   * @returns {string}
   */
  function getChoiceLabel(choice) {
    if (choice.label !== undefined) {
      return String(choice.label);
    }
    const text = getLocaleString(choice.labelKey);
    return choice.showValue ? text + ' (' + choice.value + ')' : text;
  }

  /**
   * The ' data-setting="key"' attribute, or '' when the control is not bound to a setting.
   * @param {string} settingKey - Setting name or ''.
   * @returns {string}
   */
  function buildSettingAttribute(settingKey) {
    return settingKey ? ' data-setting="' + escapeHtml(settingKey) + '"' : '';
  }

  /**
   * HTML of an on/off switch (a real checkbox, styled).
   * @param {string} settingKey - Boolean setting.
   * @param {boolean} checked - Current value.
   * @param {string} label - Accessible name.
   * @returns {string}
   */
  function buildSwitchHtml(settingKey, checked, label) {
    return [
      '<label class="mvm-switch">',
      '<input type="checkbox" data-action="setting-change"', buildSettingAttribute(settingKey), checked === true ? ' checked' : '', ' aria-label="', escapeHtml(label), '" />',
      '<span class="mvm-switch-track"></span>',
      '</label>'
    ].join('');
  }

  /**
   * HTML of a segmented control: one button per choice, the current one selected.
   * @param {string} settingKey - Setting name ('' for a control that is not a setting).
   * @param {Object[]} choices - See SETTING_CHOICES.
   * @param {string} selectedValue - Current value.
   * @param {Object} [options]
   * @param {string} [options.action='setting-change'] - ACTIONS name.
   * @param {boolean} [options.iconOnly] - Show only icons (label as tooltip).
   * @param {string} [options.label] - Accessible name of the group.
   * @returns {string}
   */
  function buildSegmentedHtml(settingKey, choices, selectedValue, options) {
    const settings = options || {};
    return [
      '<div class="mvm-segmented" role="radiogroup"', settings.label ? ' aria-label="' + escapeHtml(settings.label) + '"' : '', '>',
      choices.map(function (choice) {
        const text = getChoiceLabel(choice);
        const selected = String(choice.value) === String(selectedValue);
        return [
          '<button type="button" class="mvm-segment', selected ? ' selected' : '', '" role="radio" aria-checked="', selected ? 'true' : 'false', '"',
          ' data-action="', escapeHtml(settings.action || 'setting-change'), '"', buildSettingAttribute(settingKey),
          ' data-value="', escapeHtml(choice.value), '" title="', escapeHtml(text), '"', settings.iconOnly ? ' aria-label="' + escapeHtml(text) + '"' : '', '>',
          choice.icon ? buildIconSvg(choice.icon) : '',
          settings.iconOnly && choice.icon ? '' : '<span>' + escapeHtml(text) + '</span>',
          '</button>'
        ].join('');
      }).join(''),
      '</div>'
    ].join('');
  }

  /**
   * HTML of a <select> with one option per choice.
   * @param {string} settingKey - Setting name.
   * @param {Object[]} choices - See SETTING_CHOICES.
   * @param {string} selectedValue - Current value.
   * @param {string} label - Accessible name.
   * @returns {string}
   */
  function buildSelectHtml(settingKey, choices, selectedValue, label) {
    return [
      '<select class="mvm-select" data-action="setting-change"', buildSettingAttribute(settingKey), ' aria-label="', escapeHtml(label), '">',
      choices.map(function (choice) {
        const selected = String(choice.value) === String(selectedValue);
        return '<option value="' + escapeHtml(choice.value) + '"' + (selected ? ' selected' : '') + '>' + escapeHtml(getChoiceLabel(choice)) + '</option>';
      }).join(''),
      '</select>'
    ].join('');
  }

  /**
   * Id of the value badge (<output>) of a slider setting.
   * @param {string} settingKey - Setting name.
   * @returns {string}
   */
  function getSliderOutputId(settingKey) {
    return 'SettingValue_' + settingKey;
  }

  /**
   * HTML of a slider with its value badge. The value is saved when the user releases the slider
   * (data-commit="change"); while dragging only the badge changes (section 18).
   * @param {string} settingKey - Number setting.
   * @param {{min: number, max: (number|function(): number), step: number, unit: string}} spec - Range.
   * @param {number} value - Current value.
   * @param {string} label - Accessible name.
   * @returns {{badge: string, input: string}} The two parts (the row puts the badge next to the label).
   */
  function buildSliderParts(settingKey, spec, value, label) {
    const maximum = typeof spec.max === 'function' ? spec.max() : spec.max;
    const outputId = getSliderOutputId(settingKey);
    return {
      badge: '<output class="RangeValue" id="' + escapeHtml(outputId) + '">' + escapeHtml(String(value) + spec.unit) + '</output>',
      input: [
        '<input type="range" data-action="setting-change" data-commit="change"', buildSettingAttribute(settingKey),
        ' data-output="', escapeHtml(outputId), '" data-unit="', escapeHtml(spec.unit), '"',
        ' min="', spec.min, '" max="', maximum, '" step="', spec.step, '" value="', escapeHtml(value), '" aria-label="', escapeHtml(label), '" />'
      ].join('')
    };
  }

  /**
   * HTML of one color swatch button that picks a theme color.
   * @param {string} color - '#RRGGBB'.
   * @param {string} title - Tooltip.
   * @param {string} currentColor - Current theme color ('#RRGGBB'), for the selected mark.
   * @returns {string}
   */
  function buildSwatchHtml(color, title, currentColor) {
    const selected = String(color).toUpperCase() === String(currentColor).toUpperCase();
    return [
      '<button type="button" class="mvm-swatch', selected ? ' selected' : '', '" data-action="setting-change" data-setting="theme"',
      ' data-value="', escapeHtml(color), '" title="', escapeHtml(title), '" aria-label="', escapeHtml(title), '" style="background-color: ', escapeHtml(color), ';"></button>'
    ].join('');
  }

  /**
   * The 11 shades generated from the current theme color (click one to use it).
   * @param {string} theme - Value of the `theme` setting.
   * @returns {string}
   */
  function buildThemeShadesHtml(theme) {
    const current = resolveThemeColor(theme);
    return generateColorGradient(current).map(function (color, index) {
      return buildSwatchHtml(color, PALETTE_SHADES[index] + ' ' + color, current);
    }).join('');
  }

  /** Palette families shown in the "Color Hue & Color Shade" grid (NEW:10496-10502: no grays). */
  const THEME_GRID_EXCLUDED_FAMILIES = ['dark', 'gray', 'zinc', 'neutral', 'stone'];

  /** Shades shown in that grid (NEW: 200 to 950). */
  const THEME_GRID_SHADES = [200, 300, 400, 500, 600, 700, 800, 900, 950];

  /**
   * Theme color picker: native color input, shades of the current color, recommended colors and
   * a collapsible grid of palette colors. Every swatch is a setting-change button for `theme`.
   * @param {string} theme - Value of the `theme` setting.
   * @returns {string}
   */
  function buildThemePickerHtml(theme) {
    const current = resolveThemeColor(theme);
    const recommended = Object.keys(RECOMMENDED_THEME_COLORS).map(function (name) {
      return buildSwatchHtml(RECOMMENDED_THEME_COLORS[name], name, current);
    }).join('');
    const grid = Object.keys(COLOR_PALETTES).filter(function (family) {
      return THEME_GRID_EXCLUDED_FAMILIES.indexOf(family) === -1;
    }).map(function (family) {
      return '<div class="mvm-palette-row">' + THEME_GRID_SHADES.map(function (shade) {
        return buildSwatchHtml(COLOR_PALETTES[family][shade], family + ' ' + shade, current);
      }).join('') + '</div>';
    }).join('');
    return [
      '<div class="mvm-theme-picker">',
      '<input type="color" id="ThemeColorInput" data-action="setting-change" data-setting="theme" value="', escapeHtml(current.toLowerCase()), '" aria-label="', escapeHtml(getLocaleString('THEME_COLOR')), '" />',
      '<div id="ThemeShades" class="mvm-swatches">', buildThemeShadesHtml(theme), '</div>',
      '<div class="mvm-swatches mvm-recommended">', recommended, '</div>',
      '<details class="mvm-palettes"><summary>', escapeHtml(getLocaleString('THEME_HUE') + ' / ' + getLocaleString('THEME_SHADE')), '</summary>', grid, '</details>',
      '</div>'
    ].join('');
  }

  /**
   * The settings panel layout: groups of rows (General, Loading, Zoom, Others). A row is
   * { type, setting, labelKey } plus optional `hideOnMobile` and `visibleWhen` ("key" = shown
   * while that boolean setting is true, "key=value" = shown while the setting has that value).
   * Types: 'scope', 'select', 'segmented', 'switch', 'slider', 'theme'.
   */
  const SETTINGS_PANEL_LAYOUT = [
    {
      id: 'SettingsPanelGeneral',
      labelKey: 'GENERAL',
      rows: [
        { type: 'scope', setting: '', labelKey: 'SCOPE' },
        { type: 'select', setting: 'locale', labelKey: 'LANGUAGE' },
        { type: 'segmented', setting: 'colorScheme', labelKey: 'COLOR_SCHEME' },
        { type: 'theme', setting: 'theme', labelKey: 'THEME_COLOR' },
        { type: 'segmented', setting: 'deviceMode', labelKey: 'DEVICE_MODE' },
        { type: 'select', setting: 'loadMode', labelKey: 'DEFAULT_LOAD_MODE' }
      ]
    },
    {
      id: 'SettingsPanelLoading',
      labelKey: 'LOADING',
      rows: [
        { type: 'select', setting: 'loadSpeed', labelKey: 'LOAD_SPEED' },
        { type: 'switch', setting: 'lazyLoadImages', labelKey: 'LAZY_LOAD_IMAGES_ENABLE', hideOnMobile: true },
        { type: 'slider', setting: 'lazyStart', labelKey: 'LAZY_LOAD_IMAGES', visibleWhen: 'lazyLoadImages', hideOnMobile: true },
        { type: 'segmented', setting: 'lazyLoadRule', labelKey: 'LAZY_LOAD_RULE', visibleWhen: 'lazyLoadImages', hideOnMobile: true },
        { type: 'switch', setting: 'downloadZip', labelKey: 'DOWNLOAD_IMAGES', hideOnMobile: true }
      ]
    },
    {
      id: 'SettingsPanelZoom',
      labelKey: 'ZOOM',
      hideOnMobile: true,
      rows: [
        { type: 'segmented', setting: 'zoomMode', labelKey: 'DEFAULT_ZOOM_MODE' },
        { type: 'slider', setting: 'zoomValue', labelKey: 'DEFAULT_ZOOM', visibleWhen: 'zoomMode=percent' },
        { type: 'slider', setting: 'minZoom', labelKey: 'MINIMUM_ZOOM' },
        { type: 'slider', setting: 'zoomStep', labelKey: 'ZOOM_STEP' },
        { type: 'switch', setting: 'fitWidthIfOversize', labelKey: 'FIT_WIDTH_OVERSIZED' }
      ]
    },
    {
      id: 'SettingsPanelOthers',
      labelKey: 'OTHERS',
      rows: [
        { type: 'segmented', setting: 'header', labelKey: 'HEADER_TYPE', hideOnMobile: true },
        { type: 'segmented', setting: 'navbar', labelKey: 'NAVBAR_TYPE', hideOnMobile: true },
        { type: 'segmented', setting: 'pagination', labelKey: 'PAGINATION_TYPE' },
        { type: 'switch', setting: 'hidePageControls', labelKey: 'HIDE_CONTROLS' },
        { type: 'slider', setting: 'scrollHeight', labelKey: 'AUTO_SCROLL_HEIGHT', hideOnMobile: true },
        { type: 'switch', setting: 'enableComments', labelKey: 'ENABLE_COMMENTS' },
        { type: 'segmented', setting: 'viewMode', labelKey: 'DEFAULT_VIEW_MODE', iconOnly: true }
      ]
    }
  ];

  /**
   * True when a row with a `visibleWhen` condition must be shown (pure).
   * @param {string} condition - '' (always), 'key' (boolean true) or 'key=value'.
   * @param {function(string): *} readSetting - Returns a setting value.
   * @returns {boolean}
   */
  function isRowConditionMet(condition, readSetting) {
    if (!condition) {
      return true;
    }
    const parts = condition.split('=');
    const value = readSetting(parts[0]);
    if (parts.length === 1) {
      return value === true;
    }
    return String(value) === parts[1];
  }

  /**
   * The control HTML of a settings row, by type. The value shown is the STORED value (what a
   * reload shows), not a change made for this page only from the header.
   * @param {Object} row - A row of SETTINGS_PANEL_LAYOUT.
   * @param {string} label - Localized row label.
   * @returns {{control: string, badge: string}}
   */
  function buildSettingsControlHtml(row, label) {
    const value = row.setting ? getStoredSetting(row.setting) : '';
    if (row.type === 'scope') {
      const scopeChoices = [{ value: 'global', labelKey: 'GLOBAL' }, { value: 'local', label: window.location.hostname }];
      return { control: buildSegmentedHtml('', scopeChoices, isLocalScopeEnabled() ? 'local' : 'global', { action: 'settings-scope', label: label }), badge: '' };
    }
    if (row.type === 'select') {
      return { control: buildSelectHtml(row.setting, SETTING_CHOICES[row.setting], value, label), badge: '' };
    }
    if (row.type === 'segmented') {
      return { control: buildSegmentedHtml(row.setting, SETTING_CHOICES[row.setting], value, { label: label, iconOnly: row.iconOnly === true }), badge: '' };
    }
    if (row.type === 'switch') {
      return { control: buildSwitchHtml(row.setting, value === true, label), badge: '' };
    }
    if (row.type === 'slider') {
      const parts = buildSliderParts(row.setting, SETTING_SLIDERS[row.setting], value, label);
      return { control: parts.input, badge: parts.badge };
    }
    if (row.type === 'theme') {
      return { control: buildThemePickerHtml(value), badge: '' };
    }
    logWarn('settings panel: unknown row type', row.type);
    return { control: '', badge: '' };
  }

  /**
   * HTML of one settings row: label (with the slider value badge) and its control.
   * @param {Object} row - A row of SETTINGS_PANEL_LAYOUT.
   * @returns {string}
   */
  function buildSettingsRowHtml(row) {
    const label = getLocaleString(row.labelKey);
    const parts = buildSettingsControlHtml(row, label);
    const classes = ['ControlLabel', 'row-' + (row.setting || row.type)];
    if (row.type === 'slider') {
      classes.push('mvm-slider-row');
    }
    if (row.hideOnMobile) {
      classes.push('hide-on-mobile');
    }
    if (row.visibleWhen && !isRowConditionMet(row.visibleWhen, getStoredSetting)) {
      classes.push('mvm-row-hidden');
    }
    return [
      '<div class="', classes.join(' '), '"', row.visibleWhen ? ' data-visible-when="' + escapeHtml(row.visibleWhen) + '"' : '', '>',
      '<span class="ControlLabelText">', escapeHtml(label), parts.badge, '</span>',
      parts.control,
      '</div>'
    ].join('');
  }

  /**
   * HTML of the settings panel body: one fieldset per group.
   * @returns {string}
   */
  function buildSettingsPanelBodyHtml() {
    return SETTINGS_PANEL_LAYOUT.map(function (group) {
      return [
        '<fieldset id="', group.id, '"', group.hideOnMobile ? ' class="hide-on-mobile"' : '', '>',
        '<legend>', escapeHtml(getLocaleString(group.labelKey)), '</legend>',
        group.rows.map(buildSettingsRowHtml).join(''),
        '</fieldset>'
      ].join('');
    }).join('');
  }

  /**
   * HTML of the settings panel (left drawer): title, close button, reset button, groups.
   * @returns {string}
   */
  function buildSettingsPanelHtml() {
    return [
      '<aside id="SettingsPanel" class="mvm-panel left" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('SETTINGS')), '">',
      '<div class="mvm-panel-header">',
      '<h2>', escapeHtml(getLocaleString('SETTINGS')), '</h2>',
      buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
      '</div>',
      buildButtonHtml('reset-settings', getLocaleString('BUTTON_RESET_SETTINGS'), { id: 'ResetSettings', icon: 'settingsOff' }),
      '<div id="SettingsPanelBody">', buildSettingsPanelBodyHtml(), '</div>',
      '</aside>'
    ].join('');
  }

  /**
   * HTML of the keybindings panel (right drawer) WITHOUT its list: section 17 fills
   * #KeybindingsActions and #KeybindingsBody. The rules text is the script's own locale HTML.
   * @returns {string}
   */
  function buildKeybindingsPanelHtml() {
    return [
      '<aside id="KeybindingsPanel" class="mvm-panel right" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('KEYBINDINGS')), '">',
      '<div class="mvm-panel-header">',
      buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
      '<h2>', escapeHtml(getLocaleString('KEYBINDINGS')), '</h2>',
      '<div id="KeybindingsActions" class="mvm-panel-actions"></div>',
      '</div>',
      '<div id="KeybindingsBody"></div>',
      '<div id="HotKeysRules">', getLocaleString('KEYBIND_RULES'), '</div>',
      '</aside>'
    ].join('');
  }

  /**
   * HTML of the dark overlay behind open panels (a click on it closes them).
   * @returns {string}
   */
  function buildOverlayHtml() {
    return '<div id="Overlay" data-action="close-panels"></div>';
  }

  /**
   * A link target that is safe to put in href: http(s) and file URLs only, else '#'.
   * @param {string} url - Stored URL (bookmarks).
   * @returns {string}
   */
  function getSafeLinkHref(url) {
    return typeof url === 'string' && /^(https?|file):/i.test(url.trim()) ? url.trim() : '#';
  }

  /**
   * HTML of the bookmarks list (2026 bookmarks dialog rows, NEW:9585-9658): name, URL (opens in
   * a new tab), date, "Page: N", an open button (new tab) and a delete button. The row of the
   * chapter open in this tab is marked "current". Empty list: "List Empty".
   * @param {Object[]} bookmarks - Stored bookmarks [{name, url, page, date}].
   * @param {string} currentUrl - URL of this chapter.
   * @returns {string}
   */
  function buildBookmarkListHtml(bookmarks, currentUrl) {
    const list = Array.isArray(bookmarks) ? bookmarks.filter(function (bookmark) {
      return bookmark && typeof bookmark.url === 'string';
    }) : [];
    if (list.length === 0) {
      return '<div class="mvm-list-empty">' + escapeHtml(getLocaleString('LIST_EMPTY')) + '</div>';
    }
    return [
      '<div class="mvm-bookmark-list">',
      list.map(function (bookmark) {
        const href = getSafeLinkHref(bookmark.url);
        return [
          '<div class="mvm-bookmark-item', bookmark.url === currentUrl ? ' current' : '', '">',
          '<div class="mvm-bookmark-info">',
          '<div class="mvm-bookmark-name" title="', escapeHtml(bookmark.name), '">', escapeHtml(bookmark.name || bookmark.url), '</div>',
          '<a class="mvm-bookmark-url" href="', escapeHtml(href), '" target="_blank" rel="noopener noreferrer" title="', escapeHtml(bookmark.url), '">', escapeHtml(bookmark.url), '</a>',
          '</div>',
          '<div class="mvm-bookmark-details">',
          '<div>', escapeHtml(bookmark.date || ''), '</div>',
          '<div>', escapeHtml(getLocaleString('BOOKMARK_PAGE')), ': ', escapeHtml(bookmark.page), '</div>',
          '</div>',
          '<div class="mvm-bookmark-actions">',
          '<a class="mvm-button icon-only" href="', escapeHtml(href), '" target="_blank" rel="noopener noreferrer" title="', escapeHtml(getLocaleString('BOOKMARK_OPEN')), '" aria-label="', escapeHtml(getLocaleString('BOOKMARK_OPEN')), '">', buildIconSvg('externalLink'), '</a>',
          buildButtonHtml('bookmark-remove', getLocaleString('BOOKMARK_DELETE'), { icon: 'trash', iconOnly: true, value: bookmark.url }),
          '</div>',
          '</div>'
        ].join('');
      }).join(''),
      '</div>'
    ].join('');
  }

  /**
   * HTML of the bookmarks panel (right drawer): title, bookmark toggle for the current page,
   * close button, "remove all" button and the list (section 14 redraws #BookmarksList).
   * @returns {string}
   */
  function buildBookmarksPanelHtml() {
    return [
      '<aside id="BookmarksPanel" class="mvm-panel right" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('BOOKMARKS')), '">',
      '<div class="mvm-panel-header">',
      buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
      '<h2>', escapeHtml(getLocaleString('BOOKMARKS')), '</h2>',
      buildButtonHtml('bookmark-toggle-current', getLocaleString('BOOKMARK_TOGGLE_CURRENT'), { icon: ['bookmark', 'bookmarkOff'], iconOnly: true, className: 'Bookmark' }),
      '</div>',
      buildButtonHtml('bookmarks-clear', getLocaleString('BOOKMARKS_CLEAR'), { id: 'BookmarksClear', icon: 'trash' }),
      '<div id="BookmarksList">', buildBookmarkListHtml(getSetting('bookmarks'), window.location.href), '</div>',
      '</aside>'
    ].join('');
  }

  /**
   * HTML of the comments panel (2025 version, OLD:3759-3780): title, light/dark switch of the
   * comments area, close button, and the empty #CommentsArea (section 20 moves the captured
   * comments element into it).
   * @returns {string}
   */
  function buildCommentsPanelHtml() {
    return [
      '<aside id="CommentsPanel" class="mvm-panel right" tabindex="-1" role="dialog" aria-label="', escapeHtml(getLocaleString('COMMENTS')), '">',
      '<div class="mvm-panel-header">',
      buildButtonHtml('close-panels', getLocaleString('CLOSE'), { icon: 'x', iconOnly: true, className: 'mvm-panel-close' }),
      '<h2>', escapeHtml(getLocaleString('COMMENTS')), '</h2>',
      buildButtonHtml('comments-color-scheme', getLocaleString('COMMENTS_COLOR_SCHEME'), { icon: ['sun', 'moon'], iconOnly: true }),
      '</div>',
      '<div id="CommentsArea" class="', getSetting('colorScheme') === 'light' ? 'light' : 'dark', '"></div>',
      '</aside>'
    ].join('');
  }

  /**
   * HTML of the toast stack. It lives INSIDE the reader root: the 2026 version appended it to
   * <body>, where its own takeover CSS hid it (docs/05 section 4.2 A).
   * @returns {string}
   */
  function buildToastStackHtml() {
    return '<div id="Toasts" class="mvm-toast-stack" aria-live="polite"></div>';
  }

  /**
   * HTML of the whole reader (goes inside <body>).
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function buildAppHtml(manga) {
    return [
      '<div id="', CONFIG.rootElementId, '">',
      '<div id="ProgressBar"><div class="mvm-progress-fill"></div></div>',
      buildMenuButtonHtml(),
      buildHeaderHtml(manga),
      buildChapterHtml(manga),
      buildNavbarHtml(manga),
      buildPaginationHtml(manga),
      buildOverlayHtml(),
      buildSettingsPanelHtml(),
      buildKeybindingsPanelHtml(),
      buildBookmarksPanelHtml(),
      buildCommentsPanelHtml(),
      buildToastStackHtml(),
      '</div>'
    ].join('');
  }

  /**
   * HTML of the new <head>: charset, viewport, title and the reader styles (the theme style is
   * added by the theme applier, section 14).
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function buildHeadHtml(manga) {
    return [
      '<meta charset="UTF-8" />',
      '<meta name="viewport" content="width=device-width, initial-scale=1" />',
      '<title>', escapeHtml(manga.title), '</title>',
      '<style id="MVMReaderStyle">', READER_BASE_CSS, '</style>',
      '<style id="MVMLayoutStyle">', READER_LAYOUT_CSS, '\n', ICON_COLOR_CSS, '</style>',
      '<style id="MVMPanelsStyle">', PANELS_CSS, '</style>',
      '<style id="MVMFeaturesStyle">', FEATURES_CSS, '</style>',
      '<style id="MVMPopupStyle">', POPUP_CSS, '</style>'
    ].join('');
  }

  // ==========================================================================================
  // ==== 13. POPUPS ===========================================================================
  // One generic dialog (look of the 2025 version's SweetAlert popups), plus the start prompt and
  // the page-range prompt (two-handle slider + number inputs) built on it. showPopup() returns a
  // Promise with what the user did. Also the toasts (showToast / updateToast / closeToast): they
  // live here, not in section 20, because the download (section 19) needs them too.
  // ==========================================================================================

  /** Ways a popup can close. */
  const POPUP_RESULT = {
    CONFIRM: 'confirm',
    CANCEL: 'cancel',
    TIMER: 'timer',
    ESCAPE: 'escape',
    BACKDROP: 'backdrop'
  };

  /**
   * HTML of a popup (icon, title, content, confirm/cancel buttons, optional timer bar).
   * @param {Object} options - Same options as showPopup.
   * @returns {string}
   */
  function buildPopupHtml(options) {
    return [
      '<div class="mvm-popup" role="dialog" aria-modal="true">',
      '<div class="mvm-popup-icon ', ['info', 'warning'].indexOf(options.icon) !== -1 ? options.icon : 'question', '"></div>',
      '<h2 class="mvm-popup-title">', escapeHtml(options.title), '</h2>',
      '<div class="mvm-popup-content">', options.contentHtml || '', '</div>',
      '<div class="mvm-popup-actions">',
      '<button type="button" class="mvm-popup-button confirm">', escapeHtml(options.confirmText || getLocaleString('CLOSE')), '</button>',
      options.cancelText ? '<button type="button" class="mvm-popup-button cancel">' + escapeHtml(options.cancelText) + '</button>' : '',
      '</div>',
      options.timerMs ? '<div class="mvm-popup-timer" style="width:100%"></div>' : '',
      '</div>'
    ].join('');
  }

  /**
   * Shows a dialog and resolves when it closes.
   * @param {Object} options
   * @param {string} options.title - Title text (escaped).
   * @param {string} [options.contentHtml] - Body HTML (caller must escape site data).
   * @param {string} [options.icon='question'] - 'question', 'info' or 'warning'.
   * @param {string} [options.confirmText] - Confirm button text.
   * @param {string} [options.cancelText] - Cancel button text; omit for no cancel button.
   * @param {number} [options.timerMs] - Auto-close after this time (result TIMER).
   * @param {function(HTMLElement): void} [options.onOpen] - Called with the popup element.
   * @param {function(HTMLElement): boolean} [options.beforeConfirm] - Return false to keep it open.
   * @returns {Promise<{result: string, popup: HTMLElement}>}
   */
  function showPopup(options) {
    installStyle('MVMPopupStyle', POPUP_CSS);
    return new Promise(function (resolve) {
      const backdrop = document.createElement('div');
      backdrop.className = 'mvm-popup-backdrop';
      setElementHtml(backdrop, buildPopupHtml(options));
      const popup = backdrop.querySelector('.mvm-popup');
      let closed = false;
      let timerId = null;

      /** Closes the popup once and resolves with the reason. */
      function close(result) {
        if (closed) {
          return;
        }
        closed = true;
        if (timerId !== null) {
          clearTimeout(timerId);
        }
        document.removeEventListener('keydown', onKeyDown, true);
        backdrop.remove();
        logDebug('popup closed', options.title, result);
        resolve({ result: result, popup: popup });
      }

      /** Escape closes, Enter confirms. */
      function onKeyDown(event) {
        if (event.key === 'Escape') {
          close(POPUP_RESULT.ESCAPE);
        } else if (event.key === 'Enter') {
          confirmPopup();
        }
      }

      /** Runs the optional validation, then closes with CONFIRM. */
      function confirmPopup() {
        const allowed = options.beforeConfirm ? safeRun('popup.beforeConfirm', function () {
          return options.beforeConfirm(popup);
        }, false) : true;
        if (allowed) {
          close(POPUP_RESULT.CONFIRM);
        }
      }

      backdrop.addEventListener('click', guardHandler('popup.click', function (event) {
        if (event.target === backdrop) {
          close(POPUP_RESULT.BACKDROP);
        } else if (event.target.classList.contains('confirm')) {
          confirmPopup();
        } else if (event.target.classList.contains('cancel')) {
          close(POPUP_RESULT.CANCEL);
        }
      }));
      document.addEventListener('keydown', onKeyDown, true);
      (document.body || document.documentElement).appendChild(backdrop);

      if (options.timerMs) {
        const bar = popup.querySelector('.mvm-popup-timer');
        setTimeout(function () {
          bar.style.transitionDuration = options.timerMs + 'ms';
          bar.style.width = '0%';
        }, 20);
        timerId = setTimeout(function () {
          close(POPUP_RESULT.TIMER);
        }, options.timerMs);
      }
      if (options.onOpen) {
        safeRun('popup.onOpen', function () {
          options.onOpen(popup);
        });
      }
      const confirmButton = popup.querySelector('.confirm');
      if (confirmButton) {
        confirmButton.focus();
      }
    });
  }

  /**
   * "Starting... waiting" prompt shown in load mode 'wait'. Resolves true to start, false when
   * the user cancels (then the caller shows the Start button).
   * @param {Object} manga - The manga object (uses begin).
   * @returns {Promise<boolean>}
   */
  async function showWaitPrompt(manga) {
    const resumeText = manga.begin > 1 ? escapeHtml(getLocaleString('RESUME')) + manga.begin + '.<br/>' : '';
    const answer = await showPopup({
      title: getLocaleString('STARTING'),
      contentHtml: resumeText + escapeHtml(getLocaleString('WAITING')),
      confirmText: getLocaleString('BUTTON_START'),
      cancelText: getLocaleString('CANCEL'),
      timerMs: CONFIG.waitPopupMs
    });
    return answer.result === POPUP_RESULT.CONFIRM || answer.result === POPUP_RESULT.TIMER;
  }

  /**
   * Zero-pads a page number for the page-range display, e.g. (7, 3) -> '007' (NEW:11700-11703).
   * @param {number} page - Page number.
   * @param {number} digits - Minimum length.
   * @returns {string}
   */
  function padPageNumber(page, digits) {
    let text = String(page);
    while (text.length < digits) {
      text = '0' + text;
    }
    return text;
  }

  /**
   * Keeps a page range valid after one handle or number input changed (pure). Both ends become
   * whole numbers between 1 and totalPages (the 2026 slider allowed 0, docs/05 section 4 #6), and
   * the end that moved cannot pass the other end: it stops there.
   * @param {*} begin - First page (any value; invalid -> 1).
   * @param {*} end - Last page (any value; invalid -> totalPages).
   * @param {number} totalPages - Number of pages.
   * @param {string} changed - 'begin' or 'end': which end the user moved.
   * @returns {{begin: number, end: number}}
   */
  function normalizePageRange(begin, end, totalPages, changed) {
    const last = Math.max(1, Math.floor(Number(totalPages)) || 1);
    let first = clamp(Math.round(Number(begin)) || 1, 1, last);
    let final = clamp(Math.round(Number(end)) || last, 1, last);
    if (first > final) {
      if (changed === 'end') {
        final = first;
      } else {
        first = final;
      }
    }
    return { begin: first, end: final };
  }

  /**
   * Position of the colored part of the two-handle slider, in percent of the track (pure).
   * @param {number} begin - First page.
   * @param {number} end - Last page.
   * @param {number} totalPages - Number of pages.
   * @returns {{left: number, right: number}} Distances from the left and right ends of the track.
   */
  function computeRangeFillPercent(begin, end, totalPages) {
    const span = totalPages - 1;
    if (span <= 0) {
      return { left: 0, right: 0 };
    }
    return { left: ((begin - 1) / span) * 100, right: 100 - ((end - 1) / span) * 100 };
  }

  /**
   * HTML of the page-range prompt body: the "[001 , 045]" display, the two-handle slider (two
   * range inputs over one track) and the two number inputs of the 2025 version (OLD:5011-5091).
   * @param {number} begin - First page shown at start.
   * @param {number} totalPages - Number of pages.
   * @returns {string}
   */
  function buildPageRangeHtml(begin, totalPages) {
    const pageLabel = escapeHtml(getLocaleString('BOOKMARK_PAGE'));
    return [
      escapeHtml(getLocaleString('CHOOSE_BEGINNING')),
      '<div class="mvm-range-display">[ <span class="mvm-range-display-begin">', padPageNumber(begin, 3), '</span> , <span class="mvm-range-display-end">', padPageNumber(totalPages, 3), '</span> ]</div>',
      '<div class="mvm-dual-range">',
      '<div class="mvm-dual-range-track"><div class="mvm-dual-range-fill"></div></div>',
      '<input type="range" class="mvm-range-slider-begin" min="1" max="', totalPages, '" step="1" value="', begin, '" aria-label="', pageLabel, ' (1)" />',
      '<input type="range" class="mvm-range-slider-end" min="1" max="', totalPages, '" step="1" value="', totalPages, '" aria-label="', pageLabel, ' (2)" />',
      '</div>',
      '<div class="mvm-popup-range">',
      '<input type="number" class="mvm-range-begin" min="1" max="', totalPages, '" value="', begin, '" inputmode="numeric" />',
      '<span>-</span>',
      '<input type="number" class="mvm-range-end" min="1" max="', totalPages, '" value="', totalPages, '" inputmode="numeric" />',
      '</div>',
      '<div class="mvm-popup-error"></div>'
    ].join('');
  }

  /**
   * Connects the page-range controls: each slider handle and each number input updates the
   * others, the "[001 , 045]" display and the colored part of the track. A number being typed
   * moves the slider when it is valid; leaving the input writes the corrected value back.
   * @param {HTMLElement} popup - The popup element.
   * @param {number} totalPages - Number of pages.
   */
  function wirePageRangeControls(popup, totalPages) {
    const sliders = { begin: popup.querySelector('.mvm-range-slider-begin'), end: popup.querySelector('.mvm-range-slider-end') };
    const numbers = { begin: popup.querySelector('.mvm-range-begin'), end: popup.querySelector('.mvm-range-end') };
    const fill = popup.querySelector('.mvm-dual-range-fill');
    const display = { begin: popup.querySelector('.mvm-range-display-begin'), end: popup.querySelector('.mvm-range-display-end') };
    if (!sliders.begin || !sliders.end || !numbers.begin || !numbers.end) {
      logWarn('page range: controls not found');
      return;
    }

    /** Writes a range to the sliders, the display, the track and (optionally) the inputs. */
    function render(range, updateNumbers) {
      sliders.begin.value = String(range.begin);
      sliders.end.value = String(range.end);
      sliders.begin.classList.toggle('on-top', range.begin === totalPages);
      if (updateNumbers) {
        numbers.begin.value = String(range.begin);
        numbers.end.value = String(range.end);
      }
      if (display.begin && display.end) {
        display.begin.textContent = padPageNumber(range.begin, 3);
        display.end.textContent = padPageNumber(range.end, 3);
      }
      if (fill) {
        const percent = computeRangeFillPercent(range.begin, range.end, totalPages);
        fill.style.left = percent.left + '%';
        fill.style.right = percent.right + '%';
      }
    }

    /** Handler factory: a slider handle moved. */
    function onSliderInput(changed) {
      return guardHandler('pageRange.slider', function () {
        render(normalizePageRange(sliders.begin.value, sliders.end.value, totalPages, changed), true);
      });
    }

    /** Handler factory: a number is being typed; follow it only while it is a valid page. */
    function onNumberInput(changed) {
      return guardHandler('pageRange.numberInput', function () {
        const typed = parseInt(numbers[changed].value, 10);
        if (isNaN(typed) || typed < 1 || typed > totalPages) {
          return;
        }
        const range = changed === 'begin' ? normalizePageRange(typed, sliders.end.value, totalPages, changed) : normalizePageRange(sliders.begin.value, typed, totalPages, changed);
        render(range, false);
      });
    }

    /** Handler factory: the number input was left (or Enter): write the corrected range back. */
    function onNumberChange(changed) {
      return guardHandler('pageRange.numberChange', function () {
        render(normalizePageRange(numbers.begin.value, numbers.end.value, totalPages, changed), true);
      });
    }

    ['begin', 'end'].forEach(function (side) {
      sliders[side].addEventListener('input', onSliderInput(side));
      numbers[side].addEventListener('input', onNumberInput(side));
      numbers[side].addEventListener('change', onNumberChange(side));
    });
    render(normalizePageRange(sliders.begin.value, sliders.end.value, totalPages, 'begin'), true);
  }

  /**
   * Prompt to choose the first and last page to load: a two-handle slider plus two number
   * inputs, minimum page 1. Resolves {begin, end} or null when cancelled.
   * @param {number} totalPages - Number of pages in the chapter.
   * @param {number} suggestedBegin - Initial first page.
   * @returns {Promise<{begin: number, end: number}|null>}
   */
  async function showPageRangePrompt(totalPages, suggestedBegin) {
    const begin = clamp(suggestedBegin || 1, 1, totalPages);
    const answer = await showPopup({
      title: getLocaleString('STARTING'),
      contentHtml: buildPageRangeHtml(begin, totalPages),
      confirmText: getLocaleString('BUTTON_START'),
      cancelText: getLocaleString('CANCEL'),
      onOpen: function (popup) {
        wirePageRangeControls(popup, totalPages);
      },
      beforeConfirm: function (popup) {
        const range = readPageRange(popup, totalPages);
        popup.querySelector('.mvm-popup-error').textContent = range.error;
        return range.error === '';
      }
    });
    if (answer.result !== POPUP_RESULT.CONFIRM) {
      return null;
    }
    const chosen = readPageRange(answer.popup, totalPages);
    logDebug('page range chosen', chosen.begin, chosen.end);
    return { begin: chosen.begin, end: chosen.end };
  }

  /**
   * Reads and validates the two page inputs of the page-range prompt.
   * @param {HTMLElement} popup - The popup element.
   * @param {number} totalPages - Upper limit.
   * @returns {{begin: number, end: number, error: string}}
   */
  function readPageRange(popup, totalPages) {
    const begin = parseInt(popup.querySelector('.mvm-range-begin').value, 10);
    const end = parseInt(popup.querySelector('.mvm-range-end').value, 10);
    if (isNaN(begin) || isNaN(end)) {
      return { begin: 1, end: totalPages, error: getLocaleString('PAGE_RANGE_NEED_NUMBERS') };
    }
    if (begin < 1 || end > totalPages || begin > end) {
      return { begin: begin, end: end, error: getLocaleString('PAGE_RANGE_INVALID').replace('##total##', String(totalPages)) };
    }
    return { begin: begin, end: end, error: '' };
  }

  // ---- Toasts ----
  // Small messages stacked at the bottom-right that hide by themselves (2026 version toasts,
  // NEW:3345-3388). The stack lives INSIDE the reader root (#Toasts); before the reader exists
  // (local files page, start prompt) a fallback stack is added to <body>. A toast with
  // `durationMs: 0` stays until closeToast() or its close button. Close and action buttons
  // carry data-action (section 16 / 19); in the fallback stack (outside the reader, no click
  // delegation) a toast has no buttons and closes by its timer only.

  /** Toast types -> icon name. */
  const TOAST_ICONS = {
    info: 'infoCircle',
    success: 'circleCheck',
    warning: 'alertCircle',
    error: 'circleX'
  };

  /** Counter for toast ids. */
  let toastCounter = 0;

  /**
   * The element that holds the toasts: #Toasts inside the reader, or a fallback stack in <body>.
   * @returns {HTMLElement}
   */
  function getToastStack() {
    const inReader = document.getElementById('Toasts');
    if (inReader) {
      return inReader;
    }
    let fallback = document.getElementById('MVMToastsFallback');
    if (!fallback) {
      installStyle('MVMPopupStyle', POPUP_CSS);
      fallback = document.createElement('div');
      fallback.id = 'MVMToastsFallback';
      fallback.className = 'mvm-toast-stack';
      fallback.setAttribute('aria-live', 'polite');
      (document.body || document.documentElement).appendChild(fallback);
    }
    return fallback;
  }

  /**
   * Shows a toast and returns its id (use it with updateToast / closeToast).
   * @param {Object} options
   * @param {string} [options.type='info'] - 'info', 'success', 'warning' or 'error'.
   * @param {string} options.title - Title (escaped).
   * @param {string} [options.message] - Text below the title (escaped).
   * @param {number} [options.durationMs=CONFIG.toastDurationMs] - 0 = stays until closed.
   * @param {boolean} [options.progress] - Show a progress bar (see updateToast).
   * @param {{action: string, label: string}} [options.button] - One action button (data-action).
   * @returns {string} The toast element id.
   */
  function showToast(options) {
    const settings = options || {};
    const type = TOAST_ICONS[settings.type] ? settings.type : 'info';
    toastCounter += 1;
    const toastId = 'MVMToast' + toastCounter;
    return safeRun('toast.show', function () {
      const stack = getToastStack();
      const insideReader = stack.id === 'Toasts';
      const toast = document.createElement('div');
      toast.id = toastId;
      toast.className = 'mvm-toast ' + type;
      toast.setAttribute('role', type === 'error' ? 'alert' : 'status');
      setElementHtml(toast, [
        '<span class="mvm-toast-icon">', buildIconSvg(TOAST_ICONS[type]), '</span>',
        '<div class="mvm-toast-body">',
        '<div class="mvm-toast-title">', escapeHtml(settings.title || ''), '</div>',
        '<div class="mvm-toast-message">', escapeHtml(settings.message || ''), '</div>',
        settings.progress ? '<div class="mvm-toast-progress"><div class="mvm-toast-progress-fill"></div></div>' : '',
        settings.button && insideReader ? '<div class="mvm-toast-actions"><button type="button" class="mvm-toast-button" data-action="' + escapeHtml(settings.button.action) + '">' + escapeHtml(settings.button.label) + '</button></div>' : '',
        '</div>',
        insideReader ? '<button type="button" class="mvm-toast-close" data-action="toast-close" data-value="' + toastId + '" aria-label="' + escapeHtml(getLocaleString('CLOSE')) + '">&times;</button>' : ''
      ].join(''));
      stack.appendChild(toast);
      setTimeout(function () {
        toast.classList.add('visible');
      }, 20);
      const duration = typeof settings.durationMs === 'number' ? settings.durationMs : CONFIG.toastDurationMs;
      if (duration > 0) {
        setTimeout(function () {
          closeToast(toastId);
        }, duration);
      }
      logDebug('toast', type, settings.title, settings.message || '');
      return toastId;
    }, toastId, { title: settings.title });
  }

  /**
   * Gives a toast another type (color and icon).
   * @param {HTMLElement} toast - Toast element.
   * @param {string} type - Key of TOAST_ICONS.
   */
  function setToastType(toast, type) {
    Object.keys(TOAST_ICONS).forEach(function (name) {
      toast.classList.toggle(name, name === type);
    });
    const icon = toast.querySelector('.mvm-toast-icon');
    if (icon) {
      setElementHtml(icon, buildIconSvg(TOAST_ICONS[type]));
    }
  }

  /**
   * Changes the texts, the type and/or the progress bar of a visible toast. Does nothing when
   * the toast is already gone.
   * @param {string} toastId - Id returned by showToast.
   * @param {{title: (string|undefined), message: (string|undefined), type: (string|undefined), progressPercent: (number|undefined)}} changes
   */
  function updateToast(toastId, changes) {
    const toast = document.getElementById(toastId);
    if (!toast) {
      return;
    }
    if (typeof changes.title === 'string') {
      toast.querySelector('.mvm-toast-title').textContent = changes.title;
    }
    if (typeof changes.message === 'string') {
      toast.querySelector('.mvm-toast-message').textContent = changes.message;
    }
    if (changes.type && TOAST_ICONS[changes.type]) {
      setToastType(toast, changes.type);
    }
    const fill = toast.querySelector('.mvm-toast-progress-fill');
    if (fill && typeof changes.progressPercent === 'number') {
      fill.style.width = clamp(changes.progressPercent, 0, 100) + '%';
    }
  }

  /**
   * Hides a toast (slides out), then removes it. Safe to call twice or for a missing toast.
   * @param {string} toastId - Id returned by showToast.
   */
  function closeToast(toastId) {
    const toast = document.getElementById(toastId);
    if (!toast || toast.classList.contains('closing')) {
      return;
    }
    toast.classList.add('closing');
    toast.classList.remove('visible');
    setTimeout(function () {
      if (toast.parentNode) {
        toast.parentNode.removeChild(toast);
      }
    }, CONFIG.toastExitMs);
  }

  // ==========================================================================================
  // ==== 14. SETTING APPLIERS =================================================================
  // ONE table that says how the screen follows each setting. When a setting changes, only its
  // applier runs (no rebuilding of the whole reader). To make a new setting visible on screen:
  // write an apply function here and add it to SETTING_APPLIERS. Appliers must be safe to run
  // at any time (also before the reader exists: then they simply find no element and return).
  // The section is grouped by feature, each group starting with "// ---- <feature> ----":
  // DOM helpers, theme, page size and zoom, per-page controls, scrolling to a page, current
  // page, view mode, header, thumbnails bar, pagination, device, side panels, language,
  // reactions to image changes, THE TABLE.
  // The functions used by ACTIONS (section 16) live here too, next to the state they change.
  // ==========================================================================================

  // ---- Small DOM helpers ----

  /** The reader root element, or null before the takeover. */
  function getReaderRoot() {
    return document.getElementById(CONFIG.rootElementId);
  }

  /** The chapter (pages) container, or null. */
  function getChapterElement() {
    return document.getElementById('Chapter');
  }

  /** The <img> element of a page, or null. */
  function getPageImage(page) {
    return document.getElementById('PageImg' + page);
  }

  /** The header element, or null. */
  function getHeaderElement() {
    return document.getElementById('Header');
  }

  /**
   * Sets exactly one class out of a group on an element (e.g. one view mode).
   * @param {Element|null} element - Target.
   * @param {string[]} group - All possible classes of the group.
   * @param {string} active - The class to keep.
   */
  function setExclusiveClass(element, group, active) {
    if (!element) {
      return;
    }
    group.forEach(function (className) {
      element.classList.remove(className);
    });
    if (active) {
      element.classList.add(active);
    }
  }

  /**
   * Marks the button whose data-value equals `value` as selected inside a container (a button
   * group), and unmarks the others. Generic: used for view modes and zoom modes.
   * @param {string} containerId - Element id of the group.
   * @param {string} value - data-value to select ('' selects none).
   */
  function markSelectedButton(containerId, value) {
    const container = document.getElementById(containerId);
    if (!container) {
      return;
    }
    queryAll('[data-value]', container).forEach(function (button) {
      button.classList.toggle('selected', button.getAttribute('data-value') === value);
    });
  }

  /**
   * First and last page of the open chapter, or null before the reader opens.
   * @returns {{begin: number, last: number}|null}
   */
  function getPageRange() {
    const manga = APP_STATE.manga;
    if (!manga) {
      return null;
    }
    return { begin: manga.begin || 1, last: manga.pages };
  }

  /**
   * Calls `action(page)` for every page of the open chapter.
   * @param {function(number): void} action - Work per page.
   */
  function forEachPage(action) {
    const range = getPageRange();
    if (!range) {
      return;
    }
    sequence(range.last, range.begin).forEach(action);
  }

  // ---- Theme and color scheme (section 10 builds the CSS) ----

  /**
   * Writes the theme style (theme color + color scheme) and the light/dark classes. Runs for both
   * the `theme` and the `colorScheme` setting. Does nothing before the reader exists.
   */
  function applyThemeSettings() {
    const root = getReaderRoot();
    if (!root) {
      return;
    }
    const colorScheme = getSetting('colorScheme') === 'light' ? 'light' : 'dark';
    setExclusiveClass(root, ['light', 'dark'], colorScheme);
    setExclusiveClass(document.documentElement, ['light', 'dark'], colorScheme);
    installStyle('MVMThemeStyle', buildThemeCss(getSetting('theme'), colorScheme));
    logDebug('theme applied', getSetting('theme'), colorScheme);
  }

  // ---- Page size and zoom ----
  // Global zoom (setting zoomMode + zoomValue) gives every page a size; per-page buttons change
  // one page until the next global zoom change (same as the 2026 version, NEW:7611-7622).

  /** Width available for pages: window width minus a side thumbnails bar (NEW:7580-7584). */
  function getAvailableWidth() {
    const navbar = getSetting('navbar');
    return window.innerWidth - (navbar === 'left' || navbar === 'right' ? CONFIG.navbarSizePx : 0);
  }

  /** Height available for pages: window height minus a bottom thumbnails bar (NEW:7585-7588). */
  function getAvailableHeight() {
    return window.innerHeight - (getSetting('navbar') === 'bottom' ? CONFIG.navbarSizePx : 0);
  }

  /**
   * Size of a page for a global zoom (pure). null = automatic.
   * @param {number} naturalWidth - Image width in pixels (0 when not loaded yet).
   * @param {string} zoomMode - 'percent', 'width' or 'height'.
   * @param {number} zoomValue - Percent (percent mode).
   * @param {number} availableWidth - Pixels.
   * @param {number} availableHeight - Pixels.
   * @returns {{width: (number|null), height: (number|null)}}
   */
  function computeGlobalPageSize(naturalWidth, zoomMode, zoomValue, availableWidth, availableHeight) {
    if (zoomMode === 'width') {
      return { width: availableWidth, height: null };
    }
    if (zoomMode === 'height') {
      return { width: null, height: availableHeight };
    }
    return { width: naturalWidth > 0 ? naturalWidth * (zoomValue / 100) : null, height: null };
  }

  /**
   * New width of a page after one per-page zoom step (pure), or null when the image has not
   * loaded yet (the 2026 version also ignores the click then, NEW:11237-11268).
   * @param {{naturalWidth: number, width: (number|null)}} layout - Page layout.
   * @param {number} zoomStep - Percent per step.
   * @param {number} direction - 1 = bigger, -1 = smaller.
   * @returns {number|null}
   */
  function computeSteppedPageWidth(layout, zoomStep, direction) {
    if (!layout.naturalWidth) {
      return null;
    }
    return (layout.width || layout.naturalWidth) * (1 + direction * zoomStep / 100);
  }

  /** True for the two horizontal view modes. */
  function isFluidViewMode(viewMode) {
    return viewMode === 'FluidLTR' || viewMode === 'FluidRTL';
  }

  /** True for the two-column view modes. */
  function isBookViewMode(viewMode) {
    return viewMode === 'Book' || viewMode === 'Manga';
  }

  /**
   * Writes a page's layout to its <img> (width, height, horizontal-mode height limit).
   * @param {number} page - Page number.
   */
  function renderPageSize(page) {
    const image = getPageImage(page);
    if (!image) {
      return;
    }
    const layout = getPageLayout(page);
    image.style.width = layout.width ? layout.width + 'px' : '';
    image.style.height = layout.height ? layout.height + 'px' : '';
    image.style.maxHeight = isFluidViewMode(getSetting('viewMode')) ? getAvailableHeight() + 'px' : '';
  }

  /**
   * Gives one page the size of the current global zoom.
   * @param {number} page - Page number.
   */
  function applyGlobalZoomToPage(page) {
    const layout = getPageLayout(page);
    const size = computeGlobalPageSize(layout.naturalWidth, getSetting('zoomMode'), Number(getSetting('zoomValue')) || 100, getAvailableWidth(), getAvailableHeight());
    layout.width = size.width;
    layout.height = size.height;
    renderPageSize(page);
  }

  /** Gives every page the size of the current global zoom (overwrites per-page zoom). */
  function applyGlobalZoomToAllPages() {
    forEachPage(applyGlobalZoomToPage);
  }

  /** Updates the header zoom label, slider and the selected fit button. */
  function renderZoomControls() {
    const zoomMode = getSetting('zoomMode');
    const zoomValue = Number(getSetting('zoomValue')) || 100;
    const label = document.getElementById('ZoomValue');
    if (label) {
      label.textContent = getZoomLabel(zoomMode, zoomValue);
    }
    const slider = document.getElementById('ZoomSlider');
    if (slider) {
      slider.value = String(Math.round(zoomValue));
    }
    markSelectedButton('ZoomGroup', zoomMode);
  }

  /** Applier of `zoomMode` and `zoomValue`: header controls + every page. */
  function applyZoomSettings() {
    renderZoomControls();
    applyGlobalZoomToAllPages();
  }

  /**
   * Changes the global zoom for this page only (session), like the 2026 applyZoom (NEW:7611-7622):
   * all pages are recomputed; 'height' scrolls back to the current page, any other mode gives the
   * header its stored mode back.
   * @param {string} zoomMode - 'percent', 'width' or 'height'.
   * @param {number} [zoomValue] - Percent (default: current value).
   */
  function setGlobalZoom(zoomMode, zoomValue) {
    const value = typeof zoomValue === 'number' && !isNaN(zoomValue) ? zoomValue : Number(getSetting('zoomValue'));
    const modeChanged = setSessionSetting('zoomMode', zoomMode);
    const valueChanged = setSessionSetting('zoomValue', value);
    if (!modeChanged && !valueChanged) {
      applyZoomSettings();
    }
    if (zoomMode === 'height') {
      scrollToPage(APP_STATE.currentPage);
    } else {
      restoreStoredSetting('header');
    }
  }

  /**
   * Enlarge (+1) or reduce (-1) the global zoom by `zoomStep`, switching to percent mode. Ignored
   * unless the result stays between 0 and 500 (exclusive), as in the 2026 version (NEW:7639-7644).
   * @param {number} direction - 1 or -1.
   * @returns {boolean} True when the zoom changed.
   */
  function changeGlobalZoomByStep(direction) {
    const target = (Number(getSetting('zoomValue')) || 100) + direction * (Number(getSetting('zoomStep')) || 0);
    if (target <= 0 || target >= CONFIG.maxGlobalZoomExclusive) {
      logDebug('zoom step ignored, out of range', target);
      return false;
    }
    setGlobalZoom('percent', target);
    return true;
  }

  /**
   * Applier of `minZoom`: minimum page width in percent of the window width. Only for WebComic and
   * Vertical: the 2026 version skips Book, Manga and Gallery (NEW:11343) and its CSS meant to skip
   * the horizontal modes too (NEW:11526). Also the lower end of the header zoom slider.
   * @param {number} minZoom - Percent of the window width.
   */
  function applyMinZoom(minZoom) {
    const value = parseInt(minZoom, 10);
    const css = isNaN(value) ? '' : [
      '#MangaViewerMM #Chapter.WebComic .PageImg, #MangaViewerMM #Chapter.Vertical .PageImg { min-width: ' + value + 'vw; }'
    ].join('\n');
    installStyle('MVMMinZoomStyle', css);
    const slider = document.getElementById('ZoomSlider');
    if (slider && !isNaN(value)) {
      slider.min = String(value);
    }
  }

  /** Applier of `fitWidthIfOversize`: images wider than the reader shrink (not in horizontal modes). */
  function applyFitWidthIfOversize(enabled) {
    const chapter = getChapterElement();
    if (chapter) {
      chapter.classList.toggle('fitWidthIfOversize', enabled === true);
    }
  }

  // ---- Per-page controls ----

  /**
   * Zooms one page in (+1) or out (-1) by `zoomStep` percent.
   * @param {number} page - Page number (the clicked page).
   * @param {number} direction - 1 or -1.
   */
  function zoomPageByStep(page, direction) {
    const layout = getPageLayout(page);
    const width = computeSteppedPageWidth(layout, Number(getSetting('zoomStep')) || 0, direction);
    if (width === null) {
      logDebug('page zoom ignored: image not loaded yet', page);
      return;
    }
    layout.width = width;
    layout.height = null;
    renderPageSize(page);
  }

  /** Natural size for one page (NEW:11273-11286). */
  function restorePageZoom(page) {
    const layout = getPageLayout(page);
    layout.width = null;
    layout.height = null;
    renderPageSize(page);
  }

  /** One page as wide as the available width (NEW:11291-11304). */
  function fitPageToWidth(page) {
    const layout = getPageLayout(page);
    layout.width = getAvailableWidth();
    layout.height = null;
    renderPageSize(page);
  }

  /** One page as tall as the available height (NEW:11309-11322). */
  function fitPageToHeight(page) {
    const layout = getPageLayout(page);
    layout.width = null;
    layout.height = getAvailableHeight();
    renderPageSize(page);
  }

  /** Collapses or shows one page image (the button row stays, NEW:11182-11185). */
  function togglePageHidden(page) {
    const layout = getPageLayout(page);
    layout.hidden = !layout.hidden;
    const container = document.getElementById('Page' + page);
    if (container) {
      container.classList.toggle('hide', layout.hidden);
    }
  }

  /** Flips the "double page" mark of one page (Book/Manga, NEW:11431-11433). */
  function togglePageDoublePage(page) {
    const layout = getPageLayout(page);
    layout.doublePage = !layout.doublePage;
    applyBookPairing();
  }

  /** Applier of `hidePageControls`: hides all per-page button rows; header button shows the state. */
  function applyHidePageControls(hidden) {
    const root = getReaderRoot();
    if (root) {
      root.classList.toggle('hideControls', hidden === true);
    }
    const button = document.getElementById('pageControls');
    if (button) {
      button.classList.toggle('selected', hidden === true);
    }
  }

  /**
   * Applier of `bookmarks`: root class 'bookmarked' switches the bookmark icons (NEW:11869), and
   * the bookmarks panel list is drawn again (also after a change in another tab).
   */
  function applyBookmarksState() {
    const root = getReaderRoot();
    if (root) {
      root.classList.toggle('bookmarked', getBookmarkedPage() > 0);
    }
    const list = document.getElementById('BookmarksList');
    if (list) {
      setElementHtml(list, buildBookmarkListHtml(getSetting('bookmarks'), window.location.href));
    }
  }

  // ---- Scrolling to a page ----

  /**
   * Scrolls so that a page is at the top-left of the view, instantly (NEW:6936-6968). Page 0 or
   * less = top of the window. In horizontal modes the chapter container scrolls.
   * @param {number} page - Page number.
   */
  function scrollToPage(page) {
    if (!page || page <= 0) {
      window.scrollTo(0, 0);
      return;
    }
    const target = document.getElementById('Page' + page);
    if (!target) {
      logDebug('scrollToPage: page not in the reader', page);
      return;
    }
    const rect = target.getBoundingClientRect();
    const chapter = getChapterElement();
    if (isFluidViewMode(getSetting('viewMode')) && chapter) {
      const chapterRect = chapter.getBoundingClientRect();
      chapter.scrollBy(rect.left - chapterRect.left, rect.top - chapterRect.top);
    } else {
      window.scrollTo(rect.left + window.pageXOffset, rect.top + window.pageYOffset);
    }
    logDebug('scrolled to page', page);
  }

  // ---- Current page ----

  /**
   * Picks the current page (pure): among pages whose leading edge passed the window center, the
   * one whose edge is closest to it (NEW:11019-11046).
   * @param {{page: number, edge: number}[]} candidates - Leading edge position of each page.
   * @param {number} center - Window center on the same axis.
   * @param {number} fallback - Result when no page qualifies (the first page).
   * @returns {number}
   */
  function pickCurrentPage(candidates, center, fallback) {
    let best = null;
    candidates.forEach(function (candidate) {
      if (candidate.edge <= center && (best === null || candidate.edge > best.edge)) {
        best = candidate;
      }
    });
    return best === null ? fallback : best.page;
  }

  /** Computes and stores the page in view (leading edge: top, left in FluidLTR, right in FluidRTL). */
  function updateCurrentPage() {
    const range = getPageRange();
    if (!range) {
      return;
    }
    const viewMode = getSetting('viewMode');
    const horizontal = isFluidViewMode(viewMode);
    const candidates = [];
    sequence(range.last, range.begin).forEach(function (page) {
      const image = getPageImage(page);
      if (!image) {
        return;
      }
      const rect = image.getBoundingClientRect();
      let edge = rect.top;
      if (horizontal) {
        edge = viewMode === 'FluidRTL' ? rect.right : rect.left;
      }
      candidates.push({ page: page, edge: edge });
    });
    const center = horizontal ? window.innerWidth / 2 : window.innerHeight / 2;
    setCurrentPage(pickCurrentPage(candidates, center, range.begin));
  }

  // ---- View mode ----

  /** View mode names, from the header buttons table (section 12). */
  const VIEW_MODES = VIEW_MODE_BUTTONS.map(function (button) {
    return button.value;
  });

  /** View modes that switch to Fit Height and header 'click' for this page (NEW:7683-7691). */
  const FORCED_HEIGHT_VIEW_MODES = ['FluidLTR', 'FluidRTL', 'Book', 'Manga'];

  /**
   * Left/right/double position of every page in Book and Manga modes (pure). Counting back from a
   * page to the previous double page: an even number of single pages means "left" in Book and
   * "right" in Manga (NEW:11358-11366).
   * @param {number} begin - First page.
   * @param {number} last - Last page.
   * @param {Object<number, boolean>} doublePages - page -> true when it is a double page.
   * @param {string} viewMode - 'Book' or 'Manga'.
   * @returns {Object<number, string>} page -> 'left', 'right' or 'double'.
   */
  function computeBookPageSides(begin, last, doublePages, viewMode) {
    const sides = {};
    let singlesSinceDouble = 0;
    for (let page = begin; page <= last; page++) {
      if (doublePages[page] === true) {
        sides[page] = 'double';
        singlesSinceDouble = 0;
        continue;
      }
      const even = singlesSinceDouble % 2 === 0;
      sides[page] = (viewMode === 'Book') === even ? 'left' : 'right';
      singlesSinceDouble += 1;
    }
    return sides;
  }

  /** Writes LeftPage / RightPage / DoublePage classes on every page (used in Book and Manga). */
  function applyBookPairing() {
    const range = getPageRange();
    if (!range) {
      return;
    }
    const doublePages = {};
    sequence(range.last, range.begin).forEach(function (page) {
      doublePages[page] = getPageLayout(page).doublePage;
    });
    const sides = computeBookPageSides(range.begin, range.last, doublePages, getSetting('viewMode') === 'Manga' ? 'Manga' : 'Book');
    Object.keys(sides).forEach(function (page) {
      const container = document.getElementById('Page' + page);
      if (container) {
        container.classList.toggle('LeftPage', sides[page] === 'left');
        container.classList.toggle('RightPage', sides[page] === 'right');
        container.classList.toggle('DoublePage', sides[page] === 'double');
      }
    });
  }

  /** Applier of `viewMode`: layout class, header buttons, book pairing, page sizes. */
  function applyViewMode(viewMode) {
    const mode = VIEW_MODES.indexOf(viewMode) === -1 ? 'WebComic' : viewMode;
    setExclusiveClass(getChapterElement(), VIEW_MODES, mode);
    markSelectedButton('ViewModeGroup', mode);
    applyBookPairing();
    forEachPage(renderPageSize);
    updateCurrentPage();
  }

  /**
   * Changes the view mode for this page only, with the 2026 side effects (NEW:7680-7699):
   * horizontal/book modes force Fit Height and header 'click'; the other modes give zoom and header
   * their stored values back. Then the global zoom is applied again.
   * @param {string} viewMode - One of VIEW_MODES.
   */
  function switchViewMode(viewMode) {
    if (VIEW_MODES.indexOf(viewMode) === -1) {
      logWarn('Unknown view mode', viewMode);
      return;
    }
    setSessionSetting('viewMode', viewMode);
    if (FORCED_HEIGHT_VIEW_MODES.indexOf(viewMode) !== -1) {
      setSessionSetting('zoomMode', 'height');
      setSessionSetting('header', 'click');
    } else {
      restoreStoredSetting('zoomMode');
      restoreStoredSetting('zoomValue');
      restoreStoredSetting('header');
    }
    setGlobalZoom(getSetting('zoomMode'), Number(getSetting('zoomValue')));
    log('View mode: ' + viewMode);
  }

  // ---- Header (setting `header`: hover, scroll, click, fixed, simple) ----
  // The 2026 version decided visibility with CSS selectors on headroom classes (NEW:9204). Here the
  // same rules are computed in JS (isHeaderShown) and the CSS only knows "header-hidden".

  /** Header modes, in the settings order (NEW:10318-10338). */
  const HEADER_MODES = ['hover', 'scroll', 'click', 'fixed', 'simple'];

  /** Runtime state of the header ("headroom"). */
  const HEADROOM = {
    previousScrollY: 0,
    position: 'top',
    visible: true
  };

  /**
   * Where the page is, for the header (pure; NEW:8980-8987):
   * 'end' near the bottom (not in Fit Height), 'hide' scrolling down, 'show' scrolling up (scroll
   * mode), 'top' near the top (not in click mode), else 'none'.
   * @param {Object} input
   * @param {number} input.scrollY - Current vertical scroll.
   * @param {number} input.previousScrollY - Previous vertical scroll.
   * @param {number} input.viewportHeight - Window height.
   * @param {number} input.documentHeight - Total page height.
   * @param {string} input.headerMode - Setting `header`.
   * @param {string} input.zoomMode - Setting `zoomMode`.
   * @returns {string}
   */
  function computeHeadroomPosition(input) {
    if (input.zoomMode !== 'height' && input.scrollY + input.viewportHeight + CONFIG.headerEndDistancePx > input.documentHeight) {
      return 'end';
    }
    if (input.scrollY > input.previousScrollY && input.scrollY > CONFIG.headerHeightPx) {
      return 'hide';
    }
    if (input.headerMode === 'scroll' && input.scrollY < input.previousScrollY && input.scrollY > CONFIG.headerHeightPx) {
      return 'show';
    }
    if (input.headerMode !== 'click' && input.scrollY <= CONFIG.headerHeightPx) {
      return 'top';
    }
    return 'none';
  }

  /**
   * True when the header is on screen (pure; the 2026 CSS rules of NEW:9204 written as logic).
   * @param {string} headerMode - Setting `header`.
   * @param {string} position - Result of computeHeadroomPosition.
   * @param {boolean} visible - Mouse/burger visibility flag.
   * @returns {boolean}
   */
  function isHeaderShown(headerMode, position, visible) {
    if (headerMode === 'fixed' || headerMode === 'simple' || visible) {
      return true;
    }
    if (position === 'top' || position === 'end') {
      return true;
    }
    return headerMode === 'scroll' && position === 'show';
  }

  /** Writes the header state classes, and shows the burger only where the 2026 version did. */
  function renderHeaderState() {
    const header = getHeaderElement();
    const headerMode = getSetting('header');
    if (header) {
      setExclusiveClass(header, ['headroom-top', 'headroom-end', 'headroom-hide', 'headroom-show', 'headroom-none'], 'headroom-' + HEADROOM.position);
      header.classList.toggle('header-hidden', !isHeaderShown(headerMode, HEADROOM.position, HEADROOM.visible));
    }
    const menu = document.getElementById('menu');
    if (menu) {
      menu.classList.toggle('hide', HEADROOM.position === 'top' || HEADROOM.position === 'end');
    }
  }

  /** Recomputes the header position from the current scroll (scroll listener, throttled). */
  function updateHeadroomOnScroll() {
    const headerMode = getSetting('header');
    const scrollY = window.pageYOffset;
    const position = computeHeadroomPosition({
      scrollY: scrollY,
      previousScrollY: HEADROOM.previousScrollY,
      viewportHeight: window.innerHeight,
      documentHeight: document.body ? document.body.scrollHeight : 0,
      headerMode: headerMode,
      zoomMode: getSetting('zoomMode')
    });
    HEADROOM.position = position;
    if (headerMode === 'scroll') {
      HEADROOM.visible = position !== 'hide';
    }
    HEADROOM.previousScrollY = scrollY;
    renderHeaderState();
  }

  /**
   * In hover and scroll modes the header shows while the mouse is in the top band (1.5 x header
   * height, NEW:9010-9016).
   * @param {MouseEvent} event - mousemove.
   */
  function updateHeadroomOnMouseMove(event) {
    const headerMode = getSetting('header');
    if (headerMode !== 'hover' && headerMode !== 'scroll') {
      return;
    }
    const inside = event.clientX >= 0 && event.clientX <= window.innerWidth && event.clientY >= 0 && event.clientY <= CONFIG.headerHeightPx * 1.5;
    if (inside !== HEADROOM.visible) {
      HEADROOM.visible = inside;
      renderHeaderState();
    }
  }

  /** Burger button: shows/hides the header in click mode (NEW:9018-9023). */
  function toggleHeaderVisibility() {
    if (getSetting('header') !== 'click') {
      return;
    }
    HEADROOM.visible = !HEADROOM.visible;
    renderHeaderState();
  }

  /** Applier of `header`: mode class on header and burger, start state (NEW:9027). */
  function applyHeaderMode(headerMode) {
    const mode = HEADER_MODES.indexOf(headerMode) === -1 ? 'scroll' : headerMode;
    setExclusiveClass(getHeaderElement(), HEADER_MODES, mode);
    setExclusiveClass(document.getElementById('menu'), HEADER_MODES, mode);
    HEADROOM.visible = !(getSetting('zoomMode') === 'height' && (mode === 'click' || mode === 'hover'));
    HEADROOM.previousScrollY = window.pageYOffset;
    HEADROOM.position = window.pageYOffset <= CONFIG.headerHeightPx && mode !== 'click' ? 'top' : 'none';
    renderHeaderState();
  }

  // ---- Thumbnails bar (setting `navbar`: bottom, left, right, disabled) ----

  /** Navbar positions (NEW:10389-10404). */
  const NAVBAR_MODES = ['bottom', 'left', 'right', 'disabled'];

  /**
   * Shows a page's image in its thumbnail once the page has loaded, or the broken icon when it
   * failed (the 2026 version never set the broken class on thumbnails, docs/05 section 4 #16).
   * Thumbnails stay empty while the bar is disabled, so no extra image is requested.
   * @param {number} page - Page number.
   */
  function renderThumbnail(page) {
    const thumbnail = document.getElementById('ThumbnailImg' + page);
    if (!thumbnail) {
      return;
    }
    const record = getImageRecord(page);
    thumbnail.classList.toggle('imgBroken', record.status === IMAGE_STATUS.FAILED);
    if (record.status === IMAGE_STATUS.LOADED && getSetting('navbar') !== 'disabled' && thumbnail.getAttribute('src') !== record.src) {
      thumbnail.setAttribute('src', record.src);
    }
  }

  /**
   * Writes the position classes of the thumbnails bar and fills its thumbnails. Does not touch
   * page sizes (also used after the bar is re-rendered for a language change).
   * @param {string} navbarMode - Setting `navbar`.
   * @returns {string} The mode actually used.
   */
  function renderNavbarElement(navbarMode) {
    const mode = NAVBAR_MODES.indexOf(navbarMode) === -1 ? 'bottom' : navbarMode;
    const navigation = document.getElementById('Navigation');
    if (navigation) {
      navigation.classList.add('hiding');
      setExclusiveClass(navigation, NAVBAR_MODES, mode);
      navigation.classList.toggle('horizontal', mode === 'bottom');
      navigation.classList.toggle('vertical', mode === 'left' || mode === 'right');
      setTimeout(function () {
        navigation.classList.remove('hiding');
      }, CONFIG.navbarFadeMs);
    }
    forEachPage(renderThumbnail);
    return mode;
  }

  /** Applier of `navbar`: bar position classes, page padding, thumbnails, page sizes. */
  function applyNavbar(navbarMode) {
    const mode = renderNavbarElement(navbarMode);
    setExclusiveClass(getReaderRoot(), ['navbar-bottom', 'navbar-left', 'navbar-right', 'navbar-disabled'], 'navbar-' + mode);
    applyGlobalZoomToAllPages();
  }

  // ---- Pagination (setting `pagination`: disabled, slider, side-arrows, both) ----

  /** Pagination modes (NEW:8097-8104). */
  const PAGINATION_MODES = ['disabled', 'slider', 'side-arrows', 'both'];

  /**
   * First/last page flags for pagination (pure). The last page is the chapter's last page number.
   * The 2026 version compared with `totalPages + begin - 1`, which is wrong when the chapter starts
   * after page 1 (NEW:8024-8026, docs/05 section 4 #7).
   * @param {number} currentPage - Page in view.
   * @param {number} begin - First page shown.
   * @param {number} last - Last page shown (manga.pages).
   * @returns {{isFirst: boolean, isLast: boolean}}
   */
  function getPaginationState(currentPage, begin, last) {
    return { isFirst: currentPage <= begin, isLast: currentPage >= last };
  }

  /** Updates everything that shows the current page: counters, select, slider, buttons, arrows. */
  function renderCurrentPageControls() {
    const range = getPageRange();
    if (!range) {
      return;
    }
    const current = APP_STATE.currentPage || range.begin;
    const state = getPaginationState(current, range.begin, range.last);
    const manga = APP_STATE.manga;
    queryAll('.mvm-current-page').forEach(function (element) {
      element.textContent = String(current);
    });
    const select = document.getElementById('GoToPage');
    if (select && select !== document.activeElement) {
      select.value = String(current);
    }
    const slider = document.getElementById('PaginationSlider');
    if (slider && slider !== document.activeElement) {
      slider.value = String(current);
    }
    [['PaginationPrevious', state.isFirst], ['PaginationNext', state.isLast]].forEach(function (pair) {
      const button = document.getElementById(pair[0]);
      if (button) {
        button.disabled = pair[1];
        button.classList.toggle('disabled', pair[1]);
      }
    });
    const left = document.getElementById('SideArrowLeft');
    if (left) {
      left.classList.toggle('unavailable', state.isFirst && !isUsableLink(manga.prev));
    }
    const right = document.getElementById('SideArrowRight');
    if (right) {
      right.classList.toggle('unavailable', state.isLast && !isUsableLink(manga.next));
    }
  }

  /** Applier of `pagination`: root classes decide what is visible. */
  function applyPagination(paginationMode) {
    const mode = PAGINATION_MODES.indexOf(paginationMode) === -1 ? 'disabled' : paginationMode;
    setExclusiveClass(getReaderRoot(), PAGINATION_MODES.map(function (name) {
      return 'pagination-' + name;
    }), 'pagination-' + mode);
    renderCurrentPageControls();
  }

  // ---- Device (setting `deviceMode`: auto, desktop, tablet, mobile) ----

  /**
   * Applier of `deviceMode`: the device class on the reader root ('mobile', 'tablet', 'desktop'),
   * which drives the phone/tablet CSS. 'auto' uses the detected device (NEW:7573-7577).
   * @param {string} deviceMode - Setting value.
   */
  function applyDeviceMode(deviceMode) {
    const device = resolveDeviceMode(deviceMode, detectDevice());
    APP_STATE.device = device;
    setExclusiveClass(getReaderRoot(), ['mobile', 'tablet', 'desktop'], device);
    logDebug('device class', device, '(deviceMode ' + deviceMode + ')');
  }

  // ---- Side panels (settings, keybindings, bookmarks, comments) ----
  // All panels are always in the page; APP_STATE.panel says which one is open ('none',
  // 'settings', 'keybindings', 'bookmarks', 'comments'). Opening one closes the others. The overlay covers the reader while
  // a panel is open; a click on it, the close buttons and the Escape key close the panels.

  /** Panel name -> element id. */
  const PANEL_ELEMENT_IDS = {
    settings: 'SettingsPanel',
    keybindings: 'KeybindingsPanel',
    bookmarks: 'BookmarksPanel',
    comments: 'CommentsPanel'
  };

  /** Writes the open/closed state of the panels and the overlay. */
  function renderPanelState() {
    Object.keys(PANEL_ELEMENT_IDS).forEach(function (name) {
      const panel = document.getElementById(PANEL_ELEMENT_IDS[name]);
      if (panel) {
        panel.classList.toggle('visible', APP_STATE.panel === name);
        panel.setAttribute('aria-hidden', APP_STATE.panel === name ? 'false' : 'true');
      }
    });
    const overlay = document.getElementById('Overlay');
    if (overlay) {
      overlay.classList.toggle('visible', APP_STATE.panel !== 'none');
    }
  }

  /**
   * Opens one panel ('settings', 'keybindings') or closes all ('none'). Leaving the keybindings
   * panel ends its edit mode (unsaved edits are dropped). Listeners get ('panel', name).
   * @param {string} name - Panel name or 'none'.
   */
  function setOpenPanel(name) {
    const panelName = PANEL_ELEMENT_IDS[name] ? name : 'none';
    if (panelName !== 'keybindings' && APP_STATE.keybindsEditing) {
      APP_STATE.keybindsEditing = false;
    }
    APP_STATE.panel = panelName;
    renderPanelState();
    const panel = document.getElementById(PANEL_ELEMENT_IDS[panelName] || '');
    if (panel && typeof panel.focus === 'function') {
      panel.focus();
    }
    logDebug('panel', panelName);
    notifyAppState('panel', panelName);
  }

  /**
   * Shows the value of a slider in its badge (<output>), with its unit.
   * @param {HTMLInputElement} slider - A settings slider with data-output.
   */
  function updateRangeOutput(slider) {
    const output = document.getElementById(slider.getAttribute('data-output') || '');
    if (output) {
      output.textContent = slider.value + (slider.getAttribute('data-unit') || '');
    }
  }

  /**
   * Shows or hides the settings rows that depend on another setting (data-visible-when), using
   * the stored values.
   */
  function renderSettingsRowVisibility() {
    queryAll('#SettingsPanel [data-visible-when]').forEach(function (row) {
      row.classList.toggle('mvm-row-hidden', !isRowConditionMet(row.getAttribute('data-visible-when'), getStoredSetting));
    });
  }

  /**
   * Makes the settings panel controls of one setting show its STORED value (after a save, a
   * reset, a scope switch or a change in another tab). Generic: works for every control type
   * built in section 12 (checkbox, slider, select, color input, choice buttons).
   * @param {string} key - Setting name.
   */
  function syncSettingsControls(key) {
    const panel = document.getElementById('SettingsPanel');
    if (!panel || !Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) {
      return;
    }
    const value = getStoredSetting(key);
    queryAll('[data-setting="' + key + '"]', panel).forEach(function (control) {
      if (control.tagName === 'INPUT' && control.type === 'checkbox') {
        control.checked = value === true;
      } else if (control.tagName === 'INPUT' && control.type === 'color') {
        control.value = resolveThemeColor(value).toLowerCase();
      } else if (control.tagName === 'INPUT' || control.tagName === 'SELECT') {
        if (control !== document.activeElement || control.type === 'range') {
          control.value = String(value);
        }
        if (control.hasAttribute('data-output')) {
          updateRangeOutput(control);
        }
      } else if (control.hasAttribute('data-value')) {
        const selected = String(control.getAttribute('data-value')).toLowerCase() === String(key === 'theme' ? resolveThemeColor(value) : value).toLowerCase();
        control.classList.toggle('selected', selected);
        if (control.getAttribute('role') === 'radio') {
          control.setAttribute('aria-checked', selected ? 'true' : 'false');
        }
      }
    });
    if (key === 'theme') {
      const shades = document.getElementById('ThemeShades');
      if (shades) {
        setElementHtml(shades, buildThemeShadesHtml(value));
      }
    }
    renderSettingsRowVisibility();
  }

  /** Draws the settings panel again (after a scope switch or a reset); keeps it open. */
  function refreshSettingsPanel() {
    const panel = document.getElementById('SettingsPanel');
    if (!panel) {
      return;
    }
    setElementOuterHtml(panel, buildSettingsPanelHtml());
    renderPanelState();
  }

  // ---- Language (setting `locale`) ----

  /**
   * Replaces an element (found by id) with new HTML. Returns false when it is not in the page.
   * @param {string} elementId - Id of the element to replace.
   * @param {string} html - New HTML (one element).
   * @returns {boolean}
   */
  function replaceElementHtml(elementId, html) {
    const element = document.getElementById(elementId);
    if (!element) {
      return false;
    }
    setElementOuterHtml(element, html);
    return true;
  }

  /** Gives the per-page buttons and the "[ End ]" separator their texts in the current language. */
  function relabelPages() {
    PAGE_BUTTONS.forEach(function (button) {
      const text = getLocaleString(button.labelKey);
      queryAll('#Chapter .PageFunctions .' + button.className).forEach(function (element) {
        element.setAttribute('title', text);
        element.setAttribute('aria-label', text);
      });
    });
    const lastSeparator = lastItem(queryAll('#Chapter .separator'));
    if (lastSeparator) {
      lastSeparator.textContent = '[ ' + getLocaleString('END') + ' ]';
    }
  }

  /**
   * Applier of `locale` (runs on change only, not at startup): draws the header, burger, thumbnails
   * bar, pagination and both panels again with the new texts, then restores their state. Pages and
   * their images are NOT re-rendered (no image is requested again, per-page zoom stays); only their
   * button tooltips and the end separator get new texts. Listeners get ('textsRefreshed').
   */
  function refreshLocalizedTexts() {
    const manga = APP_STATE.manga;
    if (!manga || !getReaderRoot()) {
      return;
    }
    log('Language changed to ' + getSetting('locale') + ': refreshing the reader texts');
    replaceElementHtml('menu', buildMenuButtonHtml());
    replaceElementHtml('Header', buildHeaderHtml(manga));
    replaceElementHtml('Navigation', buildNavbarHtml(manga));
    replaceElementHtml('Pagination', buildPaginationHtml(manga));
    replaceElementHtml('SettingsPanel', buildSettingsPanelHtml());
    replaceElementHtml('KeybindingsPanel', buildKeybindingsPanelHtml());
    replaceElementHtml('BookmarksPanel', buildBookmarksPanelHtml());
    replaceElementHtml('CommentsPanel', buildCommentsPanelHtml());
    applyBookmarksState();
    const headerMode = HEADER_MODES.indexOf(getSetting('header')) === -1 ? 'scroll' : getSetting('header');
    setExclusiveClass(getHeaderElement(), HEADER_MODES, headerMode);
    setExclusiveClass(document.getElementById('menu'), HEADER_MODES, headerMode);
    renderHeaderState();
    markSelectedButton('ViewModeGroup', getSetting('viewMode'));
    renderZoomControls();
    applyMinZoom(getSetting('minZoom'));
    applyHidePageControls(getSetting('hidePageControls'));
    renderNavbarElement(getSetting('navbar'));
    applyPagination(getSetting('pagination'));
    relabelPages();
    renderPanelState();
    notifyAppState('textsRefreshed', getSetting('locale'));
  }

  // ---- Reactions to image and current-page changes ----

  /**
   * After a page image loaded: remember its natural size, mark wide images as double pages
   * (NEW:11200), apply the global zoom to it, refresh book pairing and its thumbnail.
   * @param {number} page - Page number.
   */
  function onPageImageLoaded(page) {
    const image = getPageImage(page);
    const record = getImageRecord(page);
    const layout = getPageLayout(page);
    if (!image || layout.processedSource === record.src) {
      return;
    }
    layout.processedSource = record.src;
    layout.naturalWidth = image.naturalWidth || 0;
    layout.naturalHeight = image.naturalHeight || 0;
    layout.doublePage = layout.naturalWidth > layout.naturalHeight;
    applyGlobalZoomToPage(page);
    if (isBookViewMode(getSetting('viewMode'))) {
      applyBookPairing();
    }
  }

  onAppStateChange(function (what, value) {
    if (what === 'image' && value && value.page) {
      if (value.status === IMAGE_STATUS.LOADED) {
        onPageImageLoaded(value.page);
      }
      renderThumbnail(value.page);
    } else if (what === 'currentPage') {
      renderCurrentPageControls();
    }
  });

  // ---- THE TABLE ----

  /**
   * THE TABLE. Setting name -> function(newValue). Settings not listed here do not change the
   * screen directly (for example `loadSpeed` is read when loading starts, `zoomStep` when a zoom
   * button is used). Order matters only for applyAllSettings(): the bar position comes before the
   * zoom because it changes the space available to pages.
   */
  const SETTING_APPLIERS = {
    colorScheme: applyThemeSettings,
    theme: applyThemeSettings,
    header: applyHeaderMode,
    navbar: applyNavbar,
    pagination: applyPagination,
    viewMode: applyViewMode,
    fitWidthIfOversize: applyFitWidthIfOversize,
    minZoom: applyMinZoom,
    zoomMode: applyZoomSettings,
    zoomValue: applyZoomSettings,
    hidePageControls: applyHidePageControls,
    bookmarks: applyBookmarksState,
    deviceMode: applyDeviceMode,
    locale: refreshLocalizedTexts
    // keybinds: added by section 17 (redraws the keybindings panel)
  };

  /**
   * Appliers that run only when their setting CHANGES, not in applyAllSettings() at startup
   * (the reader is drawn in the right language from the start).
   */
  const APPLY_ON_CHANGE_ONLY = ['locale'];

  /** Runs every applier with the current values (used once after the takeover). */
  function applyAllSettings() {
    Object.keys(SETTING_APPLIERS).forEach(function (key) {
      if (APPLY_ON_CHANGE_ONLY.indexOf(key) !== -1) {
        return;
      }
      safeRun('applier:' + key, function () {
        SETTING_APPLIERS[key](getSetting(key));
      }, undefined, { key: key });
    });
  }

  // Any setting change calls its applier (if it has one), then the settings panel controls of
  // that setting show the stored value again.
  onSettingChange('*', function (newValue, oldValue, key) {
    const applier = SETTING_APPLIERS[key];
    if (applier) {
      logDebug('applier run', key, newValue);
      applier(newValue);
    }
    syncSettingsControls(key);
  });


  // ==========================================================================================
  // ==== 15. IMAGE LOADING ====================================================================
  // Pages are shown with plain <img src="..."> (no fetch of image bytes), so it works without
  // CORS and asks the site for each image only once. A queue limits how many images load at the
  // same time and spaces their start (setting loadSpeed, or the site's `timer`).
  // Strategies (what the site module returned):
  //   'images'     - listImages: direct image URLs
  //   'pages'      - listPages + img (+ lazyAttr): fetch each page's HTML, read the image URL
  //   'bruteForce' - the site module drives loading itself through a small API
  // Lazy loading (decision D3; setting `lazyLoadImages`, or the site's `manga.lazy` which wins):
  // the first `lazyStart` + 1 pages are queued at once (NEW:10944-10958), the others wait in
  // LOADER.deferred until a rule asks for them. Setting `lazyLoadRule`:
  //   'distance'  - 2025 rule: a page within CONFIG.lazyDistancePx of the window (any direction,
  //                 also sideways in the horizontal modes) is queued; checked on scroll,
  //                 touchmove, resize, chapter scroll, layout changes (throttled)
  //   'lookAhead' - 2026 rule: when the current page moves forward, the next
  //                 CONFIG.lookAheadPages pages (from the current one) are queued
  //   'both'      - both rules
  // Any page can also be loaded on demand (go-to-page select, thumbnails, pagination):
  // requestPageLoad(page) puts it at the FRONT of the queue. bruteForce sites are never lazy
  // (the 2026 version did not make them lazy either).
  // ==========================================================================================

  /** Loader state. Reset for every chapter by startImageLoading(). */
  const LOADER = {
    manga: null,
    strategy: '',
    queue: [],
    activeCount: 0,
    lastStartTime: 0,
    concurrency: 10,
    delayMs: 250,
    pumpTimer: null,
    completionLogged: false,
    lazyActive: false,
    lazyRule: 'distance',
    deferred: {},
    lazyListenersAttached: false,
    lastLookAheadPage: 0
  };

  /** The values of the `lazyLoadRule` setting. */
  const LAZY_LOAD_RULES = ['distance', 'lookAhead', 'both'];

  /**
   * Which loading strategy a manga object uses: 'images', 'pages', 'bruteForce' or ''.
   * @param {Object} manga - The manga object.
   * @returns {string}
   */
  function getLoadingStrategy(manga) {
    if (Array.isArray(manga.listImages) && manga.listImages.length > 0) {
      return 'images';
    }
    if (Array.isArray(manga.listPages) && manga.listPages.length > 0) {
      return 'pages';
    }
    if (typeof manga.bruteForce === 'function') {
      return 'bruteForce';
    }
    return '';
  }

  /**
   * Adds a page to the queue. The `resolveSource` function returns the image URL (or a Promise of
   * it) when the page's turn comes.
   * @param {number} page - Page number.
   * @param {function(): (string|Promise<string>)} resolveSource - Produces the image URL.
   * @param {boolean} [urgent] - True to put the page at the FRONT of the queue (load on demand).
   */
  function enqueuePage(page, resolveSource, urgent) {
    updateImageRecord(page, { status: IMAGE_STATUS.WAITING });
    const job = { page: page, resolveSource: resolveSource };
    if (urgent === true) {
      LOADER.queue.unshift(job);
    } else {
      LOADER.queue.push(job);
    }
    pumpQueue();
  }

  /**
   * Starts queued pages while there is a free slot and the delay since the last start passed.
   * Schedules itself again when it has to wait.
   */
  function pumpQueue() {
    if (LOADER.pumpTimer !== null) {
      return;
    }
    while (LOADER.queue.length > 0 && LOADER.activeCount < LOADER.concurrency) {
      const waitMs = LOADER.delayMs - (Date.now() - LOADER.lastStartTime);
      if (waitMs > 0) {
        LOADER.pumpTimer = setTimeout(function () {
          LOADER.pumpTimer = null;
          pumpQueue();
        }, waitMs);
        return;
      }
      const job = LOADER.queue.shift();
      LOADER.activeCount += 1;
      LOADER.lastStartTime = Date.now();
      startPageJob(job);
    }
  }

  /**
   * Gives a slot back and lets the next page start. Safe to call once per job only (guarded by
   * the job's `released` flag).
   * @param {Object} job - The queue job.
   */
  function releaseSlot(job) {
    if (job.released) {
      return;
    }
    job.released = true;
    LOADER.activeCount = Math.max(0, LOADER.activeCount - 1);
    pumpQueue();
  }

  /**
   * Resolves the page's image URL, then sets it on the <img>.
   * @param {Object} job - { page, resolveSource }.
   */
  async function startPageJob(job) {
    updateImageRecord(job.page, { status: IMAGE_STATUS.LOADING });
    let source = '';
    try {
      source = normalizeUrl(await job.resolveSource());
    } catch (error) {
      logError('imageLoading.resolveSource', error, { page: job.page });
    }
    if (source === '') {
      logWarn('Page ' + job.page + ': no image URL, marking it as failed');
      markPageFailed(job.page);
      releaseSlot(job);
      return;
    }
    setPageImageSource(job, source);
  }

  /**
   * Sets the image URL and waits for load/error. A slot is released on load, on error, or after
   * CONFIG.imageLoadTimeoutMs (a very slow image keeps loading but no longer blocks the queue).
   * @param {Object} job - The queue job.
   * @param {string} source - Image URL.
   */
  function setPageImageSource(job, source) {
    const image = getPageImage(job.page);
    if (!image) {
      logWarn('Page ' + job.page + ': <img> element not found');
      releaseSlot(job);
      return;
    }
    const slowTimer = setTimeout(function () {
      logDebug('Page ' + job.page + ': still loading after ' + CONFIG.imageLoadTimeoutMs + ' ms, freeing its slot');
      releaseSlot(job);
    }, CONFIG.imageLoadTimeoutMs);
    image.onload = guardHandler('image.onload', function () {
      clearTimeout(slowTimer);
      image.classList.remove('imgBroken');
      updateImageRecord(job.page, { status: IMAGE_STATUS.LOADED, src: source });
      logDebug('Page ' + job.page + ' loaded', source);
      releaseSlot(job);
    });
    image.onerror = guardHandler('image.onerror', function () {
      clearTimeout(slowTimer);
      releaseSlot(job);
      handleImageError(job.page, source);
    });
    updateImageRecord(job.page, { src: source });
    image.setAttribute('src', source);
  }

  /**
   * Retries a broken image after a delay with a cache-busting parameter, up to the setting
   * maxReload. After that the page is marked failed (it can still be reloaded by hand).
   * @param {number} page - Page number.
   * @param {string} source - The URL that failed.
   */
  function handleImageError(page, source) {
    const record = getImageRecord(page);
    const attempts = record.attempts + 1;
    const maxAttempts = parseInt(getSetting('maxReload'), 10) || 0;
    updateImageRecord(page, { attempts: attempts });
    if (attempts > maxAttempts || isInMemoryUrl(source)) {
      logWarn('Page ' + page + ' failed after ' + record.attempts + ' retries', source);
      markPageFailed(page);
      return;
    }
    log('Page ' + page + ' failed to load, retry ' + attempts + '/' + maxAttempts + ' in ' + CONFIG.imageRetryDelayMs + ' ms');
    setTimeout(function () {
      const original = source.replace(/([?&])forceReload=\d+$/, '');
      enqueuePage(page, function () {
        return addCacheBuster(original, attempts);
      });
    }, CONFIG.imageRetryDelayMs);
  }

  /** Marks a page as failed and shows it. */
  function markPageFailed(page) {
    updateImageRecord(page, { status: IMAGE_STATUS.FAILED });
    const image = getPageImage(page);
    if (image) {
      image.classList.add('imgBroken');
    }
    const container = document.getElementById('Page' + page);
    if (container) {
      container.classList.add('failed');
    }
  }

  /**
   * Manual reload of one page (button). Resets its retry counter and loads it again.
   * @param {number} page - Page number.
   */
  function reloadPageImage(page) {
    const record = getImageRecord(page);
    const container = document.getElementById('Page' + page);
    if (container) {
      container.classList.remove('failed');
    }
    updateImageRecord(page, { attempts: 0 });
    delete LOADER.deferred[page];
    if (record.src && !isInMemoryUrl(record.src)) {
      const original = record.src.replace(/([?&])forceReload=\d+$/, '');
      const bust = Date.now() % 100000;
      log('Reloading page ' + page);
      enqueuePage(page, function () {
        return addCacheBuster(original, bust);
      });
      return;
    }
    const resolver = createSourceResolver(page);
    if (resolver) {
      enqueuePage(page, resolver);
    } else {
      logWarn('Reload: no way to find the image of page ' + page);
    }
  }

  /**
   * Builds the function that finds the image URL of a page, for the current strategy.
   * @param {number} page - Page number.
   * @returns {function(): (string|Promise<string>)|null}
   */
  function createSourceResolver(page) {
    const manga = LOADER.manga;
    if (!manga) {
      return null;
    }
    if (LOADER.strategy === 'images') {
      return function () {
        return manga.listImages[page - 1] || '';
      };
    }
    if (LOADER.strategy === 'pages') {
      return function () {
        return getElementAttribute(manga.listPages[page - 1] || '', manga.img, manga.lazyAttr || 'src').then(function (source) {
          if (source !== '' || typeof manga.reload !== 'function') {
            return source;
          }
          // Some sites: the page HTML alone has no usable image (anti-hotlink token);
          // the site module re-fetches the page with a fresh token. Errors reject and the page fails.
          log('Page ' + page + ': direct read failed, trying the site reload()');
          return Promise.resolve(manga.reload(page));
        });
      };
    }
    return null;
  }

  /**
   * Starts loading a chapter. Called once after the takeover.
   * @param {Object} manga - Validated manga object (begin and pages already set).
   */
  function startImageLoading(manga) {
    const preset = CONFIG.loadSpeedPresets[getSetting('loadSpeed')] || CONFIG.loadSpeedPresets.Safe;
    LOADER.manga = manga;
    LOADER.strategy = getLoadingStrategy(manga);
    LOADER.queue = [];
    LOADER.activeCount = 0;
    LOADER.concurrency = preset.concurrency;
    LOADER.delayMs = typeof manga.timer === 'number' && manga.timer > 0 ? manga.timer : preset.delayMs;
    LOADER.completionLogged = false;
    LOADER.deferred = {};
    LOADER.lastLookAheadPage = manga.begin;
    LOADER.lazyActive = LOADER.strategy !== 'bruteForce' && isLazyLoadingWanted(manga);
    LOADER.lazyRule = LAZY_LOAD_RULES.indexOf(getSetting('lazyLoadRule')) === -1 ? 'distance' : getSetting('lazyLoadRule');
    APP_STATE.images = {};
    log('Loading images: strategy "' + LOADER.strategy + '", pages ' + manga.begin + '-' + manga.pages + ', ' + LOADER.concurrency + ' at a time, ' + LOADER.delayMs + ' ms apart' + (LOADER.lazyActive ? ', lazy (rule "' + LOADER.lazyRule + '", first ' + (Number(getSetting('lazyStart')) + 1) + ' pages at once)' : ''));

    if (LOADER.strategy === 'bruteForce') {
      startBruteForceLoading(manga);
      return;
    }
    const eagerPages = LOADER.lazyActive ? computeEagerPages(manga.begin, manga.pages, Number(getSetting('lazyStart'))) : sequence(manga.pages, manga.begin);
    sequence(manga.pages, manga.begin).forEach(function (page) {
      if (eagerPages.indexOf(page) === -1) {
        LOADER.deferred[page] = true;
        return;
      }
      const resolver = createSourceResolver(page);
      if (resolver) {
        enqueuePage(page, resolver);
      }
    });
    if (LOADER.lazyActive) {
      logDebug('lazy: deferred pages', Object.keys(LOADER.deferred).join(','));
      attachLazyLoadListeners();
      scheduleLazyDistanceCheck();
    }
  }

  // ---- Lazy loading ----

  /**
   * True when this chapter loads lazily: the site's `manga.lazy` (true/false) wins over the
   * `lazyLoadImages` setting (NEW:10945, OLD:1940).
   * @param {Object} manga - The manga object.
   * @returns {boolean}
   */
  function isLazyLoadingWanted(manga) {
    if (manga && typeof manga.lazy === 'boolean') {
      return manga.lazy;
    }
    return getSetting('lazyLoadImages') === true;
  }

  /**
   * Pages queued at once when lazy loading is on (pure): positions 0..lazyStart counted from
   * `begin`, i.e. lazyStart + 1 pages (NEW:10944-10958: `position <= lazyStart`).
   * @param {number} begin - First page.
   * @param {number} last - Last page.
   * @param {number} lazyStart - Setting `lazyStart` (invalid -> 0).
   * @returns {number[]}
   */
  function computeEagerPages(begin, last, lazyStart) {
    const count = Math.max(0, Math.floor(Number(lazyStart)) || 0) + 1;
    return sequence(Math.min(last, begin + count - 1), begin);
  }

  /**
   * True when a rectangle is within `distance` pixels of the window on both axes (pure).
   * @param {{top: number, bottom: number, left: number, right: number}} rect - Element box (viewport coordinates).
   * @param {number} viewportWidth - Window width.
   * @param {number} viewportHeight - Window height.
   * @param {number} distance - Extra margin around the window, in pixels.
   * @returns {boolean}
   */
  function isRectNearViewport(rect, viewportWidth, viewportHeight, distance) {
    return rect.bottom >= -distance && rect.top <= viewportHeight + distance && rect.right >= -distance && rect.left <= viewportWidth + distance;
  }

  /**
   * The pages whose box is near the window (pure; the 2025 rule, OLD:1221-1227, extended to the
   * horizontal axis for the Fluid modes). Works the same when the reader jumped backward.
   * @param {{page: number, rect: Object}[]} entries - Page boxes.
   * @param {number} viewportWidth - Window width.
   * @param {number} viewportHeight - Window height.
   * @param {number} distance - CONFIG.lazyDistancePx.
   * @returns {number[]}
   */
  function selectPagesNearViewport(entries, viewportWidth, viewportHeight, distance) {
    return entries.filter(function (entry) {
      return isRectNearViewport(entry.rect, viewportWidth, viewportHeight, distance);
    }).map(function (entry) {
      return entry.page;
    });
  }

  /**
   * Pages the 2026 look-ahead rule wants (pure): from the current page, `count` pages, only
   * when the current page moved FORWARD (NEW:11002-11008). [] when it did not move forward.
   * @param {number} currentPage - Page in view now.
   * @param {number} previousPage - Highest page seen before.
   * @param {number} last - Last page.
   * @param {number} count - CONFIG.lookAheadPages.
   * @returns {number[]}
   */
  function selectLookAheadPages(currentPage, previousPage, last, count) {
    if (!(currentPage > previousPage)) {
      return [];
    }
    return sequence(Math.min(last, currentPage + count - 1), currentPage);
  }

  /** True when the active lazy rule includes `rule` ('distance' or 'lookAhead'). */
  function isLazyRuleActive(rule) {
    return LOADER.lazyActive && (LOADER.lazyRule === rule || LOADER.lazyRule === 'both');
  }

  /**
   * Queues a deferred page (lazy mode). Does nothing for a page that is not deferred.
   * @param {number} page - Page number.
   * @param {boolean} urgent - True to put it at the front of the queue.
   * @param {string} reason - For the debug log ('distance', 'lookAhead', 'on demand').
   * @returns {boolean} True when the page was queued.
   */
  function queueDeferredPage(page, urgent, reason) {
    if (!LOADER.deferred[page]) {
      return false;
    }
    delete LOADER.deferred[page];
    const resolver = createSourceResolver(page);
    if (!resolver) {
      logWarn('lazy: no way to find the image of page ' + page);
      return false;
    }
    logDebug('lazy: queue page ' + page + ' (' + reason + ')');
    enqueuePage(page, resolver, urgent);
    return true;
  }

  /**
   * Load on demand: a page the user wants to see now (go-to-page, thumbnail, pagination) is
   * queued at the front when it was still waiting for a lazy rule. Safe to call for any page.
   * @param {number} page - Page number.
   * @returns {boolean} True when the page was queued now.
   */
  function requestPageLoad(page) {
    return queueDeferredPage(page, true, 'on demand');
  }

  /** The distance rule: queues every deferred page that is near the window now. */
  function checkLazyPagesByDistance() {
    if (!isLazyRuleActive('distance')) {
      return;
    }
    const pending = Object.keys(LOADER.deferred);
    if (pending.length === 0) {
      return;
    }
    const entries = [];
    pending.forEach(function (key) {
      const container = document.getElementById('Page' + key);
      if (container) {
        entries.push({ page: parseInt(key, 10), rect: container.getBoundingClientRect() });
      }
    });
    selectPagesNearViewport(entries, window.innerWidth, window.innerHeight, CONFIG.lazyDistancePx).sort(function (first, second) {
      return first - second;
    }).forEach(function (page) {
      queueDeferredPage(page, false, 'distance');
    });
  }

  /** Throttled distance check (scroll, touchmove, resize, layout changes). */
  const scheduleLazyDistanceCheck = throttle(function () {
    safeRun('lazy.distanceCheck', checkLazyPagesByDistance);
  }, CONFIG.lazyCheckThrottleMs);

  /**
   * The look-ahead rule: when the current page moved forward, queue the next pages.
   * @param {number} currentPage - Page in view.
   */
  function onLazyCurrentPage(currentPage) {
    if (!isLazyRuleActive('lookAhead') || !LOADER.manga) {
      return;
    }
    const pages = selectLookAheadPages(currentPage, LOADER.lastLookAheadPage, LOADER.manga.pages, CONFIG.lookAheadPages);
    if (currentPage > LOADER.lastLookAheadPage) {
      LOADER.lastLookAheadPage = currentPage;
    }
    pages.forEach(function (page) {
      queueDeferredPage(page, false, 'lookAhead');
    });
  }

  /**
   * Attaches the distance-rule listeners once (window scroll, touchmove, resize; chapter scroll
   * for the horizontal modes). They do nothing when no page is deferred.
   */
  function attachLazyLoadListeners() {
    if (LOADER.lazyListenersAttached) {
      return;
    }
    const handler = guardHandler('lazy.listener', function () {
      scheduleLazyDistanceCheck();
    });
    window.addEventListener('scroll', handler, { passive: true });
    window.addEventListener('touchmove', handler, { passive: true });
    window.addEventListener('resize', handler);
    const chapter = document.getElementById('Chapter');
    if (chapter) {
      chapter.addEventListener('scroll', handler, { passive: true });
    }
    LOADER.lazyListenersAttached = true;
    logDebug('lazy: listeners attached');
  }

  // Layout changes move pages: check the distance rule again. The current page drives the
  // look-ahead rule.
  ['viewMode', 'zoomMode', 'zoomValue', 'navbar'].forEach(function (key) {
    onSettingChange(key, function () {
      if (LOADER.lazyActive) {
        scheduleLazyDistanceCheck();
      }
    });
  });
  onAppStateChange(function (what, value) {
    if (!LOADER.lazyActive) {
      return;
    }
    if (what === 'image') {
      scheduleLazyDistanceCheck();
    } else if (what === 'currentPage') {
      onLazyCurrentPage(value);
    }
  });

  /**
   * Gives a site module's bruteForce function a small API to feed images itself.
   * Same context fields as the earlier versions: begin, addImg, loadImages, loadPages, wait.
   * @param {Object} manga - The manga object with a bruteForce function.
   */
  function startBruteForceLoading(manga) {
    const context = {
      begin: manga.begin,
      wait: LOADER.delayMs,
      addImg: function (ignoredManga, page, source) {
        enqueuePage(page, function () {
          return source;
        });
      },
      loadImages: function (list) {
        LOADER.manga = Object.assign({}, manga, { listImages: list });
        LOADER.strategy = 'images';
        sequence(manga.pages, manga.begin).forEach(function (page) {
          enqueuePage(page, createSourceResolver(page));
        });
      },
      loadPages: function (list, imageSelector, lazyAttribute) {
        LOADER.manga = Object.assign({}, manga, { listPages: list, img: imageSelector, lazyAttr: lazyAttribute });
        LOADER.strategy = 'pages';
        sequence(manga.pages, manga.begin).forEach(function (page) {
          enqueuePage(page, createSourceResolver(page));
        });
      }
    };
    safeRunAsync('site.bruteForce', function () {
      return Promise.resolve(manga.bruteForce(context));
    });
  }

  /** Updates counters, tab title and progress bar after any image change. */
  function updateProgress() {
    const manga = APP_STATE.manga;
    if (!manga) {
      return;
    }
    const counts = countImages();
    const total = manga.pages - manga.begin + 1;
    const done = counts.loaded + counts.failed;
    const percentage = total > 0 ? Math.floor((counts.loaded / total) * 100) : 0;
    const root = getReaderRoot();
    if (!root) {
      return;
    }
    queryAll('.mvm-loaded-count', root).forEach(function (element) {
      element.textContent = String(counts.loaded);
    });
    queryAll('.mvm-total-count', root).forEach(function (element) {
      element.textContent = String(total);
    });
    document.title = '(' + percentage + '%) ' + manga.title;
    const bar = document.getElementById('ProgressBar');
    if (bar) {
      bar.querySelector('.mvm-progress-fill').style.width = percentage + '%';
      bar.classList.toggle('complete', done >= total);
    }
    if (done >= total && !LOADER.completionLogged) {
      LOADER.completionLogged = true;
      log('Image loading finished: ' + counts.loaded + ' loaded, ' + counts.failed + ' failed, of ' + total);
    }
  }

  // Every image change updates the progress display; so does a redraw of the header and the
  // thumbnails bar for a language change (their counters are drawn empty).
  onAppStateChange(function (what) {
    if (what === 'image' || what === 'textsRefreshed') {
      updateProgress();
    }
  });

  // ==========================================================================================
  // ==== 16. ACTIONS ==========================================================================
  // ONE table of everything the user can trigger. A click on an element with
  // data-action="<name>" runs ACTIONS[name] (section 18); the keyboard (section 17)
  // uses the same table through KEYBIND_ACTIONS (keybind id -> action name).
  // Every action receives a context: { element, page, value, setting, event } (any may be
  // missing: a key press has no element and no page). Names are kebab-case.
  // ==========================================================================================

  /**
   * Opens a chapter link safely: only http(s) or relative links are followed.
   * @param {string} url - Link from the site module.
   * @param {string} what - For the log, e.g. 'next chapter'.
   * @returns {boolean} True when navigation started.
   */
  function navigateTo(url, what) {
    const href = resolveChapterHref(url);
    if (href === '') {
      log('No usable link for ' + what + (isUsableLink(url) ? ' (invalid or blocked: ' + url + ')' : ''));
      return false;
    }
    log('Opening ' + what + ':', href);
    window.location.href = href;
    return true;
  }

  /** Go to the next chapter. */
  function actionNextChapter() {
    navigateTo(APP_STATE.manga ? APP_STATE.manga.next : '', 'next chapter');
  }

  /** Go to the previous chapter. */
  function actionPreviousChapter() {
    navigateTo(APP_STATE.manga ? APP_STATE.manga.prev : '', 'previous chapter');
  }

  /** Go to the series page; without a link, go back in history (same as the 2026 version). */
  function actionSeries() {
    const series = APP_STATE.manga ? APP_STATE.manga.series : '';
    if (!navigateTo(series, 'chapter list')) {
      window.history.back();
    }
  }

  /**
   * Page number an action works on: the clicked element's data-page, else the page in view (key
   * presses have no element).
   * @param {Object} context - Action context.
   * @returns {number} 0 when unknown.
   */
  function getActionPage(context) {
    if (context && context.page) {
      return context.page;
    }
    return APP_STATE.currentPage || 0;
  }

  /**
   * Wraps a per-page function into an action that logs a warning when no page is known.
   * Generic: used for every per-page button.
   * @param {string} name - Action name (for the log).
   * @param {function(number): void} pageFunction - Work on one page.
   * @returns {function(Object): void}
   */
  function createPageAction(name, pageFunction) {
    return function (context) {
      const page = getActionPage(context);
      if (!page) {
        logWarn(name + ': no page number');
        return;
      }
      pageFunction(page);
    };
  }

  /**
   * Builds the action of one view-mode button (session-only change, docs/04 product decision
   * table item 14: header view mode is not saved).
   * @param {string} viewMode - One of VIEW_MODES.
   * @returns {function(): void}
   */
  function createViewModeAction(viewMode) {
    return function () {
      switchViewMode(viewMode);
    };
  }

  /** Reload one page image. */
  function actionReloadPage(context) {
    if (!context.page) {
      logWarn('reload-page: no page number on the clicked element');
      return;
    }
    reloadPageImage(context.page);
  }

  /** Header slider: percent zoom, live (session only). */
  function actionZoomSlider(context) {
    const value = parseInt(context.value, 10);
    if (isNaN(value)) {
      logWarn('zoom-slider: not a number', context.value);
      return;
    }
    setGlobalZoom('percent', value);
  }

  /** Go-to-page select, pagination slider and thumbnails: scroll to that page. */
  function actionGoToPage(context) {
    const page = context.value !== undefined ? parseInt(context.value, 10) : context.page;
    if (!page || isNaN(page)) {
      logWarn('go-to-page: no page number', context.value);
      return;
    }
    requestPageLoad(page);
    scrollToPage(page);
  }

  /**
   * Scrolls one page back (-1) or forward (+1) from the page in view, within the chapter.
   * @param {number} direction - -1 or 1.
   */
  function goToNeighborPage(direction) {
    const range = getPageRange();
    if (!range) {
      return;
    }
    const target = clamp((APP_STATE.currentPage || range.begin) + direction, range.begin, range.last);
    requestPageLoad(target);
    scrollToPage(target);
  }

  /** Left side arrow: previous page, or previous chapter on the first page (NEW:8105-8108). */
  function actionSideArrowPrevious() {
    const range = getPageRange();
    if (range && getPaginationState(APP_STATE.currentPage || range.begin, range.begin, range.last).isFirst) {
      actionPreviousChapter();
      return;
    }
    goToNeighborPage(-1);
  }

  /** Right side arrow: next page, or next chapter on the LAST page (fixed last-page check). */
  function actionSideArrowNext() {
    const range = getPageRange();
    if (range && getPaginationState(APP_STATE.currentPage || range.begin, range.begin, range.last).isLast) {
      actionNextChapter();
      return;
    }
    goToNeighborPage(1);
  }

  /** Header button and key L: hide/show all page controls, SAVED (decision D5). */
  function actionTogglePageControls() {
    saveSetting('hidePageControls', getSetting('hidePageControls') !== true);
  }

  /**
   * Bookmark toggle of a page button: if this chapter is bookmarked, remove the bookmark;
   * otherwise bookmark THE CLICKED PAGE (the 2026 version used the page in view instead,
   * docs/05 section 4 #3). Saved at once.
   * @param {number} page - Clicked page.
   */
  function toggleBookmarkForPage(page) {
    // A bookmark changed by hand is no longer the one used to resume (no automatic removal).
    APP_STATE.resumedBookmarkPage = 0;
    if (getBookmarkedPage() > 0) {
      removeBookmark();
      log(getLocaleString('BOOKMARK_REMOVED'), window.location.href);
      showToast({ type: 'error', title: getLocaleString('BOOKMARK_REMOVED'), durationMs: CONFIG.bookmarkToastMs });
      return;
    }
    saveBookmark(page, window.location.href, APP_STATE.manga ? APP_STATE.manga.title : '');
    const message = getLocaleString('BOOKMARK_MESSAGE').replace('##num##', String(page));
    log(getLocaleString('BOOKMARK_SAVED') + ': ' + message, window.location.href);
    showToast({ type: 'success', title: getLocaleString('BOOKMARK_SAVED'), message: message, durationMs: CONFIG.bookmarkToastMs });
  }

  // ---- Keyboard scrolling (SCROLL_UP / SCROLL_DOWN, docs/05 section 2.15, NEW:7718-7776) ----

  /**
   * Target of a one-page step in Fit Height mode (pure, NEW:7725-7729): 0 = top of the window,
   * -1 = do nothing (past the last page), else the page number.
   * @param {number} currentPage - Page in view.
   * @param {number} begin - First page.
   * @param {number} last - Last page.
   * @param {number} direction - -1 (up) or 1 (down).
   * @returns {number}
   */
  function computePageStepTarget(currentPage, begin, last, direction) {
    const target = (currentPage || begin) + direction;
    if (target > last) {
      return -1;
    }
    return target < begin ? 0 : target;
  }

  /**
   * True when a page starts a row in Book/Manga mode (pure, NEW:7741-7749): a double page, or a
   * page preceded by an even number of single pages since the previous double page.
   * @param {number} page - Page number.
   * @param {number} begin - First page.
   * @param {number} last - Last page.
   * @param {Object<number, boolean>} doublePages - page -> true for double pages.
   * @returns {boolean}
   */
  function isBookRowStart(page, begin, last, doublePages) {
    if (page < begin || page > last) {
      return false;
    }
    if (doublePages[page] === true) {
      return true;
    }
    let singles = 0;
    for (let previous = page - 1; previous >= begin; previous--) {
      if (doublePages[previous] === true) {
        break;
      }
      singles += 1;
    }
    return singles % 2 === 0;
  }

  /**
   * Page to jump to for one row step in Book/Manga + Fit Height mode (pure, NEW:7736-7767).
   * 0 = top of the window.
   * @param {number} currentPage - Page in view.
   * @param {number} begin - First page.
   * @param {number} last - Last page.
   * @param {Object<number, boolean>} doublePages - page -> true for double pages.
   * @param {number} direction - -1 (up) or 1 (down).
   * @returns {number}
   */
  function computeBookRowTarget(currentPage, begin, last, doublePages, direction) {
    const current = currentPage || begin;
    let target = current;
    if (direction > 0) {
      target = current + 1;
      while (target <= last && !isBookRowStart(target, begin, last, doublePages)) {
        target += 1;
      }
    } else {
      target = isBookRowStart(current, begin, last, doublePages) ? current - 1 : current;
      while (target > begin && !isBookRowStart(target, begin, last, doublePages)) {
        target -= 1;
      }
    }
    if (target < begin) {
      return 0;
    }
    return target > last ? last : target;
  }

  /**
   * Smooth scroll of the window or an element by an amount, with a plain fallback for browsers
   * that do not accept the options object.
   * @param {Window|Element} target - What scrolls.
   * @param {number} left - Horizontal pixels.
   * @param {number} top - Vertical pixels.
   */
  function smoothScrollBy(target, left, top) {
    try {
      target.scrollBy({ left: left, top: top, behavior: 'smooth' });
    } catch (error) {
      logDebug('smooth scrollBy not supported, scrolling instantly', describeError(error));
      if (target === window) {
        window.scrollBy(left, top);
      } else {
        target.scrollLeft += left;
        target.scrollTop += top;
      }
    }
  }

  /**
   * Scrolls the reader one step back (-1) or forward (+1), in the 2026 order (NEW:7768-7776):
   * Book/Manga with Fit Height -> next/previous row; horizontal modes -> 80% of the window width
   * sideways (reversed right to left); Fit Height -> next/previous page; else 80% of the window
   * height, smoothly.
   * @param {number} direction - -1 or 1.
   */
  function scrollReader(direction) {
    const range = getPageRange();
    const viewMode = getSetting('viewMode');
    const zoomMode = getSetting('zoomMode');
    logDebug('keyboard scroll', direction, viewMode, zoomMode);
    if (range && isBookViewMode(viewMode) && zoomMode === 'height') {
      const doublePages = {};
      sequence(range.last, range.begin).forEach(function (page) {
        doublePages[page] = getPageLayout(page).doublePage;
      });
      scrollToPage(computeBookRowTarget(APP_STATE.currentPage, range.begin, range.last, doublePages, direction));
      return;
    }
    const chapter = getChapterElement();
    if (isFluidViewMode(viewMode) && chapter) {
      smoothScrollBy(chapter, 0.8 * window.innerWidth * direction * (viewMode === 'FluidRTL' ? -1 : 1), 0);
      return;
    }
    if (range && zoomMode === 'height') {
      const target = computePageStepTarget(APP_STATE.currentPage, range.begin, range.last, direction);
      if (target >= 0) {
        scrollToPage(target);
      }
      return;
    }
    smoothScrollBy(window, 0, 0.8 * window.innerHeight * direction);
  }

  // ---- Auto-scroll speed (keys "." and ","; SAVED, decision D5) ----

  /**
   * New auto-scroll speed after one key press (pure, NEW:7560-7568): +/-25 px, limited to
   * 0 .. getAutoScrollMaximum(window height).
   * @param {number} currentSpeed - Current `scrollHeight`.
   * @param {number} direction - 1 faster, -1 slower.
   * @param {number} viewportHeight - Window height.
   * @returns {number}
   */
  function computeAutoScrollSpeed(currentSpeed, direction, viewportHeight) {
    return clamp((Number(currentSpeed) || 0) + direction * 25, 0, getAutoScrollMaximum(viewportHeight));
  }

  /**
   * Changes the auto-scroll speed by one step and SAVES it (decision D5; the 2026 version kept
   * it for this page only).
   * @param {number} direction - 1 or -1.
   */
  function changeAutoScrollSpeed(direction) {
    const speed = computeAutoScrollSpeed(getSetting('scrollHeight'), direction, window.innerHeight);
    saveSetting('scrollHeight', speed);
    log('Auto-scroll speed: ' + speed + ' px');
  }

  // ---- Settings panel actions ----

  /**
   * Converts a value read from a control (always text) to the type of the setting's default
   * (pure). Booleans accept true/'true'; numbers must parse.
   * @param {string} key - Setting name.
   * @param {*} rawValue - Value from the control.
   * @returns {{ok: boolean, value: *}}
   */
  function convertSettingValue(key, rawValue) {
    if (!Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) {
      return { ok: false, value: undefined };
    }
    const defaultType = typeof DEFAULT_SETTINGS[key];
    if (defaultType === 'boolean') {
      return { ok: true, value: rawValue === true || rawValue === 'true' };
    }
    if (defaultType === 'number') {
      const number = Number(rawValue);
      return { ok: rawValue !== '' && rawValue !== null && !isNaN(number), value: number };
    }
    if (defaultType === 'string') {
      return { ok: rawValue !== undefined && rawValue !== null && String(rawValue) !== '', value: String(rawValue) };
    }
    return { ok: false, value: undefined };
  }

  /**
   * True when a value is allowed for a setting with a fixed list of choices (SETTING_CHOICES);
   * settings without a list accept any value of the right type.
   * @param {string} key - Setting name.
   * @param {*} value - Converted value.
   * @returns {boolean}
   */
  function isAllowedSettingValue(key, value) {
    const choices = SETTING_CHOICES[key];
    if (!choices) {
      return true;
    }
    return choices.some(function (choice) {
      return String(choice.value) === String(value);
    });
  }

  /**
   * Messages shown when the USER picks some values in the settings panel (not on changes from
   * another tab). Same texts and triggers as the 2026 version (NEW:7474-7517).
   * setting -> function(value) returning popup options, or null for no message.
   */
  const SETTING_CHOICE_NOTICES = {
    loadSpeed: function (value) {
      return value === 'Extreme' || value === 'All' ? { title: getLocaleString('SPEED_WARNING'), contentHtml: getLocaleString('SPEED_WARNING_MESSAGE'), icon: 'warning' } : null;
    },
    lazyLoadImages: function (value) {
      return value === true ? { title: getLocaleString('WARNING'), contentHtml: getLocaleString('LAZY_LOAD'), icon: 'warning' } : null;
    },
    downloadZip: function (value) {
      return value === true ? { title: getLocaleString('ATTENTION'), contentHtml: getLocaleString('AUTO_DOWNLOAD'), icon: 'info', timerMs: 10000 } : null;
    }
  };

  /**
   * Generic action of every settings control (data-setting + value): converts, validates,
   * SAVES (the appliers update the screen), then shows the notice for that choice, if any.
   * The default view mode is also applied to this page, as in the 2026 version (NEW:7705-7709).
   * @param {Object} context - Action context with `setting` and `value`.
   */
  function actionSettingChange(context) {
    const key = context.setting;
    const converted = convertSettingValue(key, context.value);
    if (!converted.ok || !isAllowedSettingValue(key, converted.value)) {
      logWarn('setting-change: ignored invalid value', { setting: key, value: context.value });
      return;
    }
    saveSetting(key, converted.value);
    if (key === 'viewMode') {
      switchViewMode(converted.value);
    }
    const notice = SETTING_CHOICE_NOTICES[key] ? SETTING_CHOICE_NOTICES[key](converted.value) : null;
    if (notice) {
      showPopup(notice);
    }
  }

  /**
   * Scope control: 'local' = this site uses its own settings, 'global' = shared settings
   * (NEW:3829-3839). The screen follows the new effective settings; the panel is redrawn.
   * @param {Object} context - Action context with value 'local' or 'global'.
   */
  function actionSettingsScope(context) {
    const useLocal = context.value === 'local';
    if (useLocal === isLocalScopeEnabled()) {
      return;
    }
    setLocalScope(useLocal);
    refreshSettingsPanel();
    log('Settings scope: ' + (useLocal ? 'this site (' + window.location.hostname + ')' : 'global'));
    showToast({ type: 'info', title: getLocaleString('SCOPE_CHANGED').replace('##scope##', useLocal ? window.location.hostname : getLocaleString('GLOBAL')), durationMs: CONFIG.settingToastMs });
  }

  /**
   * Reset button: asks first (the 2026 version reset without asking), then resets the settings
   * of the active scope. The screen follows at once (no reload needed); the panel is redrawn.
   * @returns {Promise<void>}
   */
  async function actionResetSettings() {
    const answer = await showPopup({
      title: getLocaleString('BUTTON_RESET_SETTINGS'),
      contentHtml: escapeHtml(getLocaleString('SCOPE') + ': ' + (isLocalScopeEnabled() ? window.location.hostname : getLocaleString('GLOBAL'))),
      icon: 'warning',
      confirmText: getLocaleString('BUTTON_RESET_SETTINGS'),
      cancelText: getLocaleString('CANCEL')
    });
    if (answer.result !== POPUP_RESULT.CONFIRM) {
      log('Reset settings cancelled');
      return;
    }
    resetSettings();
    refreshSettingsPanel();
  }

  /** Keyboard SETTINGS: opens the settings panel, or closes any open panel (NEW:7829-7831). */
  function actionToggleSettings() {
    setOpenPanel(APP_STATE.panel === 'none' ? 'settings' : 'none');
  }

  /** THE TABLE. Action name -> function(context). */
  const ACTIONS = {
    'go-next-chapter': actionNextChapter,
    'go-previous-chapter': actionPreviousChapter,
    'go-series': actionSeries,
    'reload-page': actionReloadPage,
    'enlarge': function () {
      changeGlobalZoomByStep(1);
    },
    'reduce': function () {
      changeGlobalZoomByStep(-1);
    },
    'restore': function () {
      setGlobalZoom('percent', 100);
    },
    'fit-width': function () {
      setGlobalZoom('width');
    },
    'fit-height': function () {
      setGlobalZoom('height');
    },
    'zoom-slider': actionZoomSlider,
    'toggle-header': toggleHeaderVisibility,
    'toggle-page-controls': actionTogglePageControls,
    'go-to-page': actionGoToPage,
    'previous-page': function () {
      goToNeighborPage(-1);
    },
    'next-page': function () {
      goToNeighborPage(1);
    },
    'side-arrow-previous': actionSideArrowPrevious,
    'side-arrow-next': actionSideArrowNext,
    'page-bookmark-toggle': createPageAction('page-bookmark-toggle', toggleBookmarkForPage),
    'page-zoom-in': createPageAction('page-zoom-in', function (page) {
      zoomPageByStep(page, 1);
    }),
    'page-zoom-out': createPageAction('page-zoom-out', function (page) {
      zoomPageByStep(page, -1);
    }),
    'page-zoom-restore': createPageAction('page-zoom-restore', restorePageZoom),
    'page-zoom-width': createPageAction('page-zoom-width', fitPageToWidth),
    'page-zoom-height': createPageAction('page-zoom-height', fitPageToHeight),
    'page-double-toggle': createPageAction('page-double-toggle', togglePageDoublePage),
    'page-hide-toggle': createPageAction('page-hide-toggle', togglePageHidden),
    'scroll-up': function () {
      scrollReader(-1);
    },
    'scroll-down': function () {
      scrollReader(1);
    },
    'increase-scroll-speed': function () {
      changeAutoScrollSpeed(1);
    },
    'decrease-scroll-speed': function () {
      changeAutoScrollSpeed(-1);
    },
    'open-settings': function () {
      setOpenPanel('settings');
    },
    'open-keybindings': function () {
      setOpenPanel('keybindings');
    },
    'close-panels': function () {
      setOpenPanel('none');
    },
    'toggle-settings': actionToggleSettings,
    'setting-change': actionSettingChange,
    'settings-scope': actionSettingsScope,
    'reset-settings': function () {
      safeRunAsync('action:reset-settings', actionResetSettings);
    },
    'toast-close': function (context) {
      closeToast(context.value || '');
    }
    // keybinds-edit, keybinds-save, keybinds-cancel: added by section 17
    // download, download-cancel: added by section 19
    // toggle-auto-scroll, open-bookmarks, bookmark-remove, bookmark-toggle-current,
    // bookmarks-clear, open-comments, comments-color-scheme: added by section 20
  };

  // One action per view mode ('view-webcomic', 'view-fluid-ltr', ...), from the buttons table.
  VIEW_MODE_BUTTONS.forEach(function (button) {
    ACTIONS[button.action] = createViewModeAction(button.value);
  });

  /**
   * Keyboard action ids (keys of DEFAULT_SETTINGS.keybinds, the names users see and edit) ->
   * ACTIONS names. Used by the keyboard engine (section 17). 'toggle-auto-scroll' is registered
   * by section 20 (auto-scroll).
   */
  const KEYBIND_ACTIONS = {
    SCROLL_UP: 'scroll-up',
    SCROLL_DOWN: 'scroll-down',
    NEXT_CHAPTER: 'go-next-chapter',
    PREVIOUS_CHAPTER: 'go-previous-chapter',
    RETURN_CHAPTER_LIST: 'go-series',
    ENLARGE: 'enlarge',
    REDUCE: 'reduce',
    RESTORE: 'restore',
    FIT_WIDTH: 'fit-width',
    FIT_HEIGHT: 'fit-height',
    SETTINGS: 'toggle-settings',
    VIEW_MODE_WEBCOMIC: 'view-webcomic',
    VIEW_MODE_VERTICAL: 'view-vertical',
    VIEW_MODE_LEFT: 'view-fluid-ltr',
    VIEW_MODE_RIGHT: 'view-fluid-rtl',
    VIEW_MODE_GALLERY: 'view-gallery',
    SCROLL_START: 'toggle-auto-scroll',
    INCREASE_SPEED: 'increase-scroll-speed',
    DECREASE_SPEED: 'decrease-scroll-speed',
    TOGGLE_CONTROLS: 'toggle-page-controls'
  };

  /**
   * Runs one action by name, with error logging. Used by clicks, value events and keys.
   * @param {string} name - Key of ACTIONS.
   * @param {Object} [context] - { element, page, value, event }.
   * @returns {boolean} True when the action exists and ran without error.
   */
  function runAction(name, context) {
    const action = ACTIONS[name];
    if (!action) {
      logWarn('Unknown action', name);
      return false;
    }
    logDebug('action', name, context && context.page ? 'page ' + context.page : '');
    return safeRun('action:' + name, function () {
      action(context || {});
      return true;
    }, false, { action: name });
  }

  // ==========================================================================================
  // ==== 17. KEYBOARD =========================================================================
  // Own replacement for the hotkeys-js library used by the 2025/2026 versions.
  //   - Key names are the ones stored in the `keybinds` setting (docs/04 section 5): letters,
  //     digits, punctuation ('/', ';', '-', '=', '.', ','), named keys ('up', 'space', 'del',
  //     'num_0'..'num_9', 'num_add'...), and modifier combos ('ctrl+x', 'shift+a', 'alt+1').
  //   - parseKeyBinding() turns a name into a canonical combo ('ctrl+shift+a');
  //     getEventCombos() turns a keydown event into the combos it can mean; a table
  //     combo -> keybind id connects both. Modifiers must match exactly (as in hotkeys-js).
  //   - ONE keydown listener (attached once). It ignores typing in inputs, selects, text areas
  //     and editable elements, and does nothing while a popup or the keybindings editor is open.
  //     Escape closes the open panel. A match runs runAction(KEYBIND_ACTIONS[id]).
  //   - The keybindings panel (list, editor with validation, save) also lives here.
  // Pure functions (no DOM): parseKeyBinding, getEventKeyNames, getEventCombos,
  // buildKeybindTable, findKeybindForEvent, formatKeyListForEditor, parseKeyListText,
  // validateKeybindEdits. They are exposed to tests/logic-tests.html.
  // ==========================================================================================

  /** The keybind ids the script knows (stored ids that are not here are kept but never bound). */
  const KEYBIND_IDS = Object.keys(DEFAULT_SETTINGS.keybinds);

  /** Modifier names, in the order used by canonical combos. */
  const MODIFIER_NAMES = ['ctrl', 'alt', 'shift', 'meta'];

  /** Other spellings accepted in key names (hotkeys-js names, KEYBIND_RULES text) -> our name. */
  const KEY_NAME_ALIASES = {
    esc: 'escape',
    return: 'enter',
    del: 'delete',
    ins: 'insert',
    option: 'alt',
    control: 'ctrl',
    command: 'meta',
    cmd: 'meta',
    '⌘': 'meta',
    '⇧': 'shift',
    '⌥': 'alt',
    '⌃': 'ctrl',
    comma: ','
  };

  /** Named (non-character) keys a binding may use. */
  const NAMED_KEYS = (function buildNamedKeys() {
    const names = ['backspace', 'tab', 'clear', 'enter', 'escape', 'space', 'up', 'down', 'left', 'right', 'home', 'end', 'pageup', 'pagedown', 'delete', 'insert',
      'num_multiply', 'num_add', 'num_enter', 'num_subtract', 'num_decimal', 'num_divide'];
    for (let number = 0; number <= 9; number++) {
      names.push('num_' + number);
    }
    for (let functionKey = 1; functionKey <= 19; functionKey++) {
      names.push('f' + functionKey);
    }
    return names;
  })();

  /** KeyboardEvent.key values of named keys -> our name. */
  const EVENT_KEY_NAMES = {
    ArrowUp: 'up',
    ArrowDown: 'down',
    ArrowLeft: 'left',
    ArrowRight: 'right',
    Up: 'up',
    Down: 'down',
    Left: 'left',
    Right: 'right',
    ' ': 'space',
    Spacebar: 'space',
    Backspace: 'backspace',
    Tab: 'tab',
    Enter: 'enter',
    Escape: 'escape',
    Esc: 'escape',
    Delete: 'delete',
    Del: 'delete',
    Insert: 'insert',
    Home: 'home',
    End: 'end',
    PageUp: 'pageup',
    PageDown: 'pagedown',
    Clear: 'clear'
  };

  /** KeyboardEvent.code of numeric keypad keys -> our name (the keypad is told apart by code). */
  const NUMPAD_CODE_NAMES = {
    NumpadMultiply: 'num_multiply',
    NumpadAdd: 'num_add',
    NumpadSubtract: 'num_subtract',
    NumpadDecimal: 'num_decimal',
    NumpadDivide: 'num_divide',
    NumpadEnter: 'num_enter',
    NumpadComma: ','
  };

  /** KeyboardEvent.code of punctuation keys (US layout position) -> character. */
  const PUNCTUATION_CODE_NAMES = {
    Minus: '-',
    Equal: '=',
    Slash: '/',
    Backslash: '\\',
    Semicolon: ';',
    Quote: '\'',
    Period: '.',
    Comma: ',',
    BracketLeft: '[',
    BracketRight: ']',
    Backquote: '\x60', // the backtick character
    Space: 'space'
  };

  /** Minimum time between two runs of the same keyboard action (NEW:7870, 100 ms). */
  const KEY_REPEAT_LIMIT_MS = 100;

  /**
   * Lower-cases a key name part and resolves its alias (pure).
   * @param {string} part - E.g. 'Del', 'Ctrl', 'W'.
   * @returns {string}
   */
  function normalizeKeyNamePart(part) {
    const lower = part.trim().toLowerCase();
    return Object.prototype.hasOwnProperty.call(KEY_NAME_ALIASES, lower) ? KEY_NAME_ALIASES[lower] : lower;
  }

  /**
   * True for a key (not modifier) name a binding may use: a named key or one visible character.
   * @param {string} key - Normalized key name.
   * @returns {boolean}
   */
  function isValidKeyName(key) {
    if (NAMED_KEYS.indexOf(key) !== -1) {
      return true;
    }
    return key.length === 1 && key.trim() !== '';
  }

  /**
   * Canonical combo text: modifiers in a fixed order, then the key ('ctrl+shift+a').
   * @param {{ctrl: boolean, alt: boolean, shift: boolean, meta: boolean}} modifiers - Pressed modifiers.
   * @param {string} key - Key name.
   * @returns {string}
   */
  function buildComboText(modifiers, key) {
    const parts = MODIFIER_NAMES.filter(function (name) {
      return modifiers[name] === true;
    });
    parts.push(key);
    return parts.join('+');
  }

  /**
   * Parses a stored key name like 'W', 'num_2', 'ctrl+x', 'Shift+A', 'del' or 'ctrl++' (pure).
   * @param {string} text - Key name.
   * @returns {{key: string, ctrl: boolean, alt: boolean, shift: boolean, meta: boolean, combo: string}|null}
   *   null when the text is not a valid key name.
   */
  function parseKeyBinding(text) {
    if (typeof text !== 'string' || text.trim() === '') {
      return null;
    }
    const trimmed = text.trim();
    let modifierParts = [];
    let keyPart = '';
    if (trimmed === '+') {
      keyPart = '+';
    } else if (/\+\+$/.test(trimmed)) {
      modifierParts = trimmed.slice(0, -2).split('+');
      keyPart = '+';
    } else {
      const parts = trimmed.split('+');
      keyPart = parts.pop();
      modifierParts = parts;
    }
    const result = { key: '', ctrl: false, alt: false, shift: false, meta: false, combo: '' };
    for (let i = 0; i < modifierParts.length; i++) {
      const modifier = normalizeKeyNamePart(modifierParts[i]);
      if (MODIFIER_NAMES.indexOf(modifier) === -1) {
        return null;
      }
      result[modifier] = true;
    }
    const key = keyPart === '+' ? '+' : normalizeKeyNamePart(keyPart);
    if (!isValidKeyName(key)) {
      return null;
    }
    result.key = key;
    result.combo = buildComboText(result, key);
    return result;
  }

  /**
   * The key names a keydown event can stand for (pure; works on any object with key/code).
   * Rules: the numeric keypad is recognized by `code` ('Numpad2' -> 'num_2', never '2'; with
   * NumLock off its arrow/home/end meaning is added); named keys and characters come from `key`
   * (lower-cased); the key POSITION (`code`) is a fallback only when `key` is not a plain
   * character (for example Alt+A on a Mac gives key 'Ã¥', or a dead key).
   * @param {{key: string, code: string}} event - The keydown event.
   * @returns {string[]}
   */
  function getEventKeyNames(event) {
    const names = [];
    const key = typeof event.key === 'string' ? event.key : '';
    const code = typeof event.code === 'string' ? event.code : '';
    /** Adds a name once. */
    function add(name) {
      if (name && names.indexOf(name) === -1) {
        names.push(name);
      }
    }
    if (code.indexOf('Numpad') === 0) {
      const digit = /^Numpad(\d)$/.exec(code);
      add(digit ? 'num_' + digit[1] : NUMPAD_CODE_NAMES[code]);
      add(EVENT_KEY_NAMES[key]);
      return names;
    }
    if (Object.prototype.hasOwnProperty.call(EVENT_KEY_NAMES, key)) {
      add(EVENT_KEY_NAMES[key]);
    } else if (/^F([1-9]|1[0-9])$/.test(key)) {
      add(key.toLowerCase());
    } else if (key.length === 1) {
      add(key.toLowerCase());
    }
    const plainCharacter = key.length === 1 && /^[\x21-\x7e]$/.test(key);
    if (!plainCharacter) {
      const letter = /^Key([A-Z])$/.exec(code);
      const digit = /^Digit(\d)$/.exec(code);
      if (letter) {
        add(letter[1].toLowerCase());
      } else if (digit) {
        add(digit[1]);
      } else if (PUNCTUATION_CODE_NAMES[code]) {
        add(PUNCTUATION_CODE_NAMES[code]);
      }
    }
    return names;
  }

  /**
   * The canonical combos a keydown event can match (pure). Modifiers must match exactly, except
   * that Shift is ignored for punctuation characters (on many keyboards '/' or ';' need Shift).
   * @param {{key: string, code: string, ctrlKey: boolean, altKey: boolean, shiftKey: boolean, metaKey: boolean}} event
   * @returns {string[]}
   */
  function getEventCombos(event) {
    const modifiers = { ctrl: event.ctrlKey === true, alt: event.altKey === true, shift: event.shiftKey === true, meta: event.metaKey === true };
    const combos = [];
    getEventKeyNames(event).forEach(function (name) {
      const combo = buildComboText(modifiers, name);
      if (combos.indexOf(combo) === -1) {
        combos.push(combo);
      }
      if (modifiers.shift && name.length === 1 && !/[a-z0-9]/.test(name)) {
        const withoutShift = buildComboText({ ctrl: modifiers.ctrl, alt: modifiers.alt, shift: false, meta: modifiers.meta }, name);
        if (combos.indexOf(withoutShift) === -1) {
          combos.push(withoutShift);
        }
      }
    });
    return combos;
  }

  /**
   * Builds the lookup table combo -> keybind id (pure). Only known ids are bound, in the order
   * of `ids`; when two actions use the same key the first one keeps it (reported in
   * `duplicates`); names that cannot be parsed are reported in `invalid`.
   * @param {Object<string, string[]>} keybinds - Setting `keybinds`.
   * @param {string[]} ids - Known keybind ids, in priority order.
   * @returns {{table: Object<string, string>, invalid: {id: string, key: string}[], duplicates: {id: string, key: string, usedBy: string}[]}}
   */
  function buildKeybindTable(keybinds, ids) {
    const result = { table: {}, invalid: [], duplicates: [] };
    ids.forEach(function (id) {
      const keys = keybinds && Array.isArray(keybinds[id]) ? keybinds[id] : [];
      keys.forEach(function (keyName) {
        const parsed = parseKeyBinding(keyName);
        if (!parsed) {
          result.invalid.push({ id: id, key: String(keyName) });
          return;
        }
        if (Object.prototype.hasOwnProperty.call(result.table, parsed.combo)) {
          if (result.table[parsed.combo] !== id) {
            result.duplicates.push({ id: id, key: String(keyName), usedBy: result.table[parsed.combo] });
          }
          return;
        }
        result.table[parsed.combo] = id;
      });
    });
    return result;
  }

  /**
   * The keybind id a keydown event triggers, or '' (pure).
   * @param {Object<string, string>} table - From buildKeybindTable().table.
   * @param {Object} event - The keydown event (or an object with the same fields).
   * @returns {string}
   */
  function findKeybindForEvent(table, event) {
    const combos = getEventCombos(event);
    for (let i = 0; i < combos.length; i++) {
      if (Object.prototype.hasOwnProperty.call(table, combos[i])) {
        return table[combos[i]];
      }
    }
    return '';
  }

  // ---- Editor text format ----
  // The editor shows the keys of one action separated by commas. The comma KEY therefore is
  // written 'comma' in the editor ('ctrl+comma' for 'ctrl+,') and stored as ',' (the stored
  // format of both earlier versions). The 2026 editor lost the ',' key on every save.

  /**
   * One stored key name as written in the editor (pure).
   * @param {string} keyName - Stored name.
   * @returns {string}
   */
  function formatKeyForEditor(keyName) {
    const text = String(keyName);
    if (text === ',') {
      return 'comma';
    }
    return /\+,$/.test(text) ? text.slice(0, -1) + 'comma' : text;
  }

  /**
   * The keys of one action as editor text: 'up, w, num_8' (pure).
   * @param {string[]} keys - Stored names.
   * @returns {string}
   */
  function formatKeyListForEditor(keys) {
    return (Array.isArray(keys) ? keys : []).map(formatKeyForEditor).join(', ');
  }

  /**
   * Splits editor text into stored key names: commas separate, spaces are trimmed, empty parts
   * are dropped, 'comma' becomes ',' (pure). '' gives [] (the action has no key).
   * @param {string} text - Editor text.
   * @returns {string[]}
   */
  function parseKeyListText(text) {
    return String(text || '').split(',').map(function (part) {
      return part.trim();
    }).filter(function (part) {
      return part !== '';
    }).map(function (part) {
      if (part.toLowerCase() === 'comma') {
        return ',';
      }
      return /\+comma$/i.test(part) ? part.slice(0, -5) + ',' : part;
    });
  }

  /**
   * Checks the editor texts of all actions (pure apart from the localized messages).
   * @param {Object<string, string>} edits - Keybind id -> editor text.
   * @returns {{keybinds: Object<string, string[]>, errors: Object<string, string>}} errors is
   *   empty when everything is valid; keybinds holds the parsed lists ([] for an empty field).
   */
  function validateKeybindEdits(edits) {
    const result = { keybinds: {}, errors: {} };
    const usedBy = {};
    Object.keys(edits).forEach(function (id) {
      const keys = parseKeyListText(edits[id]);
      result.keybinds[id] = keys;
      keys.forEach(function (keyName) {
        if (result.errors[id]) {
          return;
        }
        const parsed = parseKeyBinding(keyName);
        if (!parsed) {
          result.errors[id] = getLocaleString('KEYBIND_INVALID').replace('##key##', keyName);
          return;
        }
        if (usedBy[parsed.combo] && usedBy[parsed.combo] !== id) {
          result.errors[id] = getLocaleString('KEYBIND_DUPLICATE').replace('##key##', keyName).replace('##action##', getLocaleString(usedBy[parsed.combo]));
          return;
        }
        usedBy[parsed.combo] = id;
      });
    });
    return result;
  }

  // ---- Keybindings panel ----

  /**
   * HTML of the read-only list: "<action>: [key] / [key]" (NEW:9698-9704).
   * @returns {string}
   */
  function buildKeybindListHtml() {
    const keybinds = getSetting('keybinds') || {};
    return [
      '<div id="KeybindingsList">',
      KEYBIND_IDS.map(function (id) {
        const keys = Array.isArray(keybinds[id]) ? keybinds[id] : [];
        return [
          '<span class="mvm-keybind-name">', escapeHtml(getLocaleString(id)), ':</span>',
          '<span class="mvm-keybind-keys">', keys.map(function (keyName) {
            return '<kbd>' + escapeHtml(keyName) + '</kbd>';
          }).join(' / '), '</span>'
        ].join('');
      }).join(''),
      '</div>'
    ].join('');
  }

  /**
   * HTML of the editor: one text field per action, keys separated by commas (NEW:9712-9723),
   * with a place for its validation message.
   * @returns {string}
   */
  function buildKeybindEditorHtml() {
    const keybinds = getSetting('keybinds') || {};
    return [
      '<div id="KeybindingsEditor">',
      KEYBIND_IDS.map(function (id) {
        const inputId = 'Keybind_' + id;
        return [
          '<label for="', inputId, '">', escapeHtml(getLocaleString(id)), ':</label>',
          '<div>',
          '<input type="text" class="KeybindInput" id="', inputId, '" data-keybind="', id, '" value="', escapeHtml(formatKeyListForEditor(keybinds[id])), '" autocomplete="off" spellcheck="false" />',
          '<div class="mvm-keybind-error" id="KeybindError_', id, '"></div>',
          '</div>'
        ].join('');
      }).join(''),
      '</div>'
    ].join('');
  }

  /** Draws the panel's buttons and body for the current mode (list or editor). */
  function renderKeybindingsPanel() {
    const actions = document.getElementById('KeybindingsActions');
    const body = document.getElementById('KeybindingsBody');
    if (!actions || !body) {
      return;
    }
    if (APP_STATE.keybindsEditing) {
      setElementHtml(actions, buildButtonHtml('keybinds-save', getLocaleString('BUTTON_SAVE'), { id: 'SaveKeybindings', icon: 'deviceFloppy', title: getLocaleString('SAVE_KEYBINDS') }) +
        buildButtonHtml('keybinds-cancel', getLocaleString('CANCEL'), { id: 'CancelKeybindings', icon: 'x' }));
      setElementHtml(body, buildKeybindEditorHtml());
    } else {
      setElementHtml(actions, buildButtonHtml('keybinds-edit', getLocaleString('BUTTON_EDIT'), { id: 'EditKeybindings', icon: 'pencil', title: getLocaleString('EDIT_KEYBINDS') }));
      setElementHtml(body, buildKeybindListHtml());
    }
  }

  /** "Edit": switches the panel to the editor; shortcut keys pause meanwhile (NEW:7871). */
  function actionEditKeybinds() {
    APP_STATE.keybindsEditing = true;
    renderKeybindingsPanel();
    const firstInput = document.querySelector('#KeybindingsEditor .KeybindInput');
    if (firstInput) {
      firstInput.focus();
    }
  }

  /** "Cancel": leaves the editor without saving. */
  function actionCancelKeybinds() {
    APP_STATE.keybindsEditing = false;
    renderKeybindingsPanel();
  }

  /**
   * Shows the validation messages in the editor (one per action, '' clears it).
   * @param {Object<string, string>} errors - Keybind id -> message.
   */
  function showKeybindErrors(errors) {
    KEYBIND_IDS.forEach(function (id) {
      const message = errors[id] || '';
      const box = document.getElementById('KeybindError_' + id);
      if (box) {
        box.textContent = message;
      }
      const input = document.getElementById('Keybind_' + id);
      if (input) {
        input.classList.toggle('invalid', message !== '');
      }
    });
  }

  /**
   * "Save": validates every field; on errors shows them and keeps the editor open; otherwise
   * SAVES the keybinds (always global) in the stored format { id: [names] } - an emptied field
   * is stored as [] so it stays empty after a reload (the 2026 version stored undefined and the
   * defaults came back, docs/04 section 5.4). Unknown stored ids are kept untouched.
   */
  function actionSaveKeybinds() {
    const edits = {};
    queryAll('#KeybindingsEditor [data-keybind]').forEach(function (input) {
      edits[input.getAttribute('data-keybind')] = input.value;
    });
    const validation = validateKeybindEdits(edits);
    showKeybindErrors(validation.errors);
    const errorIds = Object.keys(validation.errors);
    if (errorIds.length > 0) {
      logWarn('Keybindings not saved: ' + errorIds.length + ' field(s) need a fix', validation.errors);
      return;
    }
    const keybinds = Object.assign(getSetting('keybinds') || {}, validation.keybinds);
    APP_STATE.keybindsEditing = false;
    saveSetting('keybinds', keybinds);
    renderKeybindingsPanel();
  }

  ACTIONS['keybinds-edit'] = actionEditKeybinds;
  ACTIONS['keybinds-save'] = actionSaveKeybinds;
  ACTIONS['keybinds-cancel'] = actionCancelKeybinds;

  // A keybinds change (save here, reset, another tab) redraws the list; not while editing.
  SETTING_APPLIERS.keybinds = function () {
    if (!APP_STATE.keybindsEditing) {
      renderKeybindingsPanel();
    }
  };

  // Redraw after the panel was rebuilt for a language change, and leave the editor when the
  // panel closes (setOpenPanel already cleared the flag).
  onAppStateChange(function (what) {
    if (what === 'textsRefreshed' || what === 'panel') {
      renderKeybindingsPanel();
    }
  });

  // ---- The keydown listener ----

  /** Last run time per keybind id (repeat limit). */
  const KEY_LAST_RUN = {};

  /** True after attachKeyboardEvents ran. */
  let keyboardEventsAttached = false;

  /**
   * True when a key typed into this element belongs to the element (text field, select,
   * editable content), so it must not trigger a shortcut.
   * @param {EventTarget} target - event.target.
   * @returns {boolean}
   */
  function isEditableTarget(target) {
    if (!target || !target.tagName) {
      return false;
    }
    if (target.isContentEditable) {
      return true;
    }
    const tag = target.tagName;
    if (tag === 'TEXTAREA' || tag === 'SELECT') {
      return true;
    }
    if (tag !== 'INPUT') {
      return false;
    }
    return ['checkbox', 'radio', 'range', 'button', 'submit', 'reset', 'color'].indexOf(String(target.type).toLowerCase()) === -1;
  }

  /** True while a popup (section 13) is on screen: it handles its own keys. */
  function isPopupOpen() {
    return document.querySelector('.mvm-popup-backdrop') !== null;
  }

  /**
   * The keydown handler: Escape closes panels; otherwise the pressed combo runs its action.
   * @param {KeyboardEvent} event - keydown.
   */
  function onKeyDown(event) {
    if (event.isComposing || !getReaderRoot() || isPopupOpen()) {
      return;
    }
    const editable = isEditableTarget(event.target);
    if (event.key === 'Escape' || event.key === 'Esc') {
      if (!editable && APP_STATE.panel !== 'none') {
        event.preventDefault();
        runAction('close-panels', { event: event });
      }
      return;
    }
    if (editable || APP_STATE.keybindsEditing) {
      return;
    }
    const id = findKeybindForEvent(buildKeybindTable(getSetting('keybinds'), KEYBIND_IDS).table, event);
    if (!id) {
      return;
    }
    event.preventDefault();
    event.stopPropagation();
    const now = Date.now();
    if (KEY_LAST_RUN[id] && now - KEY_LAST_RUN[id] < KEY_REPEAT_LIMIT_MS) {
      return;
    }
    KEY_LAST_RUN[id] = now;
    logDebug('key', getEventCombos(event).join(' | '), '->', id);
    runAction(KEYBIND_ACTIONS[id], { event: event });
  }

  /**
   * Removes the site's own key handlers set as properties (NEW:7862-7867) and attaches the one
   * keydown listener (capture phase on window, so the site's listeners do not see our keys).
   * Logs keybinds that cannot be used (bad names, keys used twice).
   */
  function attachKeyboardEvents() {
    if (keyboardEventsAttached) {
      return;
    }
    safeRun('keyboard.removeSiteHandlers', function () {
      [document, window, pageWindow].forEach(function (target) {
        target.onkeydown = null;
        target.onkeyup = null;
        target.onkeypress = null;
      });
    });
    window.addEventListener('keydown', guardHandler('keyboard.keydown', onKeyDown), true);
    keyboardEventsAttached = true;
    const check = buildKeybindTable(getSetting('keybinds'), KEYBIND_IDS);
    check.invalid.forEach(function (entry) {
      logWarn('Keybind ignored, unknown key name "' + entry.key + '" for ' + entry.id);
    });
    check.duplicates.forEach(function (entry) {
      logWarn('Keybind ignored, "' + entry.key + '" of ' + entry.id + ' is already used by ' + entry.usedBy);
    });
    logDebug('keyboard listener attached', Object.keys(check.table).length + ' keys bound');
  }

  // ==========================================================================================
  // ==== 18. EVENTS ===========================================================================
  // Listeners are attached ONCE to the reader root (event delegation): clicks anywhere inside
  // are routed through data-action, so re-rendering parts of the reader never needs re-binding.
  // The keydown listener lives in section 17 and is attached from here (attachKeyboardEvents).
  // Also attached once: slider/select value events (same data-action routing), the mouse wheel
  // (horizontal modes, thumbnails), window resize (device class, page re-fit), and the throttled
  // scroll/mousemove handlers of the header (headroom) and of current-page tracking.
  // ==========================================================================================

  /** True after attachReaderEvents ran (protects against double binding). */
  let readerEventsAttached = false;

  /** Form controls report their value through 'input'/'change' events, not through clicks. */
  const VALUE_CONTROL_TAGS = ['INPUT', 'SELECT', 'TEXTAREA'];

  /**
   * Builds the action context of an element with data-action. `value` is the control's value
   * (a checkbox gives 'true'/'false'), else its data-value; `setting` is its data-setting
   * (settings panel controls, section 12).
   * @param {Element} element - The element.
   * @param {Event} event - The DOM event.
   * @returns {{element: Element, page: number, value: (string|undefined), setting: string, event: Event}}
   */
  function buildActionContext(element, event) {
    const pageText = element.getAttribute('data-page');
    const valueText = element.getAttribute('data-value');
    const isControl = VALUE_CONTROL_TAGS.indexOf(element.tagName) !== -1;
    let value = valueText === null ? undefined : valueText;
    if (isControl) {
      value = element.type === 'checkbox' ? (element.checked ? 'true' : 'false') : element.value;
    }
    return {
      element: element,
      page: pageText ? parseInt(pageText, 10) : 0,
      value: value,
      setting: element.getAttribute('data-setting') || '',
      event: event
    };
  }

  /**
   * Handles every click inside the reader: finds the closest element with data-action.
   * Clicks on sliders and selects are ignored here; their value events run the action.
   * Link buttons (<a href data-action>, e.g. previous/next chapter): a plain left click runs the
   * action (logged navigation in this tab); Ctrl/Cmd/Shift/Alt clicks are left to the browser so
   * it can open a new tab or window (middle clicks fire 'auxclick', which is never handled here).
   * @param {MouseEvent} event - The click.
   */
  function onReaderClick(event) {
    const element = event.target && event.target.closest ? event.target.closest('[data-action]') : null;
    if (!element || VALUE_CONTROL_TAGS.indexOf(element.tagName) !== -1) {
      return;
    }
    if (element.disabled || element.classList.contains('disabled')) {
      logDebug('click on a disabled control ignored', element.getAttribute('data-action'));
      return;
    }
    if (element.tagName === 'A' && element.hasAttribute('href')) {
      if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
        logDebug('link button clicked with a modifier key or another button: the browser opens it', element.getAttribute('data-action'), element.getAttribute('href'));
        return;
      }
      event.preventDefault();
    }
    runAction(element.getAttribute('data-action'), buildActionContext(element, event));
  }

  /**
   * Handles value changes: sliders run their action on 'input' (live while dragging), except
   * sliders marked data-commit="change" (settings panel), which only update their value badge
   * while dragging and run the action on release; every other control runs on 'change'.
   * @param {Event} event - input or change.
   */
  function onReaderValueEvent(event) {
    const element = event.target;
    if (!element || !element.getAttribute || !element.hasAttribute('data-action')) {
      return;
    }
    const isRange = element.tagName === 'INPUT' && element.type === 'range';
    const commitOnChange = isRange && element.getAttribute('data-commit') === 'change';
    if (commitOnChange && event.type === 'input') {
      updateRangeOutput(element);
      return;
    }
    if ((event.type === 'input') !== (isRange && !commitOnChange)) {
      return;
    }
    runAction(element.getAttribute('data-action'), buildActionContext(element, event));
  }

  /**
   * Mouse wheel: in the horizontal modes the vertical wheel scrolls the chapter sideways (reversed
   * for right to left, NEW:9956-9970); over the bottom thumbnails bar it scrolls the thumbnails.
   * @param {WheelEvent} event - The wheel event.
   */
  function onReaderWheel(event) {
    if (!event.deltaY) {
      return;
    }
    const target = event.target && event.target.closest ? event.target : null;
    const thumbnails = target ? target.closest('#Thumbnails') : null;
    if (thumbnails && getSetting('navbar') === 'bottom') {
      thumbnails.scrollLeft += event.deltaY + event.deltaX;
      event.preventDefault();
      return;
    }
    const chapter = target ? target.closest('#Chapter') : null;
    const viewMode = getSetting('viewMode');
    if (!chapter || !isFluidViewMode(viewMode)) {
      return;
    }
    if (viewMode === 'FluidLTR') {
      chapter.scrollLeft += event.deltaY + event.deltaX;
    } else {
      chapter.scrollLeft -= event.deltaY - event.deltaX;
    }
    event.preventDefault();
  }

  /**
   * Updates the device class when the window is resized (phone rotated, window narrowed).
   * A device forced by the `deviceMode` setting stays.
   */
  function onWindowResize() {
    const device = resolveDeviceMode(getSetting('deviceMode'), detectDevice());
    if (device !== APP_STATE.device) {
      APP_STATE.device = device;
      setExclusiveClass(getReaderRoot(), ['mobile', 'tablet', 'desktop'], device);
      logDebug('device changed to', device);
    }
  }

  /** After the window size settled: re-fit every page and recompute the page in view (NEW:11086). */
  function onWindowResizeSettled() {
    applyGlobalZoomToAllPages();
    updateCurrentPage();
  }

  /**
   * Returns a handler that runs `action` at most once per animation frame, and at most once per
   * `waitMs` (current-page tracking, NEW:11060-11068).
   * @param {function(): void} action - Work to do.
   * @param {number} waitMs - Throttle time.
   * @returns {function(): void}
   */
  function createFrameThrottledHandler(action, waitMs) {
    return throttle(function () {
      if (typeof window.requestAnimationFrame === 'function') {
        window.requestAnimationFrame(action);
      } else {
        action();
      }
    }, waitMs);
  }

  /** Attaches the reader listeners once. */
  function attachReaderEvents() {
    if (readerEventsAttached) {
      logDebug('attachReaderEvents: already attached');
      return;
    }
    const root = getReaderRoot();
    if (!root) {
      logWarn('attachReaderEvents: reader root not found');
      return;
    }
    root.addEventListener('click', guardHandler('reader.click', onReaderClick));
    root.addEventListener('input', guardHandler('reader.input', onReaderValueEvent));
    root.addEventListener('change', guardHandler('reader.change', onReaderValueEvent));
    root.addEventListener('wheel', guardHandler('reader.wheel', onReaderWheel), { passive: false });
    window.addEventListener('resize', guardHandler('window.resize', throttle(onWindowResize, 300)));
    window.addEventListener('resize', guardHandler('window.resizeSettled', debounce(onWindowResizeSettled, CONFIG.resizeDebounceMs)));
    window.addEventListener('orientationchange', guardHandler('window.orientationchange', debounce(onWindowResizeSettled, CONFIG.resizeDebounceMs)));
    window.addEventListener('scroll', guardHandler('headroom.scroll', throttle(updateHeadroomOnScroll, CONFIG.headroomThrottleMs)), { passive: true });
    window.addEventListener('mousemove', guardHandler('headroom.mousemove', throttle(updateHeadroomOnMouseMove, CONFIG.headroomThrottleMs)), { passive: true });
    const trackCurrentPage = guardHandler('currentPage.track', createFrameThrottledHandler(updateCurrentPage, CONFIG.currentPageThrottleMs));
    window.addEventListener('scroll', trackCurrentPage, { passive: true });
    window.addEventListener('resize', trackCurrentPage);
    const chapter = getChapterElement();
    if (chapter) {
      chapter.addEventListener('scroll', trackCurrentPage, { passive: true });
    }
    attachKeyboardEvents();
    renderPanelState();
    readerEventsAttached = true;
    updateCurrentPage();
    logDebug('reader events attached');
  }


  // ==========================================================================================
  // ==== 19. ZIP AND DOWNLOAD =================================================================
  // PART A - self-contained ZIP tools (generalization approved, rule 53): every function of
  // part A depends ONLY on its parameters and on the other functions of part A, so part A can be
  // copied into another script unchanged. No library and no CompressionStream /
  // DecompressionStream (too new for the ES2017 floor of this script).
  //   computeCrc32(bytes)                  - CRC-32 (IEEE 802.3, the checksum ZIP uses)
  //   encodeUtf8(text) / decodeUtf8(bytes) - text <-> bytes (file names inside the ZIP)
  //   buildZipParts(files)                 - a ZIP archive (method STORE) as a list of chunks
  //   concatenateBytes(chunks)             - joins chunks into one Uint8Array
  //   inflateRaw(bytes, expectedSize)      - raw DEFLATE decoder (RFC 1951)
  //   readZipDirectory(bytes)              - the entries listed in the central directory
  //   extractZipEntry(bytes, entry)        - the bytes of one entry (STORE or DEFLATE, CRC checked)
  // Limits: no ZIP64 (archives below 4 GB and below 65535 files), no encryption, no
  // multi-volume archives. Errors are thrown with a message that names the problem.
  // PART B - the chapter download (uses the reader): get every page's bytes, name the files,
  // build the ZIP, save it with <a download>, report skipped pages, keep the button state right.
  // ==========================================================================================

  // ---- Part A.1: CRC-32 and UTF-8 ----

  /** CRC-32 lookup table, built on first use by buildCrc32Table(). */
  let crc32Table = null;

  /**
   * Builds the 256-entry CRC-32 table (reflected polynomial 0xEDB88320).
   * @returns {Uint32Array}
   */
  function buildCrc32Table() {
    const table = new Uint32Array(256);
    for (let n = 0; n < 256; n++) {
      let value = n;
      for (let bit = 0; bit < 8; bit++) {
        value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
      }
      table[n] = value >>> 0;
    }
    return table;
  }

  /**
   * CRC-32 of a byte array. Pass the previous result as `previous` to continue a checksum over
   * several chunks: computeCrc32(b, computeCrc32(a)) === computeCrc32(a + b).
   * Known value: '123456789' -> 0xCBF43926.
   * @param {Uint8Array} bytes - Data.
   * @param {number} [previous=0] - CRC of the data before these bytes.
   * @returns {number} Unsigned 32-bit CRC.
   */
  function computeCrc32(bytes, previous) {
    if (crc32Table === null) {
      crc32Table = buildCrc32Table();
    }
    let crc = ((typeof previous === 'number' ? previous : 0) ^ 0xFFFFFFFF) >>> 0;
    for (let i = 0; i < bytes.length; i++) {
      crc = crc32Table[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8);
    }
    return (crc ^ 0xFFFFFFFF) >>> 0;
  }

  /**
   * Encodes text as UTF-8 bytes (surrogate pairs become one 4-byte character).
   * @param {string} text - Text.
   * @returns {Uint8Array}
   */
  function encodeUtf8(text) {
    const value = String(text);
    const bytes = [];
    for (let i = 0; i < value.length; i++) {
      let code = value.charCodeAt(i);
      if (code >= 0xD800 && code <= 0xDBFF && i + 1 < value.length) {
        const low = value.charCodeAt(i + 1);
        if (low >= 0xDC00 && low <= 0xDFFF) {
          code = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
          i += 1;
        }
      }
      if (code < 0x80) {
        bytes.push(code);
      } else if (code < 0x800) {
        bytes.push(0xC0 | (code >> 6), 0x80 | (code & 0x3F));
      } else if (code < 0x10000) {
        bytes.push(0xE0 | (code >> 12), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
      } else {
        bytes.push(0xF0 | (code >> 18), 0x80 | ((code >> 12) & 0x3F), 0x80 | ((code >> 6) & 0x3F), 0x80 | (code & 0x3F));
      }
    }
    return new Uint8Array(bytes);
  }

  /**
   * Decodes UTF-8 bytes. Returns null when the bytes are not valid UTF-8 (the caller can then
   * use another encoding).
   * @param {Uint8Array} bytes - Encoded text.
   * @returns {string|null}
   */
  function decodeUtf8(bytes) {
    let result = '';
    let i = 0;
    while (i < bytes.length) {
      const first = bytes[i];
      let code = 0;
      let extra = 0;
      if (first < 0x80) {
        code = first;
      } else if (first >= 0xC2 && first < 0xE0) {
        code = first & 0x1F;
        extra = 1;
      } else if (first >= 0xE0 && first < 0xF0) {
        code = first & 0x0F;
        extra = 2;
      } else if (first >= 0xF0 && first < 0xF5) {
        code = first & 0x07;
        extra = 3;
      } else {
        return null;
      }
      if (extra > 0 && i + extra >= bytes.length) {
        return null;
      }
      for (let k = 1; k <= extra; k++) {
        const next = bytes[i + k];
        if ((next & 0xC0) !== 0x80) {
          return null;
        }
        code = (code << 6) | (next & 0x3F);
      }
      i += extra + 1;
      if (code > 0xFFFF) {
        code -= 0x10000;
        result += String.fromCharCode(0xD800 + (code >> 10), 0xDC00 + (code & 0x3FF));
      } else {
        result += String.fromCharCode(code);
      }
    }
    return result;
  }

  /**
   * Decodes a file name stored in a ZIP: UTF-8 when the entry says so (flag bit 11) or when the
   * bytes are valid UTF-8 (many tools write UTF-8 without the flag), else one byte per character
   * (ZIP's official code page 437 is approximated by Latin-1 here).
   * @param {Uint8Array} bytes - Name bytes.
   * @param {boolean} utf8Flag - Flag bit 11 of the entry.
   * @returns {string}
   */
  function decodeZipFileName(bytes, utf8Flag) {
    const text = decodeUtf8(bytes);
    if (text !== null) {
      return text;
    }
    let latin = '';
    for (let i = 0; i < bytes.length; i++) {
      latin += String.fromCharCode(bytes[i]);
    }
    return utf8Flag ? latin.replace(/[\u0080-ÿ]/g, '?') : latin;
  }

  // ---- Part A.2: ZIP writer (method STORE: the images are already compressed) ----

  /** ZIP record signatures and the "file name is UTF-8" flag (PKWARE APPNOTE 4.3.7, 4.3.12, 4.3.16, 4.4.4). */
  const ZIP_FORMAT = {
    localSignature: 0x04034b50,
    centralSignature: 0x02014b50,
    endSignature: 0x06054b50,
    utf8Flag: 0x0800,
    version: 20,
    maxEntries: 0xFFFF,
    maxSize: 0xFFFFFFFF
  };

  /**
   * MS-DOS time and date of a Date (what ZIP stores). Years outside 1980-2107 are clamped.
   * @param {Date} [date] - Time stamp (default: now).
   * @returns {{time: number, date: number}}
   */
  function toDosDateTime(date) {
    const value = date instanceof Date && !isNaN(date.getTime()) ? date : new Date();
    const year = Math.max(1980, Math.min(2107, value.getFullYear()));
    return {
      time: ((value.getHours() << 11) | (value.getMinutes() << 5) | Math.floor(value.getSeconds() / 2)) & 0xFFFF,
      date: (((year - 1980) << 9) | ((value.getMonth() + 1) << 5) | value.getDate()) & 0xFFFF
    };
  }

  /**
   * Builds a ZIP archive without compression: for every file a local header followed by its
   * bytes, then the central directory and the end record. The file bytes are NOT copied (the
   * chunks reference them), so a Blob can be made from the list without doubling memory.
   * @param {{name: string, bytes: Uint8Array, date: (Date|undefined)}[]} files - Files in order.
   * @returns {Uint8Array[]} Chunks; join with concatenateBytes() or new Blob(chunks).
   */
  function buildZipParts(files) {
    if (!Array.isArray(files)) {
      throw new Error('ZIP writer: files must be an array');
    }
    if (files.length > ZIP_FORMAT.maxEntries) {
      throw new Error('ZIP writer: ' + files.length + ' files; the limit without ZIP64 is ' + ZIP_FORMAT.maxEntries);
    }
    const parts = [];
    const centralRecords = [];
    let offset = 0;
    files.forEach(function (file, index) {
      if (!file || typeof file.name !== 'string' || file.name === '' || !(file.bytes instanceof Uint8Array)) {
        throw new Error('ZIP writer: file #' + index + ' needs a non-empty name and Uint8Array bytes');
      }
      const nameBytes = encodeUtf8(file.name.replace(/\\/g, '/'));
      const size = file.bytes.length;
      if (offset + 30 + nameBytes.length + size > ZIP_FORMAT.maxSize) {
        throw new Error('ZIP writer: the archive would be larger than 4 GB (ZIP64 is not supported)');
      }
      const crc = computeCrc32(file.bytes);
      const stamp = toDosDateTime(file.date);
      const local = new Uint8Array(30 + nameBytes.length);
      const view = new DataView(local.buffer);
      view.setUint32(0, ZIP_FORMAT.localSignature, true);
      view.setUint16(4, ZIP_FORMAT.version, true);
      view.setUint16(6, ZIP_FORMAT.utf8Flag, true);
      view.setUint16(8, 0, true);
      view.setUint16(10, stamp.time, true);
      view.setUint16(12, stamp.date, true);
      view.setUint32(14, crc, true);
      view.setUint32(18, size, true);
      view.setUint32(22, size, true);
      view.setUint16(26, nameBytes.length, true);
      view.setUint16(28, 0, true);
      local.set(nameBytes, 30);
      parts.push(local, file.bytes);
      centralRecords.push({ nameBytes: nameBytes, crc: crc, size: size, stamp: stamp, offset: offset });
      offset += local.length + size;
    });
    const centralStart = offset;
    centralRecords.forEach(function (record) {
      const central = new Uint8Array(46 + record.nameBytes.length);
      const view = new DataView(central.buffer);
      view.setUint32(0, ZIP_FORMAT.centralSignature, true);
      view.setUint16(4, ZIP_FORMAT.version, true);
      view.setUint16(6, ZIP_FORMAT.version, true);
      view.setUint16(8, ZIP_FORMAT.utf8Flag, true);
      view.setUint16(10, 0, true);
      view.setUint16(12, record.stamp.time, true);
      view.setUint16(14, record.stamp.date, true);
      view.setUint32(16, record.crc, true);
      view.setUint32(20, record.size, true);
      view.setUint32(24, record.size, true);
      view.setUint16(28, record.nameBytes.length, true);
      view.setUint16(30, 0, true);
      view.setUint16(32, 0, true);
      view.setUint16(34, 0, true);
      view.setUint16(36, 0, true);
      view.setUint32(38, 0, true);
      view.setUint32(42, record.offset, true);
      central.set(record.nameBytes, 46);
      parts.push(central);
      offset += central.length;
    });
    if (offset + 22 > ZIP_FORMAT.maxSize) {
      throw new Error('ZIP writer: the archive would be larger than 4 GB (ZIP64 is not supported)');
    }
    const end = new Uint8Array(22);
    const endView = new DataView(end.buffer);
    endView.setUint32(0, ZIP_FORMAT.endSignature, true);
    endView.setUint16(4, 0, true);
    endView.setUint16(6, 0, true);
    endView.setUint16(8, files.length, true);
    endView.setUint16(10, files.length, true);
    endView.setUint32(12, offset - centralStart, true);
    endView.setUint32(16, centralStart, true);
    endView.setUint16(20, 0, true);
    parts.push(end);
    return parts;
  }

  /**
   * Joins byte chunks into one array.
   * @param {Uint8Array[]} chunks - Chunks in order.
   * @returns {Uint8Array}
   */
  function concatenateBytes(chunks) {
    let total = 0;
    chunks.forEach(function (chunk) {
      total += chunk.length;
    });
    const result = new Uint8Array(total);
    let position = 0;
    chunks.forEach(function (chunk) {
      result.set(chunk, position);
      position += chunk.length;
    });
    return result;
  }

  // ---- Part A.3: DEFLATE decoder (RFC 1951: stored, fixed Huffman and dynamic Huffman blocks) ----
  // Huffman codes are decoded bit by bit with canonical code counts (the method of RFC 1951
  // section 3.2.2), which needs no lookup table and is easy to follow. Fast enough for comic
  // archives, whose images are already compressed (most entries are STORE or barely deflated).

  /** Length codes 257..285: base length and extra bits (RFC 1951 section 3.2.5). */
  const INFLATE_LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
  const INFLATE_LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];

  /** Distance codes 0..29: base distance and extra bits (RFC 1951 section 3.2.5). */
  const INFLATE_DISTANCE_BASE = [1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
  const INFLATE_DISTANCE_EXTRA = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];

  /** Order in which a dynamic block lists the code-length code lengths (RFC 1951 section 3.2.7). */
  const INFLATE_CODE_LENGTH_ORDER = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];

  /** The fixed Huffman tables (RFC 1951 section 3.2.6), built on first use. */
  let fixedInflateTables = null;

  /**
   * Builds a canonical Huffman decoding table from code lengths.
   * @param {Uint8Array} lengths - Code length per symbol (0 = unused).
   * @param {number} start - Index of the first symbol in `lengths`.
   * @param {number} count - Number of symbols.
   * @returns {{counts: Uint16Array, symbols: Uint16Array}} Codes per length, symbols by code.
   */
  function buildHuffmanTable(lengths, start, count) {
    const counts = new Uint16Array(16);
    const offsets = new Uint16Array(16);
    const symbols = new Uint16Array(count);
    for (let i = 0; i < count; i++) {
      counts[lengths[start + i]] += 1;
    }
    counts[0] = 0;
    let available = 1;
    for (let length = 1; length < 16; length++) {
      available = (available << 1) - counts[length];
      if (available < 0) {
        throw new Error('inflate: over-subscribed Huffman code');
      }
    }
    for (let length = 1; length < 15; length++) {
      offsets[length + 1] = offsets[length] + counts[length];
    }
    for (let i = 0; i < count; i++) {
      const length = lengths[start + i];
      if (length !== 0) {
        symbols[offsets[length]] = i;
        offsets[length] += 1;
      }
    }
    return { counts: counts, symbols: symbols };
  }

  /**
   * Reads `count` bits (least significant bit first, as DEFLATE packs them).
   * @param {Object} state - Decoder state.
   * @param {number} count - 0 to 16 bits.
   * @returns {number}
   */
  function readInflateBits(state, count) {
    while (state.bitCount < count) {
      if (state.position >= state.input.length) {
        throw new Error('inflate: unexpected end of data');
      }
      state.bitBuffer |= state.input[state.position] << state.bitCount;
      state.position += 1;
      state.bitCount += 8;
    }
    const value = state.bitBuffer & ((1 << count) - 1);
    state.bitBuffer >>>= count;
    state.bitCount -= count;
    return value;
  }

  /**
   * Decodes one symbol with a Huffman table (one bit at a time, codes are most significant bit
   * first).
   * @param {Object} state - Decoder state.
   * @param {{counts: Uint16Array, symbols: Uint16Array}} table - From buildHuffmanTable.
   * @returns {number}
   */
  function decodeInflateSymbol(state, table) {
    let code = 0;
    let first = 0;
    let index = 0;
    for (let length = 1; length < 16; length++) {
      if (state.bitCount === 0) {
        if (state.position >= state.input.length) {
          throw new Error('inflate: unexpected end of data');
        }
        state.bitBuffer = state.input[state.position];
        state.position += 1;
        state.bitCount = 8;
      }
      code |= state.bitBuffer & 1;
      state.bitBuffer >>>= 1;
      state.bitCount -= 1;
      const count = table.counts[length];
      if (code - first < count) {
        return table.symbols[index + code - first];
      }
      index += count;
      first = (first + count) << 1;
      code <<= 1;
    }
    throw new Error('inflate: invalid Huffman code');
  }

  /**
   * Makes room for `extra` more output bytes (doubles the buffer when needed).
   * @param {Object} state - Decoder state.
   * @param {number} extra - Bytes about to be written.
   */
  function reserveInflateOutput(state, extra) {
    const needed = state.outputLength + extra;
    if (needed <= state.output.length) {
      return;
    }
    let size = Math.max(1024, state.output.length * 2);
    while (size < needed) {
      size *= 2;
    }
    const bigger = new Uint8Array(size);
    bigger.set(state.output.subarray(0, state.outputLength));
    state.output = bigger;
  }

  /**
   * Copies a stored (uncompressed) block (RFC 1951 section 3.2.4).
   * @param {Object} state - Decoder state.
   */
  function inflateStoredBlock(state) {
    state.bitBuffer = 0;
    state.bitCount = 0;
    const input = state.input;
    if (state.position + 4 > input.length) {
      throw new Error('inflate: unexpected end of data');
    }
    const length = input[state.position] | (input[state.position + 1] << 8);
    const inverse = input[state.position + 2] | (input[state.position + 3] << 8);
    if ((length ^ 0xFFFF) !== inverse) {
      throw new Error('inflate: stored block length check failed');
    }
    state.position += 4;
    if (state.position + length > input.length) {
      throw new Error('inflate: unexpected end of data');
    }
    reserveInflateOutput(state, length);
    state.output.set(input.subarray(state.position, state.position + length), state.outputLength);
    state.outputLength += length;
    state.position += length;
  }

  /**
   * Decodes a Huffman-coded block until its end-of-block symbol (256).
   * @param {Object} state - Decoder state.
   * @param {Object} literalTable - Literal/length table.
   * @param {Object} distanceTable - Distance table.
   */
  function inflateHuffmanBlock(state, literalTable, distanceTable) {
    for (;;) {
      const symbol = decodeInflateSymbol(state, literalTable);
      if (symbol < 256) {
        reserveInflateOutput(state, 1);
        state.output[state.outputLength] = symbol;
        state.outputLength += 1;
        continue;
      }
      if (symbol === 256) {
        return;
      }
      const lengthIndex = symbol - 257;
      if (lengthIndex >= INFLATE_LENGTH_BASE.length) {
        throw new Error('inflate: invalid length symbol ' + symbol);
      }
      const length = INFLATE_LENGTH_BASE[lengthIndex] + readInflateBits(state, INFLATE_LENGTH_EXTRA[lengthIndex]);
      const distanceSymbol = decodeInflateSymbol(state, distanceTable);
      if (distanceSymbol >= INFLATE_DISTANCE_BASE.length) {
        throw new Error('inflate: invalid distance symbol ' + distanceSymbol);
      }
      const distance = INFLATE_DISTANCE_BASE[distanceSymbol] + readInflateBits(state, INFLATE_DISTANCE_EXTRA[distanceSymbol]);
      if (distance > state.outputLength) {
        throw new Error('inflate: distance ' + distance + ' points before the start of the data');
      }
      reserveInflateOutput(state, length);
      let from = state.outputLength - distance;
      for (let k = 0; k < length; k++) {
        state.output[state.outputLength] = state.output[from];
        state.outputLength += 1;
        from += 1;
      }
    }
  }

  /**
   * The fixed literal/length and distance tables (RFC 1951 section 3.2.6).
   * @returns {{literal: Object, distance: Object}}
   */
  function getFixedInflateTables() {
    if (fixedInflateTables === null) {
      const lengths = new Uint8Array(288 + 30);
      for (let symbol = 0; symbol < 288; symbol++) {
        if (symbol < 144) {
          lengths[symbol] = 8;
        } else if (symbol < 256) {
          lengths[symbol] = 9;
        } else if (symbol < 280) {
          lengths[symbol] = 7;
        } else {
          lengths[symbol] = 8;
        }
      }
      for (let symbol = 288; symbol < 288 + 30; symbol++) {
        lengths[symbol] = 5;
      }
      fixedInflateTables = { literal: buildHuffmanTable(lengths, 0, 288), distance: buildHuffmanTable(lengths, 288, 30) };
    }
    return fixedInflateTables;
  }

  /**
   * Reads the code lengths of a dynamic block and builds its two tables (RFC 1951 section 3.2.7).
   * @param {Object} state - Decoder state.
   * @returns {{literal: Object, distance: Object}}
   */
  function readDynamicInflateTables(state) {
    const literalCount = readInflateBits(state, 5) + 257;
    const distanceCount = readInflateBits(state, 5) + 1;
    const codeLengthCount = readInflateBits(state, 4) + 4;
    if (literalCount > 286 || distanceCount > 30) {
      throw new Error('inflate: invalid dynamic block header (' + literalCount + ' literal codes, ' + distanceCount + ' distance codes)');
    }
    const codeLengths = new Uint8Array(19);
    for (let i = 0; i < codeLengthCount; i++) {
      codeLengths[INFLATE_CODE_LENGTH_ORDER[i]] = readInflateBits(state, 3);
    }
    const codeLengthTable = buildHuffmanTable(codeLengths, 0, 19);
    const total = literalCount + distanceCount;
    const lengths = new Uint8Array(total);
    let index = 0;
    while (index < total) {
      const symbol = decodeInflateSymbol(state, codeLengthTable);
      if (symbol < 16) {
        lengths[index] = symbol;
        index += 1;
        continue;
      }
      let repeat = 0;
      let value = 0;
      if (symbol === 16) {
        if (index === 0) {
          throw new Error('inflate: repeat code with no previous length');
        }
        value = lengths[index - 1];
        repeat = 3 + readInflateBits(state, 2);
      } else if (symbol === 17) {
        repeat = 3 + readInflateBits(state, 3);
      } else {
        repeat = 11 + readInflateBits(state, 7);
      }
      if (index + repeat > total) {
        throw new Error('inflate: too many code lengths');
      }
      for (let k = 0; k < repeat; k++) {
        lengths[index] = value;
        index += 1;
      }
    }
    if (lengths[256] === 0) {
      throw new Error('inflate: the block has no end-of-block code');
    }
    return { literal: buildHuffmanTable(lengths, 0, literalCount), distance: buildHuffmanTable(lengths, literalCount, distanceCount) };
  }

  /**
   * Decompresses raw DEFLATE data (no zlib/gzip header), as stored in ZIP entries with method 8.
   * @param {Uint8Array} input - Compressed bytes.
   * @param {number} [expectedSize] - Uncompressed size when known (avoids growing the buffer).
   * @param {{stored: number, fixed: number, dynamic: number}} [stats] - Optional counters of the
   *   block types met (used by the tests).
   * @returns {Uint8Array}
   */
  function inflateRaw(input, expectedSize, stats) {
    if (!(input instanceof Uint8Array)) {
      throw new Error('inflate: input must be a Uint8Array');
    }
    const initialSize = typeof expectedSize === 'number' && expectedSize > 0 ? expectedSize : Math.max(1024, input.length * 4);
    const state = { input: input, position: 0, bitBuffer: 0, bitCount: 0, output: new Uint8Array(initialSize), outputLength: 0 };
    const counters = stats || { stored: 0, fixed: 0, dynamic: 0 };
    let isFinal = 0;
    do {
      isFinal = readInflateBits(state, 1);
      const type = readInflateBits(state, 2);
      if (type === 0) {
        counters.stored = (counters.stored || 0) + 1;
        inflateStoredBlock(state);
      } else if (type === 1) {
        counters.fixed = (counters.fixed || 0) + 1;
        const fixed = getFixedInflateTables();
        inflateHuffmanBlock(state, fixed.literal, fixed.distance);
      } else if (type === 2) {
        counters.dynamic = (counters.dynamic || 0) + 1;
        const dynamic = readDynamicInflateTables(state);
        inflateHuffmanBlock(state, dynamic.literal, dynamic.distance);
      } else {
        throw new Error('inflate: invalid block type 3');
      }
    } while (isFinal === 0);
    return state.outputLength === state.output.length ? state.output : state.output.slice(0, state.outputLength);
  }

  // ---- Part A.4: ZIP reader ----

  /**
   * Little-endian unsigned 16-bit number at an offset. Throws past the end.
   * @param {Uint8Array} bytes - Data.
   * @param {number} offset - Position.
   * @returns {number}
   */
  function readZipUint16(bytes, offset) {
    if (offset < 0 || offset + 2 > bytes.length) {
      throw new Error('ZIP reader: unexpected end of file at byte ' + offset);
    }
    return bytes[offset] | (bytes[offset + 1] << 8);
  }

  /**
   * Little-endian unsigned 32-bit number at an offset. Throws past the end.
   * @param {Uint8Array} bytes - Data.
   * @param {number} offset - Position.
   * @returns {number}
   */
  function readZipUint32(bytes, offset) {
    if (offset < 0 || offset + 4 > bytes.length) {
      throw new Error('ZIP reader: unexpected end of file at byte ' + offset);
    }
    return (bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 0;
  }

  /**
   * Position of the "end of central directory" record, searched backwards (it may be followed
   * by a comment of up to 65535 bytes), or -1.
   * @param {Uint8Array} bytes - Archive.
   * @returns {number}
   */
  function findZipEndRecord(bytes) {
    const lowest = Math.max(0, bytes.length - 22 - 0xFFFF);
    for (let offset = bytes.length - 22; offset >= lowest; offset--) {
      if (bytes[offset] === 0x50 && bytes[offset + 1] === 0x4B && bytes[offset + 2] === 0x05 && bytes[offset + 3] === 0x06) {
        return offset;
      }
    }
    return -1;
  }

  /**
   * Lists the entries of a ZIP archive from its central directory.
   * @param {Uint8Array} bytes - The whole archive.
   * @returns {{name: string, isDirectory: boolean, method: number, flags: number, encrypted: boolean, crc32: number, compressedSize: number, size: number, localHeaderOffset: number}[]}
   */
  function readZipDirectory(bytes) {
    if (!(bytes instanceof Uint8Array)) {
      throw new Error('ZIP reader: input must be a Uint8Array');
    }
    const endOffset = findZipEndRecord(bytes);
    if (endOffset < 0) {
      throw new Error('ZIP reader: end of central directory not found (not a ZIP file, or damaged)');
    }
    const entryCount = readZipUint16(bytes, endOffset + 10);
    const directorySize = readZipUint32(bytes, endOffset + 12);
    const directoryOffset = readZipUint32(bytes, endOffset + 16);
    if (entryCount === 0xFFFF || directorySize === 0xFFFFFFFF || directoryOffset === 0xFFFFFFFF) {
      throw new Error('ZIP reader: ZIP64 archives are not supported');
    }
    if (directoryOffset + directorySize > endOffset) {
      throw new Error('ZIP reader: the central directory lies outside the file (damaged or multi-volume archive)');
    }
    const entries = [];
    let position = directoryOffset;
    for (let i = 0; i < entryCount; i++) {
      if (readZipUint32(bytes, position) !== ZIP_FORMAT.centralSignature) {
        throw new Error('ZIP reader: bad central directory record #' + i + ' at byte ' + position);
      }
      const flags = readZipUint16(bytes, position + 8);
      const nameLength = readZipUint16(bytes, position + 28);
      const extraLength = readZipUint16(bytes, position + 30);
      const commentLength = readZipUint16(bytes, position + 32);
      const nameStart = position + 46;
      if (nameStart + nameLength > bytes.length) {
        throw new Error('ZIP reader: unexpected end of file in record #' + i);
      }
      const name = decodeZipFileName(bytes.subarray(nameStart, nameStart + nameLength), (flags & ZIP_FORMAT.utf8Flag) !== 0);
      entries.push({
        name: name,
        isDirectory: /\/$/.test(name),
        method: readZipUint16(bytes, position + 10),
        flags: flags,
        encrypted: (flags & 1) !== 0,
        crc32: readZipUint32(bytes, position + 16),
        compressedSize: readZipUint32(bytes, position + 20),
        size: readZipUint32(bytes, position + 24),
        localHeaderOffset: readZipUint32(bytes, position + 42)
      });
      position = nameStart + nameLength + extraLength + commentLength;
    }
    return entries;
  }

  /**
   * The uncompressed bytes of one entry (method 0 STORE or 8 DEFLATE), checked against the
   * size and CRC-32 of the central directory.
   * @param {Uint8Array} bytes - The whole archive.
   * @param {Object} entry - One result of readZipDirectory().
   * @returns {Uint8Array}
   */
  function extractZipEntry(bytes, entry) {
    if (entry.encrypted) {
      throw new Error('ZIP reader: "' + entry.name + '" is encrypted (not supported)');
    }
    const headerOffset = entry.localHeaderOffset;
    if (readZipUint32(bytes, headerOffset) !== ZIP_FORMAT.localSignature) {
      throw new Error('ZIP reader: bad local header for "' + entry.name + '"');
    }
    const dataStart = headerOffset + 30 + readZipUint16(bytes, headerOffset + 26) + readZipUint16(bytes, headerOffset + 28);
    const dataEnd = dataStart + entry.compressedSize;
    if (dataEnd > bytes.length) {
      throw new Error('ZIP reader: data of "' + entry.name + '" lies outside the file');
    }
    const data = bytes.subarray(dataStart, dataEnd);
    let output = null;
    if (entry.method === 0) {
      output = data.slice();
    } else if (entry.method === 8) {
      output = inflateRaw(data, entry.size);
    } else {
      throw new Error('ZIP reader: "' + entry.name + '" uses compression method ' + entry.method + ' (only 0 STORE and 8 DEFLATE are supported)');
    }
    if (output.length !== entry.size) {
      throw new Error('ZIP reader: "' + entry.name + '" has ' + output.length + ' bytes, the directory says ' + entry.size);
    }
    if (computeCrc32(output) !== entry.crc32) {
      throw new Error('ZIP reader: CRC-32 mismatch in "' + entry.name + '" (damaged archive)');
    }
    return output;
  }

  // ---- Part B.1: file names and image types (pure) ----

  /** Image MIME type -> file extension (jpeg -> jpg, as both earlier versions did). */
  const IMAGE_EXTENSIONS_BY_TYPE = {
    'image/jpeg': 'jpg',
    'image/jpg': 'jpg',
    'image/pjpeg': 'jpg',
    'image/png': 'png',
    'image/apng': 'png',
    'image/gif': 'gif',
    'image/webp': 'webp',
    'image/bmp': 'bmp',
    'image/avif': 'avif',
    'image/svg+xml': 'svg',
    'image/x-icon': 'ico',
    'image/tiff': 'tif'
  };

  /** File extensions accepted as images when read from a URL or a file name. */
  const KNOWN_IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'jfif', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg', 'ico', 'tif', 'tiff'];

  /**
   * Image type from the first bytes of the file ("magic numbers"), or '' when unknown. More
   * reliable than the server's content type (often application/octet-stream).
   * @param {Uint8Array} bytes - File start.
   * @returns {string} Extension without dot.
   */
  function detectImageExtension(bytes) {
    if (!bytes || bytes.length < 4) {
      return '';
    }
    /**
     * Bytes start..end-1 read as ASCII text.
     * @param {number} start - First byte index.
     * @param {number} end - Index after the last byte.
     * @returns {string}
     */
    const ascii = function (start, end) {
      let text = '';
      for (let i = start; i < end && i < bytes.length; i++) {
        text += String.fromCharCode(bytes[i]);
      }
      return text;
    };
    if (bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF) {
      return 'jpg';
    }
    if (bytes[0] === 0x89 && ascii(1, 4) === 'PNG') {
      return 'png';
    }
    if (ascii(0, 4) === 'GIF8') {
      return 'gif';
    }
    if (ascii(0, 4) === 'RIFF' && ascii(8, 12) === 'WEBP') {
      return 'webp';
    }
    if (ascii(0, 2) === 'BM') {
      return 'bmp';
    }
    if (ascii(4, 8) === 'ftyp' && (ascii(8, 12) === 'avif' || ascii(8, 12) === 'avis')) {
      return 'avif';
    }
    if (/^\s*(<\?xml|<svg)/i.test(ascii(0, 64))) {
      return 'svg';
    }
    return '';
  }

  /**
   * Extension of a URL path or file name, lower case, '' when none ('jpeg'/'jfif' -> 'jpg').
   * data: URLs give the extension of their MIME type.
   * @param {string} url - URL or file name.
   * @returns {string}
   */
  function getExtensionFromName(url) {
    const text = String(url || '');
    if (text.indexOf('data:') === 0) {
      const type = text.slice(5).split(/[;,]/)[0].toLowerCase();
      return IMAGE_EXTENSIONS_BY_TYPE[type] || '';
    }
    const path = text.split('#')[0].split('?')[0];
    const match = /\.([a-z0-9]{2,5})$/i.exec(path);
    if (!match) {
      return '';
    }
    const extension = match[1].toLowerCase();
    if (extension === 'jpeg' || extension === 'jfif') {
      return 'jpg';
    }
    return KNOWN_IMAGE_EXTENSIONS.indexOf(extension) === -1 ? '' : extension;
  }

  /**
   * File extension of a downloaded page (pure): from the bytes, else the content type, else the
   * URL, else 'png' (the 2026 default, NEW:6703-6712).
   * @param {Uint8Array} bytes - Image bytes (may be null).
   * @param {string} contentType - Response content type (may be '').
   * @param {string} url - Image URL.
   * @returns {string}
   */
  function getImageExtension(bytes, contentType, url) {
    const fromBytes = detectImageExtension(bytes);
    if (fromBytes) {
      return fromBytes;
    }
    const type = String(contentType || '').split(';')[0].trim().toLowerCase();
    if (IMAGE_EXTENSIONS_BY_TYPE[type]) {
      return IMAGE_EXTENSIONS_BY_TYPE[type];
    }
    return getExtensionFromName(url) || 'png';
  }

  /**
   * Name of a page file in the ZIP (pure): 'Page-' + page number zero-padded to the digit count
   * of the last page number (NEW:6800, 6833), e.g. (7, 120, 'jpg') -> 'Page-007.jpg'.
   * @param {number} page - Page number.
   * @param {number} lastPage - Last page number of the chapter.
   * @param {string} extension - File extension without dot.
   * @returns {string}
   */
  function buildPageFileName(page, lastPage, extension) {
    let number = String(page);
    const digits = String(Math.max(lastPage, page)).length;
    while (number.length < digits) {
      number = '0' + number;
    }
    return 'Page-' + number + '.' + extension;
  }

  /** Windows device names that cannot be file names. */
  const RESERVED_FILE_NAMES = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i;

  /**
   * Makes a text safe as a file name on Windows, macOS and Linux (pure): removes control
   * characters, replaces \ / : * ? " < > | with '_', trims spaces and trailing dots, avoids
   * device names, limits the length. The 2026 version did not sanitize (NEW:6866).
   * @param {string} name - Wanted name (a chapter title).
   * @param {string} fallback - Used when nothing is left.
   * @returns {string}
   */
  function sanitizeFileName(name, fallback) {
    let text = String(name === null || name === undefined ? '' : name);
    text = text.replace(/[\u0000-\u001F\u007F]/g, '').replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' ').trim().replace(/[. ]+$/, '');
    if (RESERVED_FILE_NAMES.test(text)) {
      text = '_' + text;
    }
    if (text.length > CONFIG.maxFileNameLength) {
      text = text.slice(0, CONFIG.maxFileNameLength).trim();
    }
    return text === '' ? (fallback || 'download') : text;
  }

  /**
   * Name of the chapter ZIP: title + '.zip', sanitized (pure).
   * @param {string} title - Chapter title.
   * @returns {string}
   */
  function buildZipFileName(title) {
    return sanitizeFileName(title, 'manga') + '.zip';
  }

  // ---- Part B.2: getting the bytes of an image ----

  /**
   * Bytes of a data: URL (base64 or percent-encoded).
   * @param {string} url - data: URL.
   * @returns {{bytes: Uint8Array, contentType: string}}
   */
  function decodeDataUrl(url) {
    const comma = url.indexOf(',');
    if (url.indexOf('data:') !== 0 || comma < 0) {
      throw new Error('not a data: URL');
    }
    const header = url.slice(5, comma);
    const data = url.slice(comma + 1);
    const contentType = header.split(';')[0] || 'text/plain';
    if (/;base64$/i.test(header)) {
      const binary = window.atob(data.replace(/\s+/g, ''));
      const bytes = new Uint8Array(binary.length);
      for (let i = 0; i < binary.length; i++) {
        bytes[i] = binary.charCodeAt(i);
      }
      return { bytes: bytes, contentType: contentType };
    }
    return { bytes: encodeUtf8(decodeURIComponent(data)), contentType: contentType };
  }

  /**
   * Value of one header in a raw header block ("Name: value" lines), or ''.
   * @param {string} headersText - responseHeaders of GM_xmlhttpRequest.
   * @param {string} name - Header name, any case.
   * @returns {string}
   */
  function readResponseHeader(headersText, name) {
    const wanted = name.toLowerCase() + ':';
    const lines = String(headersText || '').split(/\r?\n/);
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].toLowerCase().indexOf(wanted) === 0) {
        return lines[i].slice(wanted.length).trim();
      }
    }
    return '';
  }

  /**
   * Downloads bytes with the userscript manager (not limited by CORS; sends the chapter page
   * as Referer, as the 2025 version did, OLD:3109-3125). Rejects on HTTP errors, network errors,
   * timeouts and aborts. `abortHolder.abort` is set so a cancel can stop the request.
   * @param {string} url - Image URL.
   * @param {{abort: (function|null)}} abortHolder - Receives the abort function.
   * @returns {Promise<{bytes: Uint8Array, contentType: string}>}
   */
  function fetchBytesWithManager(url, abortHolder) {
    const request = new Promise(function (resolve, reject) {
      const handle = GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        responseType: 'arraybuffer',
        timeout: CONFIG.downloadRequestTimeoutMs,
        headers: { Referer: window.location.href },
        onload: function (response) {
          if (response.status >= 200 && response.status < 300 && response.response) {
            resolve({ bytes: new Uint8Array(response.response), contentType: readResponseHeader(response.responseHeaders, 'content-type') });
          } else {
            reject(new Error('HTTP ' + response.status + ' for ' + url));
          }
        },
        onerror: function () {
          reject(new Error('network error for ' + url));
        },
        ontimeout: function () {
          reject(new Error('timeout after ' + CONFIG.downloadRequestTimeoutMs + ' ms for ' + url));
        },
        onabort: function () {
          reject(new Error('aborted: ' + url));
        }
      });
      if (abortHolder) {
        abortHolder.abort = handle && typeof handle.abort === 'function' ? function () {
          handle.abort();
        } : null;
      }
    });
    // Some managers never call back for some errors: never wait forever (the 2025 bug).
    return withTimeout(request, CONFIG.downloadRequestTimeoutMs + 5000, 'GM_xmlhttpRequest ' + url);
  }

  /**
   * Downloads bytes with the page's fetch (works for same-origin, CORS-enabled and blob: URLs).
   * @param {string} url - Image URL.
   * @returns {Promise<{bytes: Uint8Array, contentType: string}>}
   */
  async function fetchBytesWithBrowser(url) {
    const response = await fetchOrThrow(url, { credentials: 'same-origin' }, CONFIG.downloadRequestTimeoutMs);
    const buffer = await response.arrayBuffer();
    return { bytes: new Uint8Array(buffer), contentType: response.headers.get('content-type') || '' };
  }

  /**
   * True when a URL has the same origin as this page.
   * @param {string} url - URL.
   * @returns {boolean}
   */
  function isSameOriginUrl(url) {
    try {
      return new URL(url, window.location.href).origin === window.location.origin;
    } catch (error) {
      return false;
    }
  }

  /**
   * Bytes of one page image: data: URLs are decoded, blob: URLs and pages without a userscript
   * manager use fetch, everything else uses GM_xmlhttpRequest (fallback: fetch for same-origin).
   * @param {string} url - Image URL.
   * @param {{abort: (function|null)}} abortHolder - See fetchBytesWithManager.
   * @returns {Promise<{bytes: Uint8Array, contentType: string}>}
   */
  async function fetchImageBytes(url, abortHolder) {
    if (url.indexOf('data:') === 0) {
      return decodeDataUrl(url);
    }
    if (url.indexOf('blob:') === 0 || typeof GM_xmlhttpRequest === 'undefined') {
      return fetchBytesWithBrowser(url);
    }
    try {
      return await fetchBytesWithManager(url, abortHolder);
    } catch (managerError) {
      if (!isSameOriginUrl(url) || (abortHolder && abortHolder.cancelled)) {
        throw managerError;
      }
      logDebug('download: GM_xmlhttpRequest failed, trying fetch for the same-origin URL', url, describeError(managerError));
      return fetchBytesWithBrowser(url);
    }
  }

  /**
   * Saves bytes as a file: Blob + object URL + a click on <a download>; the URL is released
   * after CONFIG.objectUrlRevokeMs (replaces FileSaver).
   * @param {Uint8Array[]} chunks - File content.
   * @param {string} fileName - Suggested file name.
   * @param {string} mimeType - Content type.
   */
  function saveBytesAsFile(chunks, fileName, mimeType) {
    const blob = new Blob(chunks, { type: mimeType });
    const objectUrl = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = objectUrl;
    link.download = fileName;
    link.rel = 'noopener';
    link.style.display = 'none';
    (document.body || document.documentElement).appendChild(link);
    link.click();
    link.remove();
    setTimeout(function () {
      URL.revokeObjectURL(objectUrl);
    }, CONFIG.objectUrlRevokeMs);
    log('Saved file "' + fileName + '" (' + blob.size + ' bytes)');
  }

  // ---- Part B.3: the download feature ----
  // Button states:
  //   'unavailable' - no chapter is open (disabled);
  //   'loading'     - pages are still loading (enabled, dimmed: a click STARTS a partial
  //                   download at once with the loaded pages, shows a toast "Downloading N of
  //                   M pages; X are still loading", and lists the pages not loaded yet in
  //                   failed_pages.txt; with zero loaded pages it only shows a toast). M3 kept
  //                   the button disabled here, so a click did nothing (M4 fix; the M4 "offer"
  //                   toast step was removed by the M4 follow-up decision);
  //   'available'   - every page finished (loaded or failed for good);
  //   'working'     - progress shown, a second click only shows "already running".
  // After finishing, cancelling or failing, the state is recomputed ('available' or 'loading';
  // the 2026 button stayed disabled, docs/05 section 4 #8). Auto-download (`downloadZip`) runs
  // ONCE per chapter (docs/05 section 4 #9); any download, also a partial one, uses it up.

  /** Download state. */
  const DOWNLOAD = {
    state: 'unavailable',
    cancelled: false,
    toastId: '',
    abortHolder: null,
    autoDownloadDone: false,
    lastProgress: ''
  };

  /**
   * How far the chapter is: pages loaded, failed, still pending, and whether every page has
   * finished (loaded or failed for good) with at least one loaded.
   * @returns {{loaded: number, failed: number, pending: number, total: number, settled: boolean}}
   */
  function getDownloadReadiness() {
    const manga = APP_STATE.manga;
    if (!manga) {
      return { loaded: 0, failed: 0, pending: 0, total: 0, settled: false };
    }
    const counts = countImages();
    const total = manga.pages - manga.begin + 1;
    const pending = Math.max(0, total - counts.loaded - counts.failed);
    return { loaded: counts.loaded, failed: counts.failed, pending: pending, total: total, settled: counts.loaded > 0 && pending === 0 };
  }

  /**
   * True when every page of the chapter has finished (loaded or failed for good) and at least
   * one loaded. With lazy loading, pages that never loaded keep this false; the download is
   * then still possible as a partial download (see announcePartialDownload).
   * @returns {boolean}
   */
  function isChapterSettled() {
    return getDownloadReadiness().settled;
  }

  /** Writes the download button state (enabled, partial/working class, progress text). */
  function renderDownloadButton() {
    const button = document.getElementById('download');
    if (!button) {
      return;
    }
    const unavailable = DOWNLOAD.state === 'unavailable';
    button.disabled = unavailable;
    button.classList.toggle('disabled', unavailable);
    button.classList.toggle('partial', DOWNLOAD.state === 'loading');
    button.classList.toggle('working', DOWNLOAD.state === 'working');
    const progress = button.querySelector('.mvm-download-progress');
    if (progress) {
      progress.textContent = DOWNLOAD.state === 'working' ? DOWNLOAD.lastProgress : '';
    }
  }

  /**
   * Recomputes 'unavailable' / 'loading' / 'available' (not while working) and starts the
   * automatic download once when the chapter is complete and `downloadZip` is on.
   */
  function updateDownloadAvailability() {
    if (DOWNLOAD.state !== 'working') {
      if (!APP_STATE.manga) {
        DOWNLOAD.state = 'unavailable';
      } else {
        DOWNLOAD.state = isChapterSettled() ? 'available' : 'loading';
      }
    }
    renderDownloadButton();
    if (DOWNLOAD.state === 'available' && getSetting('downloadZip') === true && !DOWNLOAD.autoDownloadDone) {
      DOWNLOAD.autoDownloadDone = true;
      log('All pages finished and "Download Images as Zip Automatically" is on: downloading once');
      safeRunAsync('download.auto', function () {
        return runDownload('auto');
      });
    }
  }

  /**
   * Download clicked while pages are still loading: never a silent no-op. Shows an informative
   * toast and tells the caller whether a partial download can start (at least one page loaded).
   * With zero loaded pages the toast says so and nothing is downloaded.
   * @param {{loaded: number, failed: number, pending: number, total: number}} readiness - From getDownloadReadiness.
   * @returns {boolean} True when the partial download should start now.
   */
  function announcePartialDownload(readiness) {
    log('Download requested while loading: ' + readiness.loaded + ' loaded, ' + readiness.failed + ' failed, ' + readiness.pending + ' still loading, of ' + readiness.total + ' pages');
    if (readiness.loaded === 0) {
      showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_NOTHING_LOADED') });
      return false;
    }
    showToast({
      type: 'warning',
      title: getLocaleString('DOWNLOAD_PARTIAL_STARTED').replace('##loaded##', String(readiness.loaded)).replace('##total##', String(readiness.total)).replace('##pending##', String(readiness.pending)),
      message: getLocaleString('DOWNLOAD_PARTIAL_NOTE'),
      durationMs: CONFIG.bookmarkToastMs
    });
    return true;
  }

  /**
   * Shows the progress in the toast and on the button.
   * @param {number} done - Pages processed.
   * @param {number} total - Pages in the chapter.
   */
  function reportDownloadProgress(done, total) {
    DOWNLOAD.lastProgress = done + '/' + total;
    updateToast(DOWNLOAD.toastId, {
      message: getLocaleString('DOWNLOAD_PROGRESS').replace('##num##', String(done)).replace('##total##', String(total)),
      progressPercent: total > 0 ? (done / total) * 100 : 0
    });
    renderDownloadButton();
  }

  /**
   * True when a page must be left out of a partial download: it is neither loaded nor failed
   * for good (waiting, loading, retry pending, or deferred by lazy loading).
   * @param {{status: string}} record - Image record of the page.
   * @returns {boolean}
   */
  function isPagePending(record) {
    return record.status !== IMAGE_STATUS.LOADED && record.status !== IMAGE_STATUS.FAILED;
  }

  /**
   * Gets the bytes of every page in page order, skipping (and listing) the ones that fail.
   * In a partial download, pages still pending are not requested; they are listed with the
   * reason "not loaded yet".
   * @param {Object} manga - The manga object.
   * @param {boolean} partial - True for a download started while pages were still loading.
   * @returns {Promise<{files: Object[], failed: {page: number, url: string, reason: string}[], cancelled: boolean}>}
   */
  async function collectPageFiles(manga, partial) {
    const pages = sequence(manga.pages, manga.begin);
    const files = [];
    const failed = [];
    for (let i = 0; i < pages.length; i++) {
      if (DOWNLOAD.cancelled) {
        return { files: files, failed: failed, cancelled: true };
      }
      const page = pages[i];
      const record = getImageRecord(page);
      const source = record.src;
      reportDownloadProgress(i, pages.length);
      if (partial && isPagePending(record)) {
        // The site's image (or page) URL, so the user can still fetch it by hand.
        const siteUrl = source || (manga.listImages && manga.listImages[page - 1]) || (manga.listPages && manga.listPages[page - 1]) || '';
        failed.push({ page: page, url: String(siteUrl), reason: 'not loaded yet (still ' + record.status + ' when the download started)' });
        logDebug('download: page ' + page + ' skipped, still ' + record.status);
        continue;
      }
      if (!source) {
        failed.push({ page: page, url: '', reason: 'no image URL (page never loaded)' });
        logWarn('Download: page ' + page + ' skipped, it has no image URL');
        continue;
      }
      DOWNLOAD.abortHolder = { abort: null, cancelled: false };
      try {
        const data = await fetchImageBytes(source, DOWNLOAD.abortHolder);
        const extension = getImageExtension(data.bytes, data.contentType, source);
        files.push({ name: buildPageFileName(page, manga.pages, extension), bytes: data.bytes });
        logDebug('download: page ' + page + ' ok', data.bytes.length + ' bytes', extension, source);
      } catch (error) {
        if (DOWNLOAD.cancelled) {
          return { files: files, failed: failed, cancelled: true };
        }
        failed.push({ page: page, url: source, reason: describeError(error) });
        logError('download.page', error, { page: page, url: source });
      }
    }
    reportDownloadProgress(pages.length, pages.length);
    return { files: files, failed: failed, cancelled: DOWNLOAD.cancelled };
  }

  /**
   * Text file listing the skipped pages (added to the ZIP like the 2026 version's
   * failed_pages.txt, NEW:6859-6862).
   * @param {{page: number, url: string, reason: string}[]} failed - Skipped pages.
   * @returns {{name: string, bytes: Uint8Array}}
   */
  function buildFailedPagesFile(failed) {
    const lines = failed.map(function (entry) {
      return 'Page ' + entry.page + ': ' + (entry.url || '(no URL)') + ' - ' + entry.reason;
    });
    return { name: 'failed_pages.txt', bytes: encodeUtf8(lines.join('\r\n') + '\r\n') };
  }

  /**
   * Builds and saves the ZIP from collected pages and shows the result toasts.
   * @param {{files: Object[], failed: Object[]}} result - Output of collectPageFiles.
   * @param {string} zipName - File name of the ZIP.
   */
  function saveCollectedPages(result, zipName) {
    if (result.files.length === 0) {
      throw new Error('no page could be downloaded (' + result.failed.length + ' failed)');
    }
    updateToast(DOWNLOAD.toastId, { message: getLocaleString('GENERATING_ZIP') });
    const files = result.failed.length > 0 ? result.files.concat([buildFailedPagesFile(result.failed)]) : result.files;
    saveBytesAsFile(buildZipParts(files), zipName, 'application/zip');
    closeToast(DOWNLOAD.toastId);
    showToast({ type: 'success', title: getLocaleString('DOWNLOAD_SAVED').replace('##file##', zipName) });
    if (result.failed.length > 0) {
      const skipped = result.failed.map(function (entry) {
        return entry.page;
      }).join(', ');
      logWarn('Download incomplete: skipped pages ' + skipped, result.failed);
      showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_INCOMPLETE'), message: getLocaleString('DOWNLOAD_SKIPPED_PAGES').replace('##pages##', skipped), durationMs: CONFIG.bookmarkToastMs });
    }
  }

  /**
   * Downloads the chapter as a ZIP. Never hangs: every request has a timeout, failed pages are
   * skipped and reported (toast + log + failed_pages.txt), and the button always returns to its
   * normal state. While pages are still loading, the download starts at once as a PARTIAL
   * download (loaded pages only; the others are listed in failed_pages.txt), announced by a
   * toast; with zero loaded pages only a toast is shown.
   * @param {string} trigger - 'button' or 'auto' (for the log).
   * @returns {Promise<void>}
   */
  async function runDownload(trigger) {
    if (DOWNLOAD.state === 'working') {
      showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_BUSY') });
      return;
    }
    const readiness = getDownloadReadiness();
    if (!readiness.settled && !announcePartialDownload(readiness)) {
      return;
    }
    // Any download (button, partial or automatic) uses up the automatic one for this chapter.
    DOWNLOAD.autoDownloadDone = true;
    const manga = APP_STATE.manga;
    const total = readiness.total;
    const partial = !readiness.settled;
    const zipName = buildZipFileName(manga.title || document.title);
    log('Download started (' + trigger + (partial ? ', partial: ' + readiness.pending + ' pages still pending' : '') + '): ' + total + ' pages -> "' + zipName + '"');
    DOWNLOAD.state = 'working';
    DOWNLOAD.cancelled = false;
    DOWNLOAD.lastProgress = '0/' + total;
    DOWNLOAD.toastId = showToast({
      type: 'info',
      title: getLocaleString('DOWNLOAD_ZIP'),
      message: getLocaleString('DOWNLOAD_PROGRESS').replace('##num##', '0').replace('##total##', String(total)),
      durationMs: 0,
      progress: true,
      button: { action: 'download-cancel', label: getLocaleString('CANCEL') }
    });
    renderDownloadButton();
    try {
      const result = await collectPageFiles(manga, partial);
      if (result.cancelled) {
        log('Download cancelled after ' + result.files.length + ' pages; nothing saved');
        closeToast(DOWNLOAD.toastId);
        showToast({ type: 'warning', title: getLocaleString('DOWNLOAD_CANCELLED') });
        return;
      }
      saveCollectedPages(result, zipName);
    } catch (error) {
      logError('download', error, { title: manga.title, pages: total, file: zipName, partial: partial });
      closeToast(DOWNLOAD.toastId);
      showToast({ type: 'error', title: getLocaleString('DOWNLOAD_FAILED').replace('##error##', describeError(error)), durationMs: CONFIG.bookmarkToastMs });
    } finally {
      DOWNLOAD.state = 'unavailable';
      DOWNLOAD.cancelled = false;
      DOWNLOAD.toastId = '';
      DOWNLOAD.abortHolder = null;
      DOWNLOAD.lastProgress = '';
      updateDownloadAvailability();
    }
  }

  /** Cancel button of the progress toast: stops before the next page and aborts the request. */
  function cancelDownload() {
    if (DOWNLOAD.state !== 'working') {
      return;
    }
    DOWNLOAD.cancelled = true;
    if (DOWNLOAD.abortHolder) {
      DOWNLOAD.abortHolder.cancelled = true;
      if (typeof DOWNLOAD.abortHolder.abort === 'function') {
        safeRun('download.abort', DOWNLOAD.abortHolder.abort);
      }
    }
    log('Download cancel requested');
  }

  ACTIONS.download = function () {
    safeRunAsync('action:download', function () {
      return runDownload('button');
    });
  };
  ACTIONS['download-cancel'] = cancelDownload;

  // Every image change can complete the chapter; a language change redraws the header button.
  onAppStateChange(function (what) {
    if (what === 'image') {
      updateDownloadAvailability();
    } else if (what === 'textsRefreshed') {
      renderDownloadButton();
    }
  });

  // ==========================================================================================
  // ==== 20. FEATURES =========================================================================
  // Reader features of milestone M3 that sit on top of the sections above. Each block registers
  // its own ACTIONS / SETTING_APPLIERS entries and app-state listeners (like section 17):
  //   20.1 Setting-change toasts  - a toast after each SAVED setting (2026 version showed one
  //                                 for every change, NEW:3871-3881; docs/04 8.2 item 12)
  //   20.2 Bookmarks              - panel actions, remove/clear, one-time removal at the end
  //   20.3 Auto-scroll            - continuous scrolling, pause on wheel/touch, stop at the end
  //   20.4 Comments               - capture Disqus/Facebook comments before the takeover and
  //                                 show them in the Comments panel (2025 version, decision D4)
  //   20.5 Local files            - open images, a folder or a ZIP/CBZ archive on the local
  //                                 page (2025 version, decision D4); section 21 connects it
  // ==========================================================================================

  // ---- 20.1 Setting-change toasts ----

  /** Settings whose saves show no toast (same list as the 2026 version, NEW:3872). */
  const SETTING_TOAST_EXCLUDED_KEYS = ['bookmarks', 'zoomValue'];

  /**
   * Localized label of a setting: its settings-panel row label, else the key itself.
   * @param {string} key - Setting name.
   * @returns {string}
   */
  function getSettingLabel(key) {
    if (key === 'keybinds') {
      return getLocaleString('KEYBINDINGS');
    }
    for (let g = 0; g < SETTINGS_PANEL_LAYOUT.length; g++) {
      const rows = SETTINGS_PANEL_LAYOUT[g].rows;
      for (let r = 0; r < rows.length; r++) {
        if (rows[r].setting === key) {
          return getLocaleString(rows[r].labelKey);
        }
      }
    }
    return key;
  }

  /**
   * Short text of a setting value for a toast: the choice label when the setting has a fixed
   * list of choices, the value for numbers/booleans/strings, '...' for objects and arrays.
   * @param {string} key - Setting name.
   * @param {*} value - Value.
   * @returns {string}
   */
  function formatSettingValue(key, value) {
    const choices = SETTING_CHOICES[key] || [];
    for (let i = 0; i < choices.length; i++) {
      if (String(choices[i].value) === String(value)) {
        return getChoiceLabel(choices[i]);
      }
    }
    if (value === null || value === undefined) {
      return '-';
    }
    if (typeof value === 'object') {
      return '...';
    }
    return String(value);
  }

  onSettingSaved(function (key, value, previous) {
    if (SETTING_TOAST_EXCLUDED_KEYS.indexOf(key) !== -1 || deepEqual(value, previous)) {
      return;
    }
    showToast({
      type: 'info',
      title: getLocaleString('SETTING_CHANGED').replace('##setting##', getSettingLabel(key)),
      message: getLocaleString('SETTING_CHANGED_DETAIL').replace('##old##', formatSettingValue(key, previous)).replace('##new##', formatSettingValue(key, value)),
      durationMs: CONFIG.settingToastMs
    });
  });

  // ---- 20.2 Bookmarks ----
  // Saving and the per-page toggle live in sections 08 and 16; the list is drawn by the
  // `bookmarks` applier (section 14). One-time use: the bookmark that resumed this chapter
  // (APP_STATE.resumedBookmarkPage, set by preparePage in section 21) is removed when the LAST
  // page is reached (docs/04 section 8.2 item 13: when it is reached, not when it is queued as
  // NEW:10914 did). A bookmark changed by hand during the visit is never removed automatically.

  /**
   * True when the resume bookmark must now be removed (pure).
   * @param {number} currentPage - Page in view.
   * @param {number} lastPage - Last page of the chapter.
   * @param {number} resumedPage - Page of the bookmark used to resume (0 = none).
   * @param {number} storedPage - Page bookmarked for this URL now (0 = none).
   * @returns {boolean}
   */
  function shouldRemoveResumeBookmark(currentPage, lastPage, resumedPage, storedPage) {
    return resumedPage > 0 && storedPage === resumedPage && currentPage >= lastPage;
  }

  /**
   * Removes the resume bookmark once the last page is in view.
   * @param {number} currentPage - Page in view.
   */
  function removeResumeBookmarkAtEnd(currentPage) {
    const manga = APP_STATE.manga;
    if (!manga || APP_STATE.resumedBookmarkPage <= 0) {
      return;
    }
    if (!shouldRemoveResumeBookmark(currentPage, manga.pages, APP_STATE.resumedBookmarkPage, getBookmarkedPage())) {
      return;
    }
    APP_STATE.resumedBookmarkPage = 0;
    if (removeBookmark()) {
      log('Last page reached: the bookmark used to resume this chapter was removed (only once per bookmark)', window.location.href);
      showToast({ type: 'info', title: getLocaleString('BOOKMARK_REMOVED'), durationMs: CONFIG.toastDurationMs });
    }
  }

  /**
   * Trash button of a row: removes that URL's bookmark.
   * @param {Object} context - Action context; value = bookmark URL.
   */
  function actionRemoveBookmark(context) {
    const url = context.value || '';
    if (!url) {
      logWarn('bookmark-remove: no URL on the button');
      return;
    }
    if (url === window.location.href) {
      APP_STATE.resumedBookmarkPage = 0;
    }
    if (removeBookmark(url)) {
      log(getLocaleString('BOOKMARK_REMOVED'), url);
      showToast({ type: 'error', title: getLocaleString('BOOKMARK_REMOVED'), message: url, durationMs: CONFIG.bookmarkToastMs });
    } else {
      logWarn('bookmark-remove: no bookmark for', url);
    }
  }

  /** Panel header button: bookmark (or un-bookmark) the page in view. */
  function actionToggleCurrentBookmark() {
    const manga = APP_STATE.manga;
    const page = APP_STATE.currentPage || (manga ? manga.begin : 1);
    toggleBookmarkForPage(page);
  }

  /**
   * "Remove all bookmarks", after a confirmation.
   * @returns {Promise<void>}
   */
  async function actionClearBookmarks() {
    const count = (getSetting('bookmarks') || []).length;
    if (count === 0) {
      showToast({ type: 'info', title: getLocaleString('LIST_EMPTY') });
      return;
    }
    const answer = await showPopup({
      title: getLocaleString('BOOKMARKS_CLEAR'),
      contentHtml: escapeHtml(getLocaleString('BOOKMARKS_CLEAR_CONFIRM').replace('##count##', String(count))),
      icon: 'warning',
      confirmText: getLocaleString('BOOKMARKS_CLEAR'),
      cancelText: getLocaleString('CANCEL')
    });
    if (answer.result !== POPUP_RESULT.CONFIRM) {
      log('Remove all bookmarks cancelled');
      return;
    }
    APP_STATE.resumedBookmarkPage = 0;
    saveSetting('bookmarks', []);
    log('All ' + count + ' bookmarks removed');
    showToast({ type: 'error', title: getLocaleString('BOOKMARK_REMOVED'), message: String(count), durationMs: CONFIG.settingToastMs });
  }

  ACTIONS['open-bookmarks'] = function () {
    applyBookmarksState();
    setOpenPanel('bookmarks');
  };
  ACTIONS['bookmark-remove'] = actionRemoveBookmark;
  ACTIONS['bookmark-toggle-current'] = actionToggleCurrentBookmark;
  ACTIONS['bookmarks-clear'] = function () {
    safeRunAsync('action:bookmarks-clear', actionClearBookmarks);
  };

  onAppStateChange(function (what, value) {
    if (what === 'currentPage') {
      removeResumeBookmarkAtEnd(value);
    }
  });

  // ---- 20.3 Auto-scroll ----
  // Each frame scrolls `scrollHeight` pixels per 60 Hz "tick" (scaled by the real frame time,
  // so 120 Hz screens do not scroll twice as fast): the window in the vertical modes, the
  // chapter sideways in the horizontal modes (to the left in FluidRTL). Instant steps instead
  // of the earlier versions' "smooth" step per frame (NEW:7000-7024, OLD:2753-2775): a new smooth
  // scroll every frame cancels the previous one, so the real speed depended on the browser.
  // The mouse wheel or a touch pauses it; it resumes CONFIG.autoScrollResumeMs after the last
  // wheel/touch event (2025/2026 behavior). It stops at the end, detected two ways: the
  // remaining distance (handles the negative scrollLeft of FluidRTL in current browsers) and
  // "the position did not move for a few frames" (covers browsers with another sign convention).

  /** Auto-scroll runtime state. */
  const AUTO_SCROLL = {
    running: false,
    pausedByUser: false,
    touching: false,
    frameId: 0,
    lastTime: 0,
    carry: 0,
    stuckFrames: 0,
    resumeTimer: null,
    listenersAttached: false
  };

  /**
   * Whole pixels to scroll in one frame (pure). The fraction is carried to the next frame so a
   * slow speed still moves. Frame times above 100 ms (tab in background) count as 100 ms.
   * @param {number} speed - Setting `scrollHeight` (pixels per 60 Hz tick).
   * @param {number} elapsedMs - Time since the previous frame.
   * @param {number} carry - Fraction left from the previous frame.
   * @returns {{pixels: number, carry: number}}
   */
  function computeAutoScrollStep(speed, elapsedMs, carry) {
    const exact = Math.max(0, Number(speed) || 0) * (Math.min(Math.max(elapsedMs, 0), 100) / CONFIG.autoScrollTickMs) + (carry || 0);
    const pixels = Math.floor(exact);
    return { pixels: pixels, carry: exact - pixels };
  }

  /**
   * Distance left to the end of a scroll area (pure). `direction` -1 = scrolling toward
   * negative positions (FluidRTL: a row-reverse flex box scrolls from 0 down to -(max) in
   * current browsers).
   * @param {number} position - scrollTop / scrollLeft / pageYOffset.
   * @param {number} viewportSize - Visible size.
   * @param {number} contentSize - Total scrollable size.
   * @param {number} direction - 1 or -1.
   * @returns {number}
   */
  function computeScrollRemaining(position, viewportSize, contentSize, direction) {
    const maximum = Math.max(0, contentSize - viewportSize);
    return direction < 0 ? maximum + position : maximum - position;
  }

  /**
   * What auto-scroll moves now, from the view mode.
   * @returns {{horizontal: boolean, direction: number, element: (Element|null)}}
   */
  function getAutoScrollTarget() {
    const viewMode = getSetting('viewMode');
    const chapter = getChapterElement();
    if (isFluidViewMode(viewMode) && chapter) {
      return { horizontal: true, direction: viewMode === 'FluidRTL' ? -1 : 1, element: chapter };
    }
    return { horizontal: false, direction: 1, element: null };
  }

  /**
   * Scrolls the target by some pixels and returns the remaining distance and whether it moved.
   * @param {{horizontal: boolean, direction: number, element: (Element|null)}} target - From getAutoScrollTarget.
   * @param {number} pixels - Whole pixels (> 0).
   * @returns {{moved: boolean, remaining: number}}
   */
  function scrollAutoScrollTarget(target, pixels) {
    if (target.horizontal) {
      const before = target.element.scrollLeft;
      target.element.scrollLeft = before + pixels * target.direction;
      const after = target.element.scrollLeft;
      return { moved: after !== before, remaining: computeScrollRemaining(after, target.element.clientWidth, target.element.scrollWidth, target.direction) };
    }
    const beforeY = window.pageYOffset;
    window.scrollBy(0, pixels);
    const afterY = window.pageYOffset;
    const contentHeight = Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
    return { moved: afterY !== beforeY, remaining: computeScrollRemaining(afterY, window.innerHeight, contentHeight, 1) };
  }

  /** Calls the next frame (requestAnimationFrame, or a 16 ms timer where it is missing). */
  function requestAutoScrollFrame() {
    if (typeof window.requestAnimationFrame === 'function') {
      AUTO_SCROLL.frameId = window.requestAnimationFrame(autoScrollFrame);
    } else {
      AUTO_SCROLL.frameId = setTimeout(function () {
        autoScrollFrame(Date.now());
      }, 16);
    }
  }

  /** Cancels the pending frame. */
  function cancelAutoScrollFrame() {
    if (typeof window.cancelAnimationFrame === 'function') {
      window.cancelAnimationFrame(AUTO_SCROLL.frameId);
    }
    clearTimeout(AUTO_SCROLL.frameId);
    AUTO_SCROLL.frameId = 0;
  }

  /**
   * One animation frame of auto-scroll.
   * @param {number} time - Frame time in milliseconds.
   */
  function autoScrollFrame(time) {
    if (!AUTO_SCROLL.running) {
      return;
    }
    safeRun('autoScroll.frame', function () {
      const elapsed = AUTO_SCROLL.lastTime ? time - AUTO_SCROLL.lastTime : CONFIG.autoScrollTickMs;
      AUTO_SCROLL.lastTime = time;
      const step = computeAutoScrollStep(getSetting('scrollHeight'), elapsed, AUTO_SCROLL.carry);
      AUTO_SCROLL.carry = step.carry;
      if (step.pixels > 0) {
        const result = scrollAutoScrollTarget(getAutoScrollTarget(), step.pixels);
        AUTO_SCROLL.stuckFrames = result.moved ? 0 : AUTO_SCROLL.stuckFrames + 1;
        if (result.remaining <= 1 || AUTO_SCROLL.stuckFrames >= CONFIG.autoScrollStuckFrames) {
          stopAutoScroll('reached the end');
          return;
        }
      }
      requestAutoScrollFrame();
    });
  }

  /** Header button: play icon while stopped, pause icon while running or paused by the user. */
  function renderAutoScrollButton() {
    const button = document.getElementById('AutoScroll');
    if (button) {
      button.classList.toggle('running', AUTO_SCROLL.running || AUTO_SCROLL.pausedByUser);
    }
  }

  /** Starts (or resumes) the frame loop. */
  function runAutoScrollLoop() {
    AUTO_SCROLL.running = true;
    AUTO_SCROLL.lastTime = 0;
    AUTO_SCROLL.carry = 0;
    AUTO_SCROLL.stuckFrames = 0;
    cancelAutoScrollFrame();
    requestAutoScrollFrame();
  }

  /** Starts auto-scroll. */
  function startAutoScroll() {
    attachAutoScrollListeners();
    AUTO_SCROLL.pausedByUser = false;
    runAutoScrollLoop();
    renderAutoScrollButton();
    log('Auto-scroll started: ' + getSetting('scrollHeight') + ' px per tick, ' + (getAutoScrollTarget().horizontal ? 'sideways' : 'down'));
  }

  /**
   * Stops auto-scroll completely.
   * @param {string} reason - For the log.
   */
  function stopAutoScroll(reason) {
    AUTO_SCROLL.running = false;
    AUTO_SCROLL.pausedByUser = false;
    clearTimeout(AUTO_SCROLL.resumeTimer);
    AUTO_SCROLL.resumeTimer = null;
    cancelAutoScrollFrame();
    renderAutoScrollButton();
    log('Auto-scroll stopped: ' + reason);
  }

  /** Space key / header button: start, or stop when running or paused by the user. */
  function toggleAutoScroll() {
    if (AUTO_SCROLL.running || AUTO_SCROLL.pausedByUser) {
      stopAutoScroll('toggled off');
    } else {
      startAutoScroll();
    }
  }

  /** Resumes after a pause caused by the wheel or a touch (unless a finger is still down). */
  function resumeAutoScrollAfterPause() {
    AUTO_SCROLL.resumeTimer = null;
    if (!AUTO_SCROLL.pausedByUser || AUTO_SCROLL.touching) {
      return;
    }
    AUTO_SCROLL.pausedByUser = false;
    logDebug('auto-scroll resumed after manual scrolling');
    runAutoScrollLoop();
    renderAutoScrollButton();
  }

  /**
   * Wheel / touch while auto-scroll runs: pause now, resume CONFIG.autoScrollResumeMs after the
   * last such event (NEW:7040-7061, OLD:2791-2804).
   * @param {Event} event - wheel, touchstart, touchmove or touchend.
   */
  function onManualScrollInput(event) {
    if (event.type === 'touchstart') {
      AUTO_SCROLL.touching = true;
    } else if (event.type === 'touchend' || event.type === 'touchcancel') {
      AUTO_SCROLL.touching = false;
    }
    if (AUTO_SCROLL.running) {
      AUTO_SCROLL.running = false;
      AUTO_SCROLL.pausedByUser = true;
      cancelAutoScrollFrame();
      logDebug('auto-scroll paused by ' + event.type);
    }
    if (AUTO_SCROLL.pausedByUser) {
      clearTimeout(AUTO_SCROLL.resumeTimer);
      AUTO_SCROLL.resumeTimer = setTimeout(resumeAutoScrollAfterPause, CONFIG.autoScrollResumeMs);
    }
  }

  /** Attaches the wheel/touch listeners once (on the first start). */
  function attachAutoScrollListeners() {
    if (AUTO_SCROLL.listenersAttached) {
      return;
    }
    const handler = guardHandler('autoScroll.manualInput', onManualScrollInput);
    ['wheel', 'touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(function (type) {
      window.addEventListener(type, handler, { passive: true });
    });
    AUTO_SCROLL.listenersAttached = true;
  }

  ACTIONS['toggle-auto-scroll'] = toggleAutoScroll;

  onAppStateChange(function (what) {
    if (what === 'textsRefreshed') {
      renderAutoScrollButton();
    }
  });

  // ---- 20.4 Comments ----
  // Before the takeover the site's comments section (CONFIG.commentsSelector: Disqus or
  // Facebook) is scrolled into view so it loads, awaited at most CONFIG.commentsWaitTimeoutMs,
  // and KEPT (the element object survives the takeover). A site module may return it as
  // `manga.comments` (an element) instead. After the takeover it is moved into #CommentsArea.
  // Setting `enableComments` (default true, as in the 2025 version) switches capture off.

  /**
   * True for a DOM element (works across script and page worlds, unlike instanceof).
   * @param {*} value - Anything.
   * @returns {boolean}
   */
  function isElementNode(value) {
    return value !== null && typeof value === 'object' && value.nodeType === 1 && typeof value.appendChild === 'function';
  }

  /**
   * True when a comments element shows content (an iframe of the comment service, or text).
   * @param {Element|null} element - Comments container.
   * @returns {boolean}
   */
  function hasCommentsContent(element) {
    if (!element) {
      return false;
    }
    return element.querySelector('iframe:not(#indicator-north):not(#indicator-south)') !== null || readText(element) !== '';
  }

  /**
   * Finds and waits for the site's comments (never longer than CONFIG.commentsWaitTimeoutMs).
   * @param {Object} manga - The manga object (its `comments` element wins when present).
   * @returns {Promise<Element|null>}
   */
  async function captureComments(manga) {
    if (getSetting('enableComments') !== true) {
      logDebug('comments: capture is off (setting enableComments)');
      return null;
    }
    if (manga && isElementNode(manga.comments)) {
      logDebug('comments: provided by the site module');
      return manga.comments;
    }
    const found = query(CONFIG.commentsSelector);
    if (!found) {
      logDebug('comments: no comments section on this page', CONFIG.commentsSelector);
      return null;
    }
    log('Comments: waiting up to ' + CONFIG.commentsWaitTimeoutMs + ' ms for ' + CONFIG.commentsSelector + ' to load');
    window.scrollTo(0, document.body ? document.body.scrollHeight : 0);
    try {
      await waitUntil(function () {
        return hasCommentsContent(query(CONFIG.commentsSelector)) ? true : null;
      }, 'comments (' + CONFIG.commentsSelector + ')', CONFIG.commentsWaitTimeoutMs);
      log('Comments captured');
    } catch (error) {
      log('Comments not loaded in time; continuing without waiting longer (' + describeError(error) + ')');
    }
    window.scrollTo(0, 0);
    const element = query(CONFIG.commentsSelector) || found;
    return element.children.length > 0 ? element : null;
  }

  /** Enables the header Comments button when comments exist and the setting is on. */
  function renderCommentsButton() {
    const button = document.getElementById('CommentsButton');
    if (!button) {
      return;
    }
    const available = isElementNode(APP_STATE.comments) && getSetting('enableComments') === true;
    button.disabled = !available;
    button.classList.toggle('disabled', !available);
  }

  /** Moves the captured comments into the Comments panel (again after a panel redraw). */
  function mountComments() {
    const area = document.getElementById('CommentsArea');
    if (area && isElementNode(APP_STATE.comments) && APP_STATE.comments.parentNode !== area) {
      area.appendChild(APP_STATE.comments);
      logDebug('comments: placed in the Comments panel');
    }
    renderCommentsButton();
  }

  /** Light/dark switch of the comments area only (2025 version, OLD:3212-3216). */
  function toggleCommentsColorScheme() {
    const area = document.getElementById('CommentsArea');
    if (area) {
      const light = !area.classList.contains('light');
      area.classList.toggle('light', light);
      area.classList.toggle('dark', !light);
    }
  }

  ACTIONS['open-comments'] = function () {
    if (!isElementNode(APP_STATE.comments)) {
      logWarn('open-comments: no comments were captured');
      return;
    }
    mountComments();
    setOpenPanel('comments');
  };
  ACTIONS['comments-color-scheme'] = toggleCommentsColorScheme;
  SETTING_APPLIERS.enableComments = renderCommentsButton;

  onAppStateChange(function (what) {
    if (what === 'textsRefreshed') {
      mountComments();
    }
  });

  // ---- 20.5 Local files ----
  // The 2025 version (OLD:4897-4979) read images, a folder of images or a ZIP file chosen on a
  // local HTML page (inputs #file, #folder, #images of that page), sorted the files naturally
  // and showed them. It used JSZip for ZIP files; it never supported RAR/CBR or 7z. Here: the
  // same, with the own ZIP reader of section 19 (STORE and DEFLATE entries), plus an own panel
  // with the three inputs. Nothing leaves the computer: files become object URLs.
  // These functions do not open the reader themselves: they call `onImagesReady(title, urls)`,
  // which section 21 provides.

  /** Image file extensions accepted from folders and archives (2025 list OLD:4909, plus avif, jfif). */
  const LOCAL_IMAGE_NAME_PATTERN = /\.(png|jpe?g|jfif|gif|bmp|webp|avif)$/i;

  /** Image MIME types accepted from the file inputs (2025 list OLD:4897-4908, plus avif). */
  const LOCAL_IMAGE_TYPES = ['image/apng', 'image/avif', 'image/bmp', 'image/gif', 'image/jpeg', 'image/pjpeg', 'image/png', 'image/svg+xml', 'image/tiff', 'image/webp', 'image/x-icon'];

  /**
   * Natural, number-aware comparison of two names (pure): 'page2' < 'page10', case-insensitive,
   * punctuation ignored like the 2025 version's localeCompare(..., {numeric, ignorePunctuation})
   * (OLD:4910-4914), but the same result in every browser.
   * @param {string} first - Name.
   * @param {string} second - Name.
   * @returns {number} Negative, 0 or positive.
   */
  function compareNaturally(first, second) {
    /**
     * Lower case without spaces and punctuation, for the comparison.
     * @param {string} text - File name.
     * @returns {string}
     */
    const simplify = function (text) {
      return String(text).toLowerCase().replace(/[\s_\-.,;:'"()[\]{}!?#~+]+/g, '');
    };
    const chunksA = simplify(first).match(/\d+|\D+/g) || [];
    const chunksB = simplify(second).match(/\d+|\D+/g) || [];
    const length = Math.min(chunksA.length, chunksB.length);
    for (let i = 0; i < length; i++) {
      const a = chunksA[i];
      const b = chunksB[i];
      const aIsNumber = /^\d/.test(a);
      const bIsNumber = /^\d/.test(b);
      if (aIsNumber && bIsNumber) {
        const difference = parseInt(a, 10) - parseInt(b, 10);
        if (difference !== 0) {
          return difference;
        }
        if (a.length !== b.length) {
          return a.length - b.length;
        }
      } else if (a !== b) {
        if (aIsNumber !== bIsNumber) {
          return aIsNumber ? -1 : 1;
        }
        return a < b ? -1 : 1;
      }
    }
    if (chunksA.length !== chunksB.length) {
      return chunksA.length - chunksB.length;
    }
    return String(first) < String(second) ? -1 : (String(first) > String(second) ? 1 : 0);
  }

  /**
   * Archive type from the first bytes of a file (pure): 'zip', 'rar', '7z' or 'unknown'.
   * @param {Uint8Array} bytes - File start.
   * @returns {string}
   */
  function detectArchiveFormat(bytes) {
    if (!bytes || bytes.length < 4) {
      return 'unknown';
    }
    if (bytes[0] === 0x50 && bytes[1] === 0x4B && ((bytes[2] === 0x03 && bytes[3] === 0x04) || (bytes[2] === 0x05 && bytes[3] === 0x06) || (bytes[2] === 0x07 && bytes[3] === 0x08))) {
      return 'zip';
    }
    if (bytes.length >= 6 && bytes[0] === 0x52 && bytes[1] === 0x61 && bytes[2] === 0x72 && bytes[3] === 0x21 && bytes[4] === 0x1A && bytes[5] === 0x07) {
      return 'rar';
    }
    if (bytes.length >= 6 && bytes[0] === 0x37 && bytes[1] === 0x7A && bytes[2] === 0xBC && bytes[3] === 0xAF && bytes[4] === 0x27 && bytes[5] === 0x1C) {
      return '7z';
    }
    return 'unknown';
  }

  /**
   * True for a chosen file that is an image (by MIME type, else by name).
   * @param {File} file - Chosen file.
   * @returns {boolean}
   */
  function isLocalImageFile(file) {
    return LOCAL_IMAGE_TYPES.indexOf(String(file.type).toLowerCase()) !== -1 || LOCAL_IMAGE_NAME_PATTERN.test(file.name);
  }

  /**
   * MIME type of an image file name ('' when unknown), so the browser shows archive images.
   * @param {string} name - File name.
   * @returns {string}
   */
  function getImageMimeTypeFromName(name) {
    const extension = getExtensionFromName(name);
    const types = Object.keys(IMAGE_EXTENSIONS_BY_TYPE);
    for (let i = 0; i < types.length; i++) {
      if (IMAGE_EXTENSIONS_BY_TYPE[types[i]] === extension) {
        return types[i];
      }
    }
    return '';
  }

  /**
   * Reads a chosen file into bytes (FileReader: works in old browsers without Blob.arrayBuffer).
   * @param {File} file - Chosen file.
   * @returns {Promise<Uint8Array>}
   */
  function readFileBytes(file) {
    return new Promise(function (resolve, reject) {
      const reader = new FileReader();
      reader.onload = function () {
        resolve(new Uint8Array(reader.result));
      };
      reader.onerror = function () {
        reject(reader.error || new Error('FileReader failed'));
      };
      reader.readAsArrayBuffer(file);
    });
  }

  /**
   * An error whose message is shown to the user as is (unsupported file, empty archive...).
   * @param {string} message - Localized text.
   * @returns {Error}
   */
  function createUserFacingError(message) {
    const error = new Error(message);
    error.userFacing = true;
    return error;
  }

  /**
   * Images of a ZIP/CBZ archive, sorted naturally by their path inside the archive, as object
   * URLs. Entries that cannot be read are logged and skipped.
   * @param {File} file - The archive.
   * @returns {Promise<{title: string, listImages: string[]}>}
   */
  async function loadImagesFromArchive(file) {
    const bytes = await readFileBytes(file);
    const format = detectArchiveFormat(bytes);
    if (format !== 'zip') {
      throw createUserFacingError(getLocaleString('LOCAL_FILES_UNSUPPORTED').replace('##file##', file.name));
    }
    const entries = readZipDirectory(bytes).filter(function (entry) {
      return !entry.isDirectory && LOCAL_IMAGE_NAME_PATTERN.test(entry.name);
    }).sort(function (first, second) {
      return compareNaturally(first.name, second.name);
    });
    log('Local archive "' + file.name + '": ' + entries.length + ' images');
    const listImages = [];
    entries.forEach(function (entry) {
      safeRun('localFiles.extract', function () {
        const content = extractZipEntry(bytes, entry);
        const type = getImageMimeTypeFromName(entry.name);
        listImages.push(URL.createObjectURL(type ? new Blob([content], { type: type }) : new Blob([content])));
      }, undefined, { archive: file.name, entry: entry.name, method: entry.method });
    });
    if (listImages.length === 0) {
      throw createUserFacingError(getLocaleString('LOCAL_FILES_EMPTY').replace('##file##', file.name));
    }
    return { title: file.name.replace(/\.[^.]+$/, ''), listImages: listImages };
  }

  /**
   * Chosen image files (or a folder), sorted naturally by their path, as object URLs. The title
   * is the folder name, else "Local Images" (OLD:4947-4963).
   * @param {File[]} files - Chosen files.
   * @returns {{title: string, listImages: string[]}}
   */
  function loadImagesFromFiles(files) {
    const images = files.filter(isLocalImageFile).sort(function (first, second) {
      return compareNaturally(first.webkitRelativePath || first.name, second.webkitRelativePath || second.name);
    });
    if (images.length === 0) {
      throw createUserFacingError(getLocaleString('LOCAL_FILES_EMPTY').replace('##file##', files.length === 1 ? files[0].name : String(files.length)));
    }
    const folder = images[0].webkitRelativePath ? images[0].webkitRelativePath.split('/')[0] : '';
    log('Local images: ' + images.length + ' of ' + files.length + ' chosen files', folder ? '(folder ' + folder + ')' : '');
    return {
      title: folder || getLocaleString('LOCAL_FILES_TITLE'),
      listImages: images.map(function (file) {
        return URL.createObjectURL(file);
      })
    };
  }

  /**
   * Handles a file selection: one non-image file is read as an archive; otherwise the images
   * among the files are used. Errors are logged and shown (toast and the panel status line).
   * @param {FileList|File[]} fileList - Chosen files.
   * @param {function(string, string[]): void} onImagesReady - Opens the reader.
   * @param {HTMLElement|null} statusElement - Where to show progress and errors (may be null).
   * @returns {Promise<void>}
   */
  async function openLocalFileSelection(fileList, onImagesReady, statusElement) {
    const files = Array.prototype.slice.call(fileList || []);
    if (files.length === 0) {
      return;
    }
    /**
     * Writes the status line of the local files panel.
     * @param {string} text - Message.
     * @param {boolean} [isError] - Error look.
     */
    const setStatus = function (text, isError) {
      if (statusElement) {
        statusElement.textContent = text;
        statusElement.classList.toggle('error', isError === true);
      }
    };
    const first = files[0];
    try {
      let result = null;
      if (files.length === 1 && !isLocalImageFile(first)) {
        setStatus(getLocaleString('LOCAL_FILES_READING').replace('##file##', first.name), false);
        result = await loadImagesFromArchive(first);
      } else {
        result = loadImagesFromFiles(files);
      }
      setStatus('', false);
      onImagesReady(result.title, result.listImages);
    } catch (error) {
      const message = error && error.userFacing ? error.message : getLocaleString('LOCAL_FILES_ERROR').replace('##file##', first.name).replace('##error##', describeError(error));
      logError('localFiles.open', error, { files: files.length, first: first.name, size: first.size, type: first.type });
      setStatus(message, true);
      showToast({ type: 'error', title: message, durationMs: CONFIG.bookmarkToastMs });
    }
  }

  /**
   * HTML of the local files panel: three inputs (images, folder, ZIP/CBZ archive).
   * @returns {string}
   */
  function buildLocalFilesPanelHtml() {
    /**
     * HTML of one file input with its label.
     * @param {string} labelKey - Locale key of the label.
     * @param {string} attributes - Extra input attributes.
     * @returns {string}
     */
    const option = function (labelKey, attributes) {
      return '<label class="mvm-upload-option"><span>' + escapeHtml(getLocaleString(labelKey)) + '</span><input type="file" ' + attributes + ' /></label>';
    };
    return [
      '<h2>', escapeHtml(getLocaleString('LOCAL_FILES_OPEN')), '</h2>',
      '<p class="mvm-upload-hint">', escapeHtml(getLocaleString('LOCAL_FILES_HINT')), '</p>',
      option('LOCAL_FILES_IMAGES', 'class="mvm-upload-images" accept="image/*" multiple'),
      option('LOCAL_FILES_FOLDER', 'class="mvm-upload-folder" webkitdirectory directory multiple'),
      option('LOCAL_FILES_ARCHIVE', 'class="mvm-upload-archive" accept=".zip,.cbz,application/zip,application/x-cbz,application/vnd.comicbook+zip"'),
      '<div class="mvm-upload-status" role="status"></div>'
    ].join('');
  }

  /**
   * Shows the local files panel on the current (not taken over) page.
   * @param {function(string, string[]): void} onImagesReady - Opens the reader.
   * @returns {HTMLElement} The panel.
   */
  function showLocalFilesPanel(onImagesReady) {
    installStyle('MVMPopupStyle', POPUP_CSS);
    let panel = document.getElementById('MVMLocalFiles');
    if (panel) {
      return panel;
    }
    panel = document.createElement('div');
    panel.id = 'MVMLocalFiles';
    panel.className = 'mvm-upload';
    setElementHtml(panel, buildLocalFilesPanelHtml());
    const status = panel.querySelector('.mvm-upload-status');
    panel.addEventListener('change', guardHandler('localFiles.change', function (event) {
      if (event.target && event.target.type === 'file') {
        openLocalFileSelection(event.target.files, onImagesReady, status);
      }
    }));
    (document.body || document.documentElement).appendChild(panel);
    log('Local files panel shown: choose images, a folder or a ZIP/CBZ archive');
    return panel;
  }

  /** Removes the local files panel (when the reader opens). */
  function hideLocalFilesPanel() {
    const panel = document.getElementById('MVMLocalFiles');
    if (panel) {
      panel.remove();
    }
  }

  /**
   * Connects the inputs of a local HTML page made for the 2025 version: #file (ZIP),
   * #folder and #images (OLD:4964-4979), and hides its #LocalTest block as that version did.
   * @param {function(string, string[]): void} onImagesReady - Opens the reader.
   * @returns {number} How many inputs were connected.
   */
  function bindLegacyUploadInputs(onImagesReady) {
    let bound = 0;
    ['#file', '#folder', '#images'].forEach(function (selector) {
      const input = query(selector);
      if (input && input.tagName === 'INPUT' && input.type === 'file') {
        input.addEventListener('change', guardHandler('localFiles.legacyInput', function () {
          openLocalFileSelection(input.files, onImagesReady, null);
        }));
        bound += 1;
      }
    });
    const localTest = query('#LocalTest');
    if (bound > 0 && localTest) {
      localTest.style.display = 'none';
    }
    if (bound > 0) {
      log('Local files: connected ' + bound + ' file inputs of this page');
    }
    return bound;
  }

  // ==========================================================================================
  // ==== 21. STARTUP ==========================================================================
  // From page load to a running reader, in this order (docs/02-how-the-viewer-works.md):
  //   1. findMatchingSites   - site modules whose `url` regex matches this address
  //   2. runSite (per site)  - its waits (each with a timeout), then site.run()
  //   3. validateMangaResult - checks the returned object; a bad result is a failure
  //   4. firstSuccessfulSite - the FIRST site that succeeds wins; all failures are logged
  //   5. preparePage         - title, resume page (bookmark), before() hook, load mode
  //   6. openViewer          - capture comments (section 20), take over the page, draw the
  //                            reader, load images
  // On a local page (isStandalone) the local file reader is set up first (section 20.5).
  // Every step logs what it does when verbose logging is on, and every failure names the site
  // and the step. MVMDiagnose() runs steps 1-3 for every matching site without taking over the
  // page, which is the fastest way to test a new site module.
  // ==========================================================================================

  /**
   * Display name of a site (its `name` may be an array of brand names).
   * @param {Object} site - Site module.
   * @returns {string}
   */
  function getSiteName(site) {
    if (!site) {
      return '(no site)';
    }
    return Array.isArray(site.name) ? site.name.join(' / ') : String(site.name);
  }

  /**
   * Site modules whose `url` regex matches the current address. A broken site object is logged
   * and skipped, never fatal.
   * @param {Object[]} sites - All site modules.
   * @param {string} [address=window.location.href] - Address to test.
   * @returns {Object[]}
   */
  function findMatchingSites(sites, address) {
    const href = address || window.location.href;
    return sites.filter(function (site) {
      return safeRun('startup.matchSite', function () {
        if (!site || !(site.url instanceof RegExp) || typeof site.run !== 'function') {
          logWarn('Invalid site module skipped (needs url RegExp and run function):', site);
          return false;
        }
        return site.url.test(href);
      }, false, { site: getSiteName(site) });
    });
  }

  /**
   * Builds the list of wait conditions declared by a site, in the order they run.
   * @param {Object} site - Site module.
   * @returns {{label: string, check: function(): *}[]}
   */
  function describeSiteWaits(site) {
    const waits = [];
    if (site.waitAttr !== undefined) {
      const selector = site.waitAttr[0];
      const attribute = site.waitAttr[1];
      waits.push({
        label: 'attribute "' + attribute + '" on "' + selector + '"',
        check: function () {
          return readAttribute(document.querySelector(selector), attribute);
        }
      });
    }
    if (site.waitEle !== undefined) {
      waits.push({
        label: 'element "' + site.waitEle + '"',
        check: function () {
          return document.querySelector(site.waitEle);
        }
      });
    }
    if (site.waitVar !== undefined) {
      waits.push({
        label: 'page variable "' + site.waitVar + '"',
        check: function () {
          return pageWindow[site.waitVar];
        }
      });
    }
    if (site.waitFunc !== undefined) {
      waits.push({
        label: 'waitFunc condition',
        check: function () {
          return site.waitFunc() ? true : null;
        }
      });
    }
    return waits;
  }

  /**
   * Runs one site: its waits (sequentially, each with a timeout), then run(), then validation.
   * Rejects with an Error whose message names the site and the failed step.
   * @param {Object} site - Site module.
   * @returns {Promise<{site: Object, manga: Object, report: Object}>}
   */
  async function runSite(site) {
    const siteName = getSiteName(site);
    const report = { site: siteName, steps: [], warnings: [] };
    const startedAt = Date.now();
    logDebug('[' + siteName + '] starting checks');

    const waits = describeSiteWaits(site);
    for (let i = 0; i < waits.length; i++) {
      const stepStart = Date.now();
      try {
        await waitUntil(waits[i].check, '[' + siteName + '] ' + waits[i].label, CONFIG.siteWaitTimeoutMs);
        report.steps.push('wait ' + waits[i].label + ': ok in ' + (Date.now() - stepStart) + ' ms');
      } catch (error) {
        report.steps.push('wait ' + waits[i].label + ': FAILED (' + error.message + ')');
        throw new Error('[' + siteName + '] step "wait for ' + waits[i].label + '" failed: ' + error.message);
      }
    }
    if (typeof site.waitTime === 'number' && site.waitTime > 0) {
      logDebug('[' + siteName + '] waiting fixed time ' + site.waitTime + ' ms');
      await sleep(site.waitTime);
      report.steps.push('fixed wait ' + site.waitTime + ' ms');
    }

    let manga = null;
    try {
      manga = await site.run();
    } catch (error) {
      report.steps.push('run(): THREW ' + describeError(error));
      throw new Error('[' + siteName + '] step "run()" threw: ' + describeError(error));
    }
    report.steps.push('run(): returned in ' + (Date.now() - startedAt) + ' ms total');

    const validation = validateMangaResult(manga);
    report.warnings = validation.warnings;
    report.strategy = validation.strategy;
    report.pages = manga && typeof manga.pages === 'number' ? manga.pages : 0;
    validation.warnings.forEach(function (warning) {
      logWarn('[' + siteName + '] ' + warning);
    });
    if (validation.errors.length > 0) {
      throw new Error('[' + siteName + '] result rejected: ' + validation.errors.join(' | '));
    }
    logDebug('[' + siteName + '] accepted', report);
    return { site: site, manga: manga, report: report };
  }

  /**
   * Checks what a site's run() returned. ERRORS make the site fail (so another matching site
   * can win); WARNINGS are only logged. This is the first thing to read when a new site does
   * not work: every message says what is wrong and how to fix it.
   * @param {*} manga - Whatever run() returned.
   * @returns {{errors: string[], warnings: string[], strategy: string}}
   */
  function validateMangaResult(manga) {
    const errors = [];
    const warnings = [];
    if (manga === null || typeof manga !== 'object' || Array.isArray(manga)) {
      errors.push('run() must return an object, got ' + (manga === null ? 'null' : typeof manga));
      return { errors: errors, warnings: warnings, strategy: '' };
    }
    if (typeof manga.pages !== 'number' || isNaN(manga.pages) || manga.pages <= 0) {
      errors.push('pages must be a number > 0 (got ' + manga.pages + '). A site returns 0 on purpose to decline.');
    } else if (Math.floor(manga.pages) !== manga.pages) {
      errors.push('pages must be a whole number (got ' + manga.pages + ')');
    }
    const strategy = getLoadingStrategy(manga);
    if (strategy === '') {
      errors.push('no loading strategy: return a non-empty listImages, or listPages + img, or a bruteForce function');
    }
    const list = strategy === 'images' ? manga.listImages : strategy === 'pages' ? manga.listPages : null;
    if (list) {
      if (typeof manga.pages === 'number' && list.length < manga.pages) {
        warnings.push('pages (' + manga.pages + ') is larger than the list (' + list.length + '): the last pages will be empty. Use list.length.');
      } else if (typeof manga.pages === 'number' && list.length > manga.pages) {
        warnings.push('the list has ' + list.length + ' entries but pages is ' + manga.pages + ': extra entries are ignored.');
      }
      const empty = list.filter(function (entry) {
        return typeof entry !== 'string' || entry.trim() === '';
      }).length;
      if (empty > 0) {
        warnings.push(empty + ' empty or non-text entries in the list (they will show as failed pages). Filter them out.');
      }
      const invalid = list.filter(function (entry) {
        return typeof entry === 'string' && entry.trim() !== '' && !isInMemoryUrl(entry) && !isValidUrl(entry);
      }).length;
      if (invalid > 0) {
        warnings.push(invalid + ' entries are not valid URLs.');
      }
      if (uniqueValues(list).length < list.length) {
        warnings.push('the list contains duplicates (' + (list.length - uniqueValues(list).length) + '). Check the selector or deduplicate.');
      }
    }
    if (strategy === 'pages' && (typeof manga.img !== 'string' || manga.img.trim() === '')) {
      errors.push('listPages needs `img`: the CSS selector of the image inside each page');
    }
    ['title', 'series', 'prev', 'next'].forEach(function (field) {
      if (manga[field] !== undefined && manga[field] !== null && typeof manga[field] !== 'string') {
        warnings.push(field + ' should be a string (got ' + typeof manga[field] + ')');
      }
    });
    ['prev', 'next'].forEach(function (field) {
      if (typeof manga[field] === 'string' && /undefined|null/.test(manga[field])) {
        warnings.push(field + ' contains "undefined" or "null" (' + manga[field] + '): a value was missing when the link was built.');
      }
    });
    if (manga.begin !== undefined && (typeof manga.begin !== 'number' || manga.begin < 1)) {
      warnings.push('begin should be a page number >= 1 (got ' + manga.begin + '); using 1');
    }
    if (manga.before !== undefined && typeof manga.before !== 'function') {
      warnings.push('before should be a function');
    }
    if (manga.reload !== undefined && typeof manga.reload !== 'function') {
      warnings.push('reload should be a function(page) returning an image URL (it is ignored)');
    }
    if (manga.comments !== undefined && manga.comments !== null && !isElementNode(manga.comments)) {
      warnings.push('comments should be a DOM element (the comments container); it is ignored');
    }
    if (manga.lazy !== undefined && typeof manga.lazy !== 'boolean') {
      warnings.push('lazy should be true or false (it overrides the lazy load setting); it is ignored');
    }
    return { errors: errors, warnings: warnings, strategy: strategy };
  }

  /**
   * Runs all matching sites at the same time; resolves with the FIRST success. Rejects only
   * when every site failed, with all failure messages (written for ES2017: no Promise.any).
   * @param {Object[]} sites - Matching site modules.
   * @returns {Promise<{site: Object, manga: Object, report: Object}>}
   */
  function firstSuccessfulSite(sites) {
    return new Promise(function (resolve, reject) {
      const failures = [];
      let settled = false;
      if (sites.length === 0) {
        reject(new Error('No site module matches this address'));
        return;
      }
      sites.forEach(function (site) {
        runSite(site).then(function (result) {
          if (!settled) {
            settled = true;
            resolve(result);
          } else {
            logDebug('[' + getSiteName(site) + '] also succeeded, but another site won first');
          }
        }, function (error) {
          failures.push(describeError(error));
          logDebug('site failed', describeError(error));
          if (failures.length === sites.length && !settled) {
            settled = true;
            reject(new Error('All matching sites failed:\n  - ' + failures.join('\n  - ')));
          }
        });
      });
    });
  }

  /**
   * Fills defaults on the winning manga object and runs its before() hook.
   * @param {Object} site - Winning site.
   * @param {Object} manga - Its manga object (changed in place).
   */
  async function preparePage(site, manga) {
    log('Found ' + manga.pages + ' pages in ' + getSiteName(site));
    if (typeof manga.title !== 'string' || manga.title.trim() === '') {
      manga.title = getPageTitle();
    }
    const bookmarkedPage = getBookmarkedPage();
    const siteBegin = typeof manga.begin === 'number' && manga.begin >= 1 ? manga.begin : 1;
    manga.begin = clamp(bookmarkedPage > 0 ? bookmarkedPage : siteBegin, 1, manga.pages);
    APP_STATE.resumedBookmarkPage = bookmarkedPage > 0 ? bookmarkedPage : 0;
    if (bookmarkedPage > 0) {
      log('Resuming from bookmarked page ' + manga.begin + ' (the bookmark is removed when the last page is reached)');
    }
    if (typeof manga.before === 'function') {
      logDebug('Running the site before() hook');
      await safeRunAsync('site.before', function () {
        return Promise.resolve(manga.before(manga.begin));
      }, undefined, { site: getSiteName(site) });
    }
    APP_STATE.site = site;
    APP_STATE.manga = manga;
  }

  /**
   * Removes the site's page and writes the reader (2025 method: clean document, own HTML).
   * Cloning the whole document drops every event listener the site attached to elements.
   * @param {Object} manga - The prepared manga object.
   */
  function takeOverPage(manga) {
    const oldRoot = document.documentElement;
    const newRoot = oldRoot.cloneNode(false);
    document.replaceChild(newRoot, oldRoot);
    oldRoot.getAttributeNames().forEach(function (name) {
      newRoot.removeAttribute(name);
    });
    newRoot.setAttribute(CONFIG.takeoverMarkerAttribute, '');
    setElementHtml(newRoot, '<head></head><body></body>');
    window.scrollTo(0, 0);
    setElementHtml(document.head, buildHeadHtml(manga));
    setElementHtml(document.body, buildAppHtml(manga));
    APP_STATE.device = resolveDeviceMode(getSetting('deviceMode'), detectDevice());
    setExclusiveClass(getReaderRoot(), ['mobile', 'tablet', 'desktop'], APP_STATE.device);
    logDebug('Page taken over');
  }

  /**
   * Opens the reader for the prepared manga, optionally for a page range. Before the takeover
   * the site's comments are captured (at most CONFIG.commentsWaitTimeoutMs, section 20).
   * @param {number} [begin] - First page (default: manga.begin).
   * @param {number} [end] - Last page (default: manga.pages).
   * @returns {Promise<void>}
   */
  async function openViewer(begin, end) {
    const manga = APP_STATE.manga;
    if (!manga) {
      logWarn('openViewer: no chapter prepared yet');
      return;
    }
    if (document.documentElement.hasAttribute(CONFIG.takeoverMarkerAttribute) || APP_STATE.opening) {
      logWarn('openViewer: the reader is already open (or opening)');
      return;
    }
    APP_STATE.opening = true;
    try {
      if (typeof begin === 'number' && begin >= 1) {
        manga.begin = clamp(begin, 1, manga.pages);
      }
      if (typeof end === 'number' && end >= manga.begin) {
        manga.pages = clamp(end, manga.begin, manga.pages);
      }
      APP_STATE.comments = await safeRunAsync('startup.captureComments', function () {
        return captureComments(manga);
      }, null);
      log('Opening the reader: pages ' + manga.begin + '-' + manga.pages);
      safeRun('startup.takeOverPage', function () {
        takeOverPage(manga);
        applyAllSettings();
        attachReaderEvents();
        mountComments();
        startImageLoading(manga);
        updateDownloadAvailability();
      }, undefined, { begin: manga.begin, end: manga.pages });
    } finally {
      APP_STATE.opening = false;
    }
  }

  /** Floating "Start" button (load mode 'never', or after cancelling the wait prompt). */
  function showStartButton() {
    if (document.getElementById('StartMOV')) {
      return;
    }
    installStyle('MVMPopupStyle', POPUP_CSS);
    const button = document.createElement('button');
    button.id = 'StartMOV';
    button.type = 'button';
    button.textContent = getLocaleString('BUTTON_START');
    button.addEventListener('click', guardHandler('startButton.click', function () {
      startWithPageRange();
    }));
    (document.body || document.documentElement).appendChild(button);
    log('Start button added to the page');
  }

  /** Asks for a page range, then opens the reader. */
  async function startWithPageRange() {
    const manga = APP_STATE.manga;
    if (!manga) {
      return;
    }
    const range = await showPageRangePrompt(manga.pages, manga.begin);
    if (!range) {
      log('Page range prompt cancelled');
      return;
    }
    const button = document.getElementById('StartMOV');
    if (button) {
      button.remove();
    }
    openViewer(range.begin, range.end);
  }

  /**
   * Applies the load mode: 'always' opens at once, 'wait' asks first (auto-start after 3 s),
   * 'never' shows only the Start button.
   * @param {Object} site - Winning site (its `start` overrides the setting).
   */
  async function applyLoadMode(site) {
    const mode = site.start || getSetting('loadMode');
    log('Load mode: ' + mode);
    if (mode === 'always') {
      openViewer();
      return;
    }
    if (mode === 'never') {
      showStartButton();
      return;
    }
    const start = await showWaitPrompt(APP_STATE.manga);
    if (start) {
      openViewer();
    } else {
      showStartButton();
    }
  }

  /** The pseudo site module used for files opened from the computer (logs, MVMDiagnose). */
  const LOCAL_FILES_SITE = {
    name: 'Local Files (opened from this computer)',
    url: /^$/,
    run: function () {
      return null;
    }
  };

  /**
   * Opens the reader with images chosen on the local page (callback of section 20.5).
   * @param {string} title - Folder, archive or default title.
   * @param {string[]} listImages - Object URLs, in reading order.
   * @returns {boolean} True when the reader opens.
   */
  function openLocalImages(title, listImages) {
    if (document.documentElement.hasAttribute(CONFIG.takeoverMarkerAttribute) || APP_STATE.opening) {
      showToast({ type: 'warning', title: getLocaleString('LOCAL_FILES_ALREADY_OPEN') });
      return false;
    }
    const manga = { title: title, series: '?reload', pages: listImages.length, begin: 1, prev: '', next: '', lazy: false, listImages: listImages };
    const validation = validateMangaResult(manga);
    if (validation.errors.length > 0) {
      logError('localFiles.validate', new Error(validation.errors.join(' | ')), { title: title, pages: listImages.length });
      return false;
    }
    log('Opening ' + listImages.length + ' local images: "' + title + '"');
    APP_STATE.site = LOCAL_FILES_SITE;
    APP_STATE.manga = manga;
    APP_STATE.resumedBookmarkPage = 0;
    hideLocalFilesPanel();
    openViewer();
    return true;
  }

  /** True on the standalone local reader page (Manga_Viewer_MM_Local.html) or a page made for the 2025 local reader. */
  function isLocalReaderPage() {
    return /Manga_Viewer_MM_Local\.html$/i.test(window.location.pathname) || query('#LocalTest') !== null;
  }

  /**
   * Local file reader (decision D4) on a local page: connects the inputs of a page made for the
   * 2025 version, or shows the own panel on the local reader page. Other local HTML files are
   * left alone (the script's @include matches every file:///...html); MVMOpenFiles() opens the
   * panel on any page. The site checks still run afterwards (the 'Local Files' test site).
   */
  function setupLocalFilesReader() {
    const bound = bindLegacyUploadInputs(openLocalImages);
    if (bound === 0 && isLocalReaderPage()) {
      showLocalFilesPanel(openLocalImages);
    }
  }

  /**
   * MAIN ENTRY: find the site, read the chapter, then start according to the load mode.
   * @param {Object[]} sites - All site modules.
   */
  async function start(sites) {
    log('Starting ' + SCRIPT_INFO.name + ' ' + SCRIPT_INFO.version + ' on ' + detectDevice() + ', ' + getBrowserName() + ', with ' + SCRIPT_INFO.manager + (isVerbose() ? ' (verbose logging ON)' : ''));
    if (document.documentElement.hasAttribute(CONFIG.takeoverMarkerAttribute)) {
      log('The reader is already running on this page; nothing to do');
      return;
    }
    if (isStandalone()) {
      safeRun('startup.localFiles', setupLocalFilesReader);
    }
    const matching = findMatchingSites(sites);
    log(matching.length + ' of ' + sites.length + ' site modules match this address:', matching.map(getSiteName).join(', ') || '(none)');
    if (matching.length === 0) {
      return;
    }
    let winner = null;
    try {
      winner = await firstSuccessfulSite(matching);
    } catch (error) {
      log(describeError(error));
      log('Tip: run MVMDiagnose() in the console for a step-by-step report.');
      return;
    }
    log('Using site module: ' + getSiteName(winner.site));
    await preparePage(winner.site, winner.manga);
    exposeToConsole('MVM', function (begin, end) {
      openViewer(begin, end);
    });
    await sleep(CONFIG.startDelayMs);
    await safeRunAsync('startup.applyLoadMode', function () {
      return applyLoadMode(winner.site);
    });
  }

  /**
   * Console helper for site developers: runs every matching site (waits, run, validation)
   * WITHOUT taking over the page, and prints a table. Turns verbose logging on while it runs.
   * @param {Object[]} sites - All site modules.
   * @returns {Promise<Object[]>} One report per matching site.
   */
  async function diagnose(sites) {
    const previousVerbose = isVerbose();
    setVerbose(true);
    const matching = findMatchingSites(sites);
    log('DIAGNOSE: ' + matching.length + ' site modules match ' + window.location.href);
    const reports = [];
    for (let i = 0; i < matching.length; i++) {
      try {
        const result = await runSite(matching[i]);
        reports.push({ site: result.report.site, ok: true, pages: result.report.pages, strategy: result.report.strategy, warnings: result.report.warnings.join(' | '), steps: result.report.steps.join(' > ') });
        console.log(CONFIG.logPrefix + ' DIAGNOSE manga object of ' + result.report.site + ':', result.manga);
      } catch (error) {
        reports.push({ site: getSiteName(matching[i]), ok: false, pages: 0, strategy: '', warnings: '', steps: describeError(error) });
      }
    }
    if (typeof console.table === 'function') {
      console.table(reports);
    } else {
      console.log(reports);
    }
    setVerbose(previousVerbose);
    return reports;
  }

  // ==========================================================================================
  // ==== 22. SITE HELPERS =====================================================================
  // Helpers used only by site modules (section 23): language/category labels, text-content
  // finders (same names as the original find.ts) and the scroll collector.
  // ==========================================================================================

  /** Languages a site can serve. Documentation only (not used for logic). */
  const Language = {
    ENGLISH: 'English',
    SPANISH: 'Spanish',
    PORTUGUESE: 'Portuguese',
    CHINESE: 'Chinese',
    RAW: 'Raw'
  };

  /** Kind of content a site serves. Documentation only (not used for logic). */
  const Category = {
    MANGA: 'manga',
    COMIC: 'comic'
  };

  // ---- Text-content finders (same names as the original find.ts, which site code uses) ----

  /** Ways to compare an element's trimmed text with the wanted text. */
  const TEXT_MATCHERS = {
    eq: function (text, wanted) {
      return text === wanted;
    },
    starts: function (text, wanted) {
      return text.indexOf(wanted) === 0;
    },
    ends: function (text, wanted) {
      return text.length >= wanted.length && text.lastIndexOf(wanted) === text.length - wanted.length;
    }
  };

  /**
   * Finds all elements whose trimmed text matches one of the wanted texts.
   * @param {string} selector - CSS selector of candidate elements.
   * @param {string|string[]} content - Wanted text, or several alternatives.
   * @param {string} matcherKey - 'eq', 'starts' or 'ends'.
   * @returns {Element[]}
   */
  function findElementsByContent(selector, content, matcherKey) {
    const matcher = TEXT_MATCHERS[matcherKey];
    if (!matcher) {
      throw new Error('Invalid matcherKey: ' + matcherKey);
    }
    const wantedList = Array.isArray(content) ? content : [content];
    return queryAll(selector).filter(function (element) {
      const text = readText(element);
      return wantedList.some(function (wanted) {
        return matcher(text, wanted);
      });
    });
  }

  /**
   * Finds the first matching element, then its closest ancestor matching `ancestor` (default 'a').
   * @param {string} selector - CSS selector of candidate elements.
   * @param {string|string[]} content - Wanted text(s).
   * @param {string} matcherKey - 'eq', 'starts' or 'ends'.
   * @param {string} [ancestor='a'] - Selector of the ancestor to return.
   * @returns {Element|null}
   */
  function findClosestByContent(selector, content, matcherKey, ancestor) {
    const found = findElementsByContent(selector, content, matcherKey)[0];
    if (!found) {
      return null;
    }
    return found.closest(ancestor || 'a');
  }

  /** All elements whose text equals `content`. */
  function findByContentEq(selector, content) {
    return findElementsByContent(selector, content, 'eq');
  }

  /** First element whose text starts with `content`, or undefined. */
  function findOneByContentStarts(selector, content) {
    return findElementsByContent(selector, content, 'starts')[0];
  }

  /** Closest ancestor (default 'a') of the first element whose text equals `content`. */
  function findClosestByContentEq(selector, content, ancestor) {
    return findClosestByContent(selector, content, 'eq', ancestor);
  }

  /** Closest ancestor (default 'a') of the first element whose text starts with `content`. */
  function findClosestByContentStarts(selector, content, ancestor) {
    return findClosestByContent(selector, content, 'starts', ancestor);
  }

  /** Closest ancestor (default 'a') of the first element whose text ends with `content`. */
  function findClosestByContentEnds(selector, content, ancestor) {
    return findClosestByContent(selector, content, 'ends', ancestor);
  }

  // ---- Site data capture (debugging aid for writing or fixing site modules) ----
  // When a site module finds raw data (a JSON payload, a page variable, an API answer), it can
  // call recordSiteData(siteName, label, data). The data is kept for this page and, with verbose
  // logging on, a readable report is printed: the SHAPE of the data (keys, types, array lengths),
  // the first items of every array, and every value that looks like a URL or an image file name
  // (with its path, e.g. items[0].url). That report is usually enough to turn a slow "scroll the
  // page" site module into a direct list of image URLs. Console: MVMSiteData() prints all captures
  // again and returns them; MVMSiteData(true) returns them as one JSON text (for copy()).

  /** Every capture of this page: { time, site, label, data }. */
  const SITE_DATA_CAPTURES = [];

  /**
   * Short type name for a value: 'null', 'array(12)', 'object', 'string', 'number'...
   * @param {*} value - Any value.
   * @returns {string}
   */
  function describeValueType(value) {
    if (value === null) {
      return 'null';
    }
    if (Array.isArray(value)) {
      return 'array(' + value.length + ')';
    }
    return typeof value;
  }

  /**
   * Builds an indented outline of the data: keys with their types (and short sample values),
   * array lengths, nested objects up to CONFIG.siteDataMaxDepth. Arrays are described by their
   * FIRST item only.
   * @param {*} value - Data to describe.
   * @param {string} [indent=''] - Current indentation.
   * @param {number} [depth=0] - Current depth.
   * @returns {string[]} Outline lines.
   */
  function describeDataShape(value, indent, depth) {
    const prefix = indent || '';
    const level = depth || 0;
    const lines = [];
    if (level >= CONFIG.siteDataMaxDepth) {
      return lines;
    }
    if (Array.isArray(value)) {
      if (value.length > 0) {
        lines.push(prefix + '[0]: ' + describeValueType(value[0]));
        describeDataShape(value[0], prefix + '  ', level + 1).forEach(function (line) {
          lines.push(line);
        });
      }
      return lines;
    }
    if (value !== null && typeof value === 'object') {
      Object.keys(value).forEach(function (key) {
        const child = value[key];
        let sample = '';
        if (typeof child === 'string') {
          sample = ' = "' + child.slice(0, 80) + (child.length > 80 ? '..."' : '"');
        } else if (typeof child === 'number' || typeof child === 'boolean') {
          sample = ' = ' + child;
        }
        lines.push(prefix + key + ': ' + describeValueType(child) + sample);
        describeDataShape(child, prefix + '  ', level + 1).forEach(function (line) {
          lines.push(line);
        });
      });
    }
    return lines;
  }

  /**
   * True for text that looks like a URL or an image file name.
   * @param {string} text - Candidate.
   * @returns {boolean}
   */
  function looksLikeUrl(text) {
    if (typeof text !== 'string' || text.length < 4) {
      return false;
    }
    return /^(https?:)?\/\//i.test(text) || /^data:image\//i.test(text) || /^blob:/i.test(text) || /\.(jpe?g|png|webp|avif|gif|bmp)(\?|#|$)/i.test(text);
  }

  /**
   * Collects every URL-like string inside the data, with its path (e.g. 'items[3].src').
   * Stops after CONFIG.siteDataMaxUrls results.
   * @param {*} value - Data to search.
   * @returns {{path: string, value: string}[]}
   */
  function findUrlLikeValues(value) {
    const found = [];
    const seen = [];

    /**
     * Visits one value recursively.
     * @param {*} current - Value.
     * @param {string} path - Its path from the root.
     * @param {number} depth - Nesting level.
     */
    function visit(current, path, depth) {
      if (found.length >= CONFIG.siteDataMaxUrls || depth > CONFIG.siteDataMaxDepth + 4) {
        return;
      }
      if (typeof current === 'string') {
        if (looksLikeUrl(current)) {
          found.push({ path: path || '(root)', value: current.slice(0, CONFIG.siteDataMaxStringLength) });
        }
        return;
      }
      if (current === null || typeof current !== 'object' || seen.indexOf(current) !== -1) {
        return;
      }
      seen.push(current);
      if (Array.isArray(current)) {
        current.forEach(function (item, index) {
          visit(item, path + '[' + index + ']', depth + 1);
        });
        return;
      }
      Object.keys(current).forEach(function (key) {
        visit(current[key], path ? path + '.' + key : key, depth + 1);
      });
    }

    safeRun('siteData.findUrls', function () {
      visit(value, '', 0);
    });
    return found;
  }

  /**
   * JSON text of a value, shortened for the console. Never throws.
   * @param {*} value - Data.
   * @param {number} maxLength - Maximum characters.
   * @returns {string}
   */
  function toShortJson(value, maxLength) {
    let text = '';
    try {
      text = JSON.stringify(value);
    } catch (error) {
      text = '(not serializable: ' + describeError(error) + ')';
    }
    if (typeof text !== 'string') {
      text = String(text);
    }
    return text.length > maxLength ? text.slice(0, maxLength) + '... (' + text.length + ' chars)' : text;
  }

  /**
   * Prints the first items of an array (helper of logSiteDataReport).
   * @param {string} name - Label for each line.
   * @param {Array} list - The array.
   */
  function logArraySample(name, list) {
    list.slice(0, CONFIG.siteDataSampleItems).forEach(function (item, index) {
      console.log(name + '[' + index + ']: ' + toShortJson(item, CONFIG.siteDataMaxStringLength * 3));
    });
  }

  /**
   * Prints the readable report of one capture (shape, array samples, URL-like values, raw data).
   * @param {{time: string, site: string, label: string, data: *}} capture - The capture.
   */
  function logSiteDataReport(capture) {
    const header = CONFIG.logPrefix + ' [site data] ' + capture.site + ' - ' + capture.label;
    const grouped = typeof console.groupCollapsed === 'function' && typeof console.groupEnd === 'function';
    if (grouped) {
      console.groupCollapsed(header);
    } else {
      console.log(header);
    }
    console.log('Type: ' + describeValueType(capture.data));
    console.log('Shape:\n' + (describeDataShape(capture.data).join('\n') || '(plain value)'));
    if (Array.isArray(capture.data)) {
      logArraySample('item', capture.data);
    } else if (capture.data !== null && typeof capture.data === 'object') {
      Object.keys(capture.data).forEach(function (key) {
        if (Array.isArray(capture.data[key])) {
          logArraySample(key, capture.data[key]);
        }
      });
    }
    const urls = findUrlLikeValues(capture.data);
    console.log('URL-like values (' + urls.length + (urls.length >= CONFIG.siteDataMaxUrls ? '+' : '') + '):');
    urls.forEach(function (entry) {
      console.log('  ' + entry.path + ' = ' + entry.value);
    });
    console.log('Raw data:', capture.data);
    if (grouped) {
      console.groupEnd();
    }
  }

  /**
   * Keeps a piece of raw site data for debugging and prints its report when verbose logging is
   * on. Safe to call from any site module; never throws.
   * @param {string} siteName - Site module name, e.g. 'Comix.to'.
   * @param {string} label - What the data is, e.g. 'chapter pages (JSON.parse result.pages)'.
   * @param {*} data - The data (kept by reference).
   */
  function recordSiteData(siteName, label, data) {
    safeRun('siteData.record', function () {
      const capture = { time: new Date().toISOString(), site: siteName, label: label, data: data };
      SITE_DATA_CAPTURES.push(capture);
      logDebug('site data captured: ' + siteName + ' - ' + label + ' (' + describeValueType(data) + '). Run MVMSiteData() to print it again.');
      if (isVerbose()) {
        logSiteDataReport(capture);
      }
    }, undefined, { site: siteName, label: label });
  }

  /**
   * Installs a wrapper around JSON.parse that hands every parsed result to `pick`; when pick
   * returns something, it is recorded with recordSiteData and kept as the latest value. Use it
   * when a site parses its chapter data with JSON.parse before the reader can read it. Runs only
   * when the hostname contains `hostIncludes`. Call it at script load (top level of section 23),
   * not inside run(), or the site may already have parsed its data.
   * @param {Object} options
   * @param {string} options.hostIncludes - Part of the hostname, e.g. 'comix.to'.
   * @param {string} options.siteName - For logs.
   * @param {string} options.label - What is captured, for logs.
   * @param {function(*): *} options.pick - Returns the wanted part of a parsed value, or null.
   * @returns {function(): *} Returns the latest captured value (null until something matched).
   */
  function installJsonParseCapture(options) {
    let latest = null;

    /** Getter for the latest captured value. */
    function getLatest() {
      return latest;
    }

    if (window.location.hostname.indexOf(options.hostIncludes) === -1) {
      return getLatest;
    }
    const originalParse = JSON.parse;
    JSON.parse = function (text, reviver) {
      const parsed = originalParse.call(JSON, text, reviver);
      try {
        const picked = options.pick(parsed);
        if (picked !== null && picked !== undefined) {
          latest = picked;
          log(options.siteName + ': captured ' + options.label + ' from JSON.parse');
          recordSiteData(options.siteName, options.label, picked);
        }
      } catch (error) {
        logDebug(options.siteName + ': JSON.parse capture check failed', describeError(error));
      }
      return parsed;
    };
    logDebug(options.siteName + ': JSON.parse capture installed for ' + options.label);
    return getLatest;
  }

  // ---- Scroll collector (for sites that only reveal/descramble images while scrolling) ----

  /**
   * Converts a blob: URL into a data: URL so it survives the page takeover.
   * Returns the original URL on failure (and logs why).
   * @param {string} blobUrl - A blob: URL.
   * @returns {Promise<string>}
   */
  async function blobUrlToDataUrl(blobUrl) {
    try {
      const response = await fetch(blobUrl);
      const blob = await response.blob();
      return await new Promise(function (resolve, reject) {
        const reader = new FileReader();
        reader.onloadend = function () {
          resolve(reader.result);
        };
        reader.onerror = function () {
          reject(reader.error);
        };
        reader.readAsDataURL(blob);
      });
    } catch (error) {
      logError('blobUrlToDataUrl', error, { blobUrl: blobUrl });
      return blobUrl;
    }
  }

  /**
   * Waits until the page has at least `pageIndex + 1` reader images and returns that one.
   * @param {number} pageIndex - 0-based page index.
   * @param {function(HTMLImageElement): boolean} isReaderImage - Filter for the site's page images.
   * @param {number} timeoutMs - Give up after this time.
   * @param {number} pollMs - Check interval.
   * @returns {Promise<HTMLImageElement|null>} The image, or null after the timeout.
   */
  async function waitForReaderImage(pageIndex, isReaderImage, timeoutMs, pollMs) {
    let waited = 0;
    while (waited < timeoutMs) {
      const readerImages = queryAll('img').filter(isReaderImage);
      if (readerImages[pageIndex]) {
        return readerImages[pageIndex];
      }
      await sleep(pollMs);
      waited += pollMs;
    }
    return null;
  }

  /**
   * Waits until an image has a valid, loaded source at least 250 px wide (placeholders are
   * smaller) and returns it.
   * @param {HTMLImageElement} imageElement - The page image.
   * @param {function(string): boolean} isValidSource - Accepts real page URLs.
   * @param {number} timeoutMs - Give up after this time.
   * @param {number} pollMs - Check interval.
   * @returns {Promise<string>} The source, or '' after the timeout.
   */
  async function waitForValidImageSource(imageElement, isValidSource, timeoutMs, pollMs) {
    let waited = 0;
    while (waited < timeoutMs) {
      const source = imageElement.src || readAttribute(imageElement, 'src');
      const width = imageElement.naturalWidth || imageElement.width || 0;
      if (source && isValidSource(source) && (imageElement.naturalWidth > 250 || imageElement.complete) && (width === 0 || width >= 250)) {
        return source;
      }
      await sleep(pollMs);
      waited += pollMs;
    }
    return '';
  }

  /**
   * Full-screen progress overlay used by the collectors (bruteforceScroll, bruteforceClick).
   * @param {string} initialText - First line of text (already localized).
   * @returns {{setText: function(string): void, setProgress: function(number): void, remove: function(): void}}
   */
  function createCollectorOverlay(initialText) {
    const overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(33,37,41,0.95);z-index:999999;display:flex;flex-direction:column;align-items:center;justify-content:center;color:white;font-family:sans-serif;font-size:20px;';
    setElementHtml(overlay, [
      '<div style="font-size:24px;font-weight:bold;margin-bottom:20px;">MangaViewerMM</div>',
      '<div class="mvm-scroll-text">', escapeHtml(initialText), '</div>',
      '<div style="margin-top:20px;width:200px;height:6px;background:#495057;border-radius:3px;overflow:hidden;">',
      '<div class="mvm-scroll-bar" style="width:0;height:100%;background:#37b24d;"></div>',
      '</div>'
    ].join(''));
    document.body.appendChild(overlay);
    return {
      setText: function (text) {
        const line = overlay.querySelector('.mvm-scroll-text');
        if (line) {
          line.textContent = text;
        }
      },
      setProgress: function (fraction) {
        const bar = overlay.querySelector('.mvm-scroll-bar');
        if (bar) {
          bar.style.width = Math.round(clamp(fraction, 0, 1) * 100) + '%';
        }
      },
      remove: function () {
        overlay.remove();
      }
    };
  }

  /**
   * Converts the blob: URLs of a collected list into data: URLs (the site may revoke blob: URLs
   * after the page takeover) and makes the others absolute. Empty entries stay empty, so page
   * order is kept.
   * @param {string[]} urls - Collected URLs.
   * @returns {Promise<string[]>}
   */
  function finalizeCollectedUrls(urls) {
    return Promise.all(urls.map(function (url) {
      if (url === '') {
        return '';
      }
      if (url.indexOf('blob:') === 0) {
        return blobUrlToDataUrl(url);
      }
      return toAbsoluteUrl(url);
    }));
  }

  /**
   * Scrolls the ORIGINAL page image by image, waits until each image has a valid source,
   * and returns the collected URLs (blob: URLs converted to data: URLs). Shows a progress overlay.
   * Cost: the site loads every image once while scrolling. Use only when nothing else works.
   * @param {number} expectedPagesCount - How many pages to collect.
   * @param {function(HTMLImageElement): boolean} isReaderImage - True for a reader page image.
   * @param {function(string): boolean} isValidSource - True when the image source is final.
   * @returns {Promise<string[]>}
   */
  async function bruteforceScroll(expectedPagesCount, isReaderImage, isValidSource) {
    const perImageTimeoutMs = CONFIG.scrollCollectTimeoutMs;
    const pollMs = CONFIG.scrollCollectPollMs;
    const startedAt = Date.now();
    const overlay = createCollectorOverlay(getLocaleString('SCROLL_PREPARING'));

    /**
     * Updates the overlay text (localized, SCROLL_PROGRESS) and bar.
     * @param {number} collected - Pages collected so far.
     */
    function updateOverlay(collected) {
      const total = expectedPagesCount > 0 ? String(expectedPagesCount) : '?';
      overlay.setText(getLocaleString('SCROLL_PROGRESS').replace('##num##', String(collected)).replace('##total##', total));
      overlay.setProgress(expectedPagesCount > 0 ? collected / expectedPagesCount : 0.5);
    }

    updateOverlay(0);
    const originalScrollY = window.scrollY;
    const collectedUrls = [];
    try {
      for (let pageIndex = 0; pageIndex < expectedPagesCount; pageIndex++) {
        const imageElement = await waitForReaderImage(pageIndex, isReaderImage, perImageTimeoutMs, pollMs);
        if (!imageElement) {
          log('bruteforceScroll: no image element found for page ' + (pageIndex + 1));
          continue;
        }
        imageElement.scrollIntoView({ behavior: 'auto', block: 'center' });
        const validSource = await waitForValidImageSource(imageElement, isValidSource, perImageTimeoutMs, pollMs);
        if (validSource) {
          collectedUrls.push(validSource);
        } else {
          log('bruteforceScroll: timeout waiting for page ' + (pageIndex + 1) + ', keeping its current source');
          collectedUrls.push(imageElement.src || readAttribute(imageElement, 'src'));
        }
        updateOverlay(collectedUrls.length);
      }
    } finally {
      window.scrollTo(0, originalScrollY);
      overlay.remove();
    }
    if (collectedUrls.length === 0) {
      throw new Error('bruteforceScroll: no images collected from the page');
    }
    log('bruteforceScroll: collected ' + collectedUrls.length + ' of ' + expectedPagesCount + ' pages in ' + Math.round((Date.now() - startedAt) / 1000) + ' s');
    recordSiteData('bruteforceScroll', 'image sources collected by scrolling (compare with the site data above)', collectedUrls.slice());
    return finalizeCollectedUrls(collectedUrls);
  }

  /**
   * Last-resort collector for readers that show one page at a time behind a "next" button:
   * clicks "next" through every page and reads the image URL on each step. Use only when the
   * image URLs are not available in the page source. Shows a progress overlay.
   * Page order is always kept: a page whose image did not appear in time is returned as ''
   * (the reader shows it as failed and it can be reloaded; the result checker warns about it).
   * Paced by CONFIG.clickCollectDelayMs between clicks - do not make it faster: sites block
   * fast collectors (Comix blocked fast scrolling around page 40).
   * @param {function(): (void|Promise)} resetPosition - Brings the reader back to page 1 (e.g. clicks "previous" until the first page shows). May return a Promise; it is awaited.
   * @param {number} pageCount - Total pages to collect.
   * @param {string} nextSelector - CSS selector of the "next page" button.
   * @param {string} targetSelector - CSS selector of the container holding the current image.
   * @param {string} [imageSelector='img'] - Image element inside the container.
   * @param {string} [imageAttribute='src'] - Attribute holding the URL.
   * @returns {Promise<string[]>} One entry per page ('' for a page that timed out).
   */
  async function bruteforceClick(resetPosition, pageCount, nextSelector, targetSelector, imageSelector, imageAttribute) {
    const imgSelector = imageSelector || 'img';
    const imgAttribute = imageAttribute || 'src';
    const overlay = createCollectorOverlay(getLocaleString('SCROLL_PREPARING'));
    const collected = [];

    /**
     * Finds the image currently shown by the reader.
     * @returns {Element|null}
     */
    function findShownImage() {
      const container = document.querySelector(targetSelector);
      return container ? container.querySelector(imgSelector) : document.querySelector(imgSelector);
    }

    try {
      await Promise.resolve(resetPosition());
      for (let page = 1; page <= pageCount; page++) {
        overlay.setText(getLocaleString('CLICK_PROGRESS').replace('##num##', String(page)).replace('##total##', String(pageCount)));
        overlay.setProgress(pageCount > 0 ? (page - 1) / pageCount : 0);
        let source = '';
        try {
          source = await waitUntil(function () {
            const value = readAttribute(findShownImage(), imgAttribute);
            return value !== '' ? value : null;
          }, 'bruteforceClick page ' + page + ' image', CONFIG.scrollCollectTimeoutMs);
        } catch (error) {
          log('bruteforceClick: timeout on page ' + page + ' (kept as an empty page so the order stays right): ' + error.message);
        }
        collected.push(source);
        // Intentional: clear the shown image's attribute so the NEXT wait only succeeds once the
        // site has put the next page's URL there (otherwise we would read the same page twice).
        const shown = findShownImage();
        if (shown) {
          shown.removeAttribute(imgAttribute);
        }
        if (page < pageCount) {
          const next = document.querySelector(nextSelector);
          if (next) {
            // .click() sends a real, bubbling click; frameworks (React, Vue) listen higher up.
            next.click();
          } else {
            log('bruteforceClick: "next" button not found (' + nextSelector + ') after page ' + page);
          }
          await sleep(CONFIG.clickCollectDelayMs);
        }
      }
    } finally {
      overlay.remove();
    }
    const found = collected.filter(function (url) {
      return url !== '';
    }).length;
    if (found === 0) {
      throw new Error('bruteforceClick: no images collected from the page');
    }
    log('bruteforceClick: collected ' + found + ' of ' + pageCount + ' pages');
    recordSiteData('bruteforceClick', 'image sources collected by clicking next', collected.slice());
    return finalizeCollectedUrls(collected);
  }


  // ---- Placeholder images (used by the "Local Files" test site and by tests) ----

  /**
   * An SVG image as a data: URL, with its size written in the middle. No network needed.
   * @param {number} width - Width in pixels.
   * @param {number} height - Height in pixels.
   * @param {string} [backgroundColor='#0F1C3F'] - Fill color.
   * @param {string} [textColor='#ECEAD9'] - Text color.
   * @returns {string}
   */
  function placeholder(width, height, backgroundColor, textColor) {
    const fill = backgroundColor || '#0F1C3F';
    const ink = textColor || '#ECEAD9';
    const svg = [
      '<svg xmlns="http://www.w3.org/2000/svg" width="', width, '" height="', height, '" viewBox="0 0 ', width, ' ', height, '">',
      '<rect width="100%" height="100%" fill="', fill, '"/>',
      '<text x="50%" y="50%" fill="', ink, '" font-family="sans-serif" font-size="', Math.round(Math.min(width, height) / 10), '" text-anchor="middle" dominant-baseline="middle">',
      width, ' x ', height,
      '</text></svg>'
    ].join('');
    return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg);
  }

  /**
   * A placeholder with a random common page size and a random dark color.
   * @returns {string}
   */
  function randomPlaceholder() {
    const widths = [985, 1970, 800];
    const heights = [1400, 1200, 1600];
    const colors = ['#2D1657', '#152C55', '#7A1420', '#806D15', '#0F5B30', '#1a3e3c', '#480f5b'];
    /**
     * Random item of a list.
     * @param {Array} list - Items.
     * @returns {*}
     */
    const pick = function (list) {
      return list[Math.floor(Math.random() * list.length)];
    };
    return placeholder(pick(widths), pick(heights), pick(colors));
  }

  // ==========================================================================================
  // ==== 23. SITE MODULES ======================================================================
  // One object per supported website. How to write one: docs/03-site-modules-guide.md.
  // To add a site: 1) add its object here, 2) add it to SITES below, 3) add an @include line
  // to the metadata block at the top, 4) add testUrls and run tests/logic-tests.html,
  // 5) open a chapter, run MVMDiagnose() in the console.
  // ==========================================================================================

  // ---- Site modules, batch A (ported from NEW src/main/*.ts, NEW:12061-12838) ----

  /**
   * Turns a list of raw image URLs into absolute URLs and drops empty entries.
   * Shared by the batch A site modules.
   * @param {string[]} list - Raw URLs (may contain '', null or undefined).
   * @returns {string[]}
   */
  function sitesAImageList(list) {
    return (list || []).map(function (url) {
      return toAbsoluteUrl(url);
    }).filter(function (url) {
      return url !== '';
    });
  }

  // Asura Scans: reads the Astro island "props" JSON of the ChapterReader component.
  const siteAsura = {
    name: 'Asura Scans',
    url: /https?:\/\/(www\.)?(asurascans|asuracomics)\.(com|net)\/.+/,
    homepage: 'https://asurascans.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'astro-island[component-url*="ChapterReader"]',
    testUrls: {
      match: ['https://asurascans.com/comics/some-series/chapter/12', 'https://www.asuracomics.net/comics/x-1234/chapter/1'],
      noMatch: ['https://asurascans.com/', 'https://asurascansXcom/comics/a/chapter/1']
    },
    run: function () {
      let props = {};
      try {
        props = JSON.parse(queryAttribute('astro-island[component-url*="ChapterReader"]', 'props') || '{}');
      } catch (error) {
        throw new Error('Asura Scans: parse ChapterReader props failed: ' + error.message);
      }
      /** Astro serializes every value as [type, value]; returns the value part. */
      function astroValue(entry) {
        return entry ? entry[1] : undefined;
      }
      const seriesSlug = astroValue(props.seriesSlug);
      const rawUrls = (astroValue(props.pages) || []).map(function (page) {
        const pageValue = astroValue(page);
        return pageValue ? astroValue(pageValue.url) : undefined;
      }).filter(Boolean);
      const listImages = sitesAImageList(rawUrls);
      return {
        title: astroValue(props.seriesName) + ' - Chapter ' + astroValue(props.chapterName),
        series: '/comics/' + seriesSlug,
        pages: listImages.length,
        prev: readAttribute(findClosestByContentEq('span', 'Prev', 'a'), 'href') || queryAttribute('link[rel="prev"]', 'href') || undefined,
        next: readAttribute(findClosestByContentEq('span', 'Next', 'a'), 'href') || queryAttribute('link[rel="next"]', 'href') || undefined,
        listImages: listImages
      };
    }
  };

  // Batoto: DOM query; v3 "/title" reader (forces ?load=2 = all pages) or v2 "/chapter" reader.
  const siteBatoto = {
    name: 'Batoto',
    url: /https?:\/\/(?:www\.)?(?:fto|jto|hto|dto|mto|wto|bato|battwo|batotwo|comiko|batocomic|readtoto|zbato|xbato|mangatoto)\.(?:to|com|net|org)\/(chapter|title).*/,
    homepage: 'https://rentry.co/batoto',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'div[name="image-item"] img, .page-img',
    testUrls: {
      match: ['https://bato.to/chapter/123456', 'https://www.mangatoto.com/title/12345-some-title/678910-ch_1'],
      noMatch: ['https://bato.to/', 'https://example.com/chapter/1']
    },
    run: function () {
      if (window.location.pathname.indexOf('/title') === 0) {
        if (window.location.search !== '?load=2') {
          window.location.search = '?load=2';
        }
        const titleImages = sitesAImageList(queryAll('div[name="image-item"] img').map(function (img) {
          return readAttribute(img, 'src');
        }));
        return {
          title: queryText('h6'),
          series: queryAttribute('h3 a', 'href'),
          pages: titleImages.length,
          prev: readAttribute(findClosestByContentEnds('span', 'Prev Chapter', 'a'), 'href'),
          next: readAttribute(findClosestByContentStarts('span', 'Next Chapter', 'a'), 'href'),
          listImages: titleImages
        };
      }
      const images = sitesAImageList(queryAll('.page-img').map(function (img) {
        return readAttribute(img, 'src');
      }));
      return {
        title: queryText('.nav-title a'),
        series: queryAttribute('.nav-title a', 'href'),
        pages: images.length,
        prev: queryAttribute('.nav-prev a', 'href'),
        next: queryAttribute('.nav-next a', 'href'),
        listImages: images
      };
    }
  };

  // BilibiliComics: embedded Nuxt JSON (#__NUXT_DATA__), keeps entries that look like image paths.
  const siteBilibilicomics = {
    name: 'BilibiliComics',
    url: /https?:\/\/(www\.)?(bilibilicomics)\.net\/episode\/.+/,
    homepage: 'https://www.bilibilicomics.net/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#__NUXT_DATA__',
    testUrls: {
      match: ['https://www.bilibilicomics.net/episode/12345', 'https://bilibilicomics.net/episode/abc-1'],
      noMatch: ['https://www.bilibilicomics.net/', 'https://bilibilicomicsXnet/episode/1']
    },
    run: async function () {
      let nuxtData = [];
      try {
        const nuxtElement = query('#__NUXT_DATA__');
        nuxtData = JSON.parse(nuxtElement ? nuxtElement.innerHTML : '');
      } catch (error) {
        throw new Error('BilibiliComics: parse #__NUXT_DATA__ failed: ' + error.message);
      }
      const images = (Array.isArray(nuxtData) ? nuxtData : []).filter(function (entry) {
        return typeof entry === 'string' && /.(png|jpg|jpeg|gif|bmp|webp)$/i.exec(entry);
      });
      const buttons = queryAll('.pre-next-btns');
      return {
        title: queryText('.chapterTitle'),
        series: queryAttribute('.book-name', 'href'),
        pages: images.length,
        prev: readAttribute(buttons[0], 'href'),
        next: readAttribute(buttons[2], 'href'),
        listImages: images.map(function (image) {
          return 'https://static.comicfans.io/' + image;
        })
      };
    }
  };

  // Comick: Next.js embedded JSON (#__NEXT_DATA__ pageProps), images on the meo.comick.pictures CDN.
  const siteComick = {
    name: 'Comick',
    url: /https?:\/\/(www\.)?comick\.io\/.+/,
    homepage: 'https://comick.io/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitFunc: function () {
      return /\/([^/]+)-chapter.+$/.test(window.location.pathname);
    },
    waitEle: '#__NEXT_DATA__',
    waitTime: 3000,
    testUrls: {
      match: ['https://comick.io/comic/some-series/AbCd-chapter-12-en', 'https://www.comick.io/comic/x'],
      noMatch: ['https://comick.io/', 'https://comickXio/comic/x']
    },
    run: function () {
      let nextData = null;
      try {
        const nextElement = document.getElementById('__NEXT_DATA__');
        nextData = JSON.parse(nextElement ? nextElement.innerHTML : '');
      } catch (error) {
        throw new Error('Comick: parse #__NEXT_DATA__ failed: ' + error.message);
      }
      // FIX (vs NEW): NEW read data.chapter without a null check in the title fallback (docs/03 5.8, NEW:12155).
      const data = (nextData && nextData.props && nextData.props.pageProps) || {};
      const chapter = data.chapter || {};
      const comic = chapter.md_comics || {};
      const listImages = (chapter.md_images || []).map(function (image) {
        return 'https://meo.comick.pictures/' + (image ? image.b2key : undefined);
      });
      const hasSeoTitle = data.seoTitle !== null && data.seoTitle !== undefined;
      return {
        title: hasSeoTitle ? data.seoTitle : comic.title + ' ' + chapter.chap,
        series: '/comic/' + comic.slug,
        pages: listImages.length,
        prev: data.prev ? data.prev.href : undefined,
        next: data.next ? data.next.href : undefined,
        listImages: listImages
      };
    }
  };

  // ---- Comix.to helpers: React Query cache walk, JSON.parse interception, scroll collection ----

  /**
   * Comix.to: captures the chapter "pages" payload when the site parses it (installed at script
   * load). Returns the latest payload or null. Also recorded for MVMSiteData().
   */
  const getComixInterceptedPages = installJsonParseCapture({
    hostIncludes: 'comix.to',
    siteName: 'Comix.to',
    label: 'chapter pages (JSON.parse result.pages)',
    pick: function (parsed) {
      return parsed && parsed.result && parsed.result.pages ? parsed.result.pages : null;
    }
  });

  /**
   * Walks the React fiber tree under #app-root and returns the first value accepted by 'pickFromClient'
   * for any React Query client found in stateNode.props.client or memoizedProps.client/value.
   * @param {function(Object): *} pickFromClient - Returns a value (found) or null (keep walking).
   * @returns {*}
   */
  function comixWalkQueryClients(pickFromClient) {
    const root = document.querySelector('#app-root');
    if (!root) {
      return null;
    }
    const fiberKey = Object.keys(root).find(function (key) {
      return key.indexOf('__reactContainer') === 0 || key.indexOf('__reactFiber') === 0;
    });
    if (!fiberKey) {
      return null;
    }
    const stack = [root[fiberKey]];
    while (stack.length > 0) {
      const current = stack.pop();
      if (!current) {
        continue;
      }
      if (current.stateNode && current.stateNode.props) {
        const fromState = pickFromClient(current.stateNode.props.client);
        if (fromState) {
          return fromState;
        }
      }
      if (current.memoizedProps) {
        const fromMemo = pickFromClient(current.memoizedProps.client || current.memoizedProps.value);
        if (fromMemo) {
          return fromMemo;
        }
      }
      if (current.child) {
        stack.push(current.child);
      }
      if (current.sibling) {
        stack.push(current.sibling);
      }
    }
    return null;
  }

  /** Returns all cached queries of a React Query client, or [] when it is not a client. */
  function comixClientQueries(client) {
    if (client && typeof client.getQueryCache === 'function') {
      return client.getQueryCache().getAll();
    }
    return [];
  }

  /** Chapter pages ({ items: [...] }) from the React Query cache, or null. */
  function comixFindQueryData() {
    return comixWalkQueryClients(function (client) {
      const queries = comixClientQueries(client);
      for (const queryEntry of queries) {
        const data = queryEntry.state.data;
        if (data && data.result && data.result.pages) {
          return data.result.pages;
        }
      }
      return null;
    });
  }

  /** Chapter list (array of chapters) from the React Query cache, or null. */
  function comixFindChapterListFromCache() {
    return comixWalkQueryClients(function (client) {
      const queries = comixClientQueries(client);
      for (const queryEntry of queries) {
        const data = queryEntry.state.data;
        if (data) {
          if (Array.isArray(data) && data.length > 0 && (data[0].chapterNumber !== undefined || data[0].number !== undefined)) {
            return data;
          }
          if (data.result && Array.isArray(data.result.items) && data.result.items.length > 0) {
            const first = data.result.items[0];
            if (first.chapterNumber !== undefined || first.number !== undefined || first.mangaId !== undefined) {
              return data.result.items;
            }
          }
        }
      }
      return null;
    });
  }

  /** Page count from a page <select>, or 0. */
  function comixGetPageCountFromSelect() {
    const select = query('select[aria-label*="page" i], select[class*="page" i], select[id*="page" i]');
    if (select) {
      return select.querySelectorAll('option').length;
    }
    return 0;
  }

  /** Page count from a "1 / N" or "1 of N" text in the DOM, or 0. */
  function comixFindPageCountFromDom() {
    const elements = queryAll('span, div, button, option');
    for (const element of elements) {
      const text = element.textContent || '';
      const match = /^\s*1\s*(?:\/|of)\s*(\d+)\s*$/i.exec(text.trim());
      if (match) {
        const count = parseInt(match[1], 10);
        if (count > 0 && count < 500) {
          return count;
        }
      }
    }
    return 0;
  }

  /** True when an href is a Comix chapter link. */
  function comixIsChapterHref(href) {
    return Boolean(href) && href.indexOf('/title/') !== -1 && /\/\d+-chapter-/.test(href);
  }

  /** prev/next from chapter links labelled next/prev (text, aria-label or icon class). */
  function comixGetPrevNextLinks() {
    let next = null;
    let prev = null;
    for (const anchor of queryAll('a')) {
      const href = anchor.getAttribute('href');
      if (!comixIsChapterHref(href)) {
        continue;
      }
      const text = (anchor.textContent || '').toLowerCase().trim();
      const ariaLabel = (anchor.getAttribute('aria-label') || '').toLowerCase();
      if (text.indexOf('next') !== -1 || ariaLabel.indexOf('next') !== -1 || anchor.querySelector('[class*="next"]') || anchor.querySelector('[class*="right"]')) {
        next = href;
      }
      if (text.indexOf('prev') !== -1 || text.indexOf('previous') !== -1 || ariaLabel.indexOf('prev') !== -1 || ariaLabel.indexOf('previous') !== -1 || anchor.querySelector('[class*="prev"]') || anchor.querySelector('[class*="left"]')) {
        prev = href;
      }
    }
    return { prev: prev, next: next };
  }

  /** prev/next by sorting every chapter link on the page by chapter number. */
  function comixGetNavigationFromLinks() {
    const chapterLinks = [];
    for (const anchor of queryAll('a')) {
      const href = anchor.getAttribute('href');
      if (!comixIsChapterHref(href)) {
        continue;
      }
      const match = /-chapter-(\d+(\.\d+)?)/.exec(href);
      if (match) {
        chapterLinks.push({ href: href, num: parseFloat(match[1]) });
      }
    }
    if (chapterLinks.length === 0) {
      return { prev: null, next: null };
    }
    const seen = new Set();
    const uniqueChapters = chapterLinks.filter(function (item) {
      const path = item.href.split('#')[0].split('?')[0];
      if (seen.has(path)) {
        return false;
      }
      seen.add(path);
      return true;
    });
    uniqueChapters.sort(function (a, b) {
      return a.num - b.num;
    });
    const currentCleanPath = window.location.pathname.split('#')[0].split('?')[0];
    const index = uniqueChapters.findIndex(function (item) {
      return item.href.indexOf(currentCleanPath) !== -1 || currentCleanPath.indexOf(item.href) !== -1;
    });
    let prev = null;
    let next = null;
    if (index !== -1) {
      if (index > 0) {
        prev = uniqueChapters[index - 1].href;
      }
      if (index < uniqueChapters.length - 1) {
        next = uniqueChapters[index + 1].href;
      }
    }
    return { prev: prev, next: next };
  }

  /** prev/next: labelled links first, then the sorted chapter links. */
  function comixGetPrevNext() {
    const labelled = comixGetPrevNextLinks();
    if (labelled.prev || labelled.next) {
      return labelled;
    }
    return comixGetNavigationFromLinks();
  }

  /** True for a reader page image (inside the app, not an icon/avatar, not narrower than 250 px). */
  function comixIsReaderImage(img) {
    if (!img.closest('#app-root') && !img.closest('.rpage-body')) {
      return false;
    }
    const src = img.src || img.getAttribute('src') || '';
    if (!src) {
      return false;
    }
    if (src.indexOf('avatar') !== -1 || src.indexOf('logo') !== -1 || src.indexOf('icon') !== -1 || src.indexOf('placeholder') !== -1) {
      return false;
    }
    if (src.indexOf('data:image/svg+xml') === 0) {
      return false;
    }
    const width = img.naturalWidth || img.width || 0;
    return !(width > 0 && width < 250);
  }

  /** True when an image source is final (data/blob URL or a Comix/wowpic host, not a placeholder). */
  function comixIsValidSource(src) {
    if (!src) {
      return false;
    }
    if (src.indexOf('placeholder') !== -1 || src.indexOf('data:image/svg+xml') === 0 || src.indexOf('loading') !== -1) {
      return false;
    }
    return src.indexOf('data:') === 0 || src.indexOf('blob:') === 0 || src.indexOf('comix.to') !== -1 || src.indexOf('static.comix.to') !== -1 || src.indexOf('wowpic') !== -1;
  }

  /** First of two values that is not null/undefined (replacement for nullish coalescing). */
  function comixFirstDefined(first, second) {
    return first !== null && first !== undefined ? first : second;
  }

  /**
   * Chapter title and series link: from the #initial-data JSON, else the page title and the
   * first series link.
   * @returns {{title: string, series: string}}
   */
  function comixFindTitleAndSeries() {
    const initialDataElement = document.getElementById('initial-data');
    let initialData = {};
    try {
      initialData = initialDataElement ? JSON.parse(initialDataElement.textContent || '{}') : {};
    } catch (error) {
      throw new Error('Comix.to: parse #initial-data failed: ' + error.message);
    }
    let title = '';
    let series = '';
    if (initialData && initialData.queries) {
      const keys = Object.keys(initialData.queries);
      for (let i = 0; i < keys.length; i++) {
        const queryData = initialData.queries[keys[i]];
        if (queryData && queryData.title && queryData.url) {
          title = queryData.title;
          series = queryData.url;
          break;
        }
      }
    }
    return {
      title: title || getPageTitle(),
      series: series || queryAttribute('a[href^="/title/"]:not([href*="-chapter-"])', 'href')
    };
  }

  /**
   * Previous/next chapter links from the cached chapter list (sorted by chapter number).
   * @param {Object[]|null} chapterList - Chapters from the React Query cache, or null.
   * @returns {{prev: (string|null), next: (string|null)}}
   */
  function comixFindNeighborChapters(chapterList) {
    const result = { prev: null, next: null };
    const slugMatch = /\/title\/([^/]+)/.exec(window.location.pathname);
    const mangaSlug = slugMatch ? slugMatch[1] : '';
    const chapterMatch = /\/(\d+)-chapter-/.exec(window.location.pathname);
    const currentChapterId = chapterMatch ? parseInt(chapterMatch[1], 10) : 0;
    if (!chapterList || !currentChapterId || !mangaSlug) {
      return result;
    }
    /**
     * Chapter number of a list item (number or chapterNumber field).
     * @param {Object} chapterItem - Chapter from the list.
     * @returns {number}
     */
    const chapterNumber = function (chapterItem) {
      return parseFloat(comixFirstDefined(comixFirstDefined(chapterItem.number, chapterItem.chapterNumber), '0'));
    };
    const sortedChapters = chapterList.slice().sort(function (a, b) {
      return chapterNumber(a) - chapterNumber(b);
    });
    const index = sortedChapters.findIndex(function (chapterItem) {
      return parseInt(chapterItem.id, 10) === currentChapterId;
    });
    if (index > 0) {
      const prevChapter = sortedChapters[index - 1];
      result.prev = '/title/' + mangaSlug + '/' + prevChapter.id + '-chapter-' + comixFirstDefined(prevChapter.number, prevChapter.chapterNumber);
    }
    if (index !== -1 && index < sortedChapters.length - 1) {
      const nextChapter = sortedChapters[index + 1];
      result.next = '/title/' + mangaSlug + '/' + nextChapter.id + '-chapter-' + comixFirstDefined(nextChapter.number, nextChapter.chapterNumber);
    }
    return result;
  }

  // Comix.to: React Query cache / intercepted JSON for counts and navigation, images by scrolling (bruteforceScroll).
  const siteComix = {
    name: 'Comix.to',
    homepage: 'https://comix.to/',
    url: /https?:\/\/comix\.to\/(title|comic)\/.+\/.+/,
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://comix.to/title/some-manga/12345-chapter-1', 'https://comix.to/comic/abc/1'],
      noMatch: ['https://comix.to/', 'https://comix.to/title/some-manga']
    },
    run: async function () {
      let elapsed = 0;
      let pagesData = null;
      let chapterList = null;
      while (elapsed < 5000) {
        if (!pagesData) {
          pagesData = comixFindQueryData();
        }
        if (!chapterList) {
          chapterList = comixFindChapterListFromCache();
        }
        if (pagesData && chapterList) {
          break;
        }
        await sleep(100);
        elapsed += 100;
      }
      if (pagesData) {
        recordSiteData('Comix.to', 'chapter pages (React Query cache)', pagesData);
      }
      if (!pagesData && getComixInterceptedPages()) {
        pagesData = getComixInterceptedPages();
      }
      let expectedPagesCount = (pagesData && pagesData.items && pagesData.items.length) || 0;
      if (expectedPagesCount === 0) {
        expectedPagesCount = comixGetPageCountFromSelect();
      }
      if (expectedPagesCount === 0) {
        expectedPagesCount = comixFindPageCountFromDom();
      }
      if (expectedPagesCount === 0) {
        expectedPagesCount = queryAll('#app-root img, .rpage-body img').filter(comixIsReaderImage).length || 40;
      }
      log('Comix.to: identified expected page count: ' + expectedPagesCount);
      const listImages = await bruteforceScroll(expectedPagesCount, comixIsReaderImage, comixIsValidSource);
      const names = comixFindTitleAndSeries();
      let navigation = comixFindNeighborChapters(chapterList);
      if (!navigation.prev && !navigation.next) {
        navigation = comixGetPrevNext();
      }
      return {
        title: names.title,
        series: names.series,
        pages: listImages.length,
        prev: navigation.prev,
        next: navigation.next,
        listImages: listImages
      };
    }
  };

  // DemonicScans: DOM query; waits for images already pointing at the cdn.demoniclibs.com host.
  const siteDemonicscans = {
    name: 'DemonicScans',
    url: /https?:\/\/(www\.)?demonicscans\.org\/(title\/.+\/chapter\/.+|chaptered\.php\?manga=\d+&chapter=.+)/,
    homepage: 'https://demonicscans.org/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'img.imgholder[src*="cdn.demoniclibs.com"]',
    testUrls: {
      match: ['https://demonicscans.org/title/Some-Manga/chapter/12/1', 'https://demonicscans.org/chaptered.php?manga=123&chapter=4'],
      noMatch: ['https://demonicscans.org/', 'https://demonicscans.org/manga/Some-Manga']
    },
    run: async function () {
      const images = sitesAImageList(queryAll('img.imgholder[src*="cdn.demoniclibs.com"]').map(function (img) {
        return readFirstAttribute(img, ['data-src', 'src']);
      }));
      return {
        title: getPageTitle(),
        series: queryAttribute('h1 a', 'href'),
        pages: images.length,
        prev: queryAttribute('.prevchap', 'href'),
        next: queryAttribute('.nextchap', 'href'),
        listImages: images
      };
    }
  };

  // Dynasty-Scans: page JavaScript variable 'pages' (array of { image }).
  const siteDynastyscans = {
    name: 'Dynasty-Scans',
    url: /https?:\/\/(www\.)?dynasty-scans\.com\/chapters\/.+/,
    homepage: 'https://dynasty-scans.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    // FIX (vs NEW): NEW read the 'pages' global with no wait, a TypeError if it appears late (docs/03 5.8, NEW:12560).
    waitVar: 'pages',
    testUrls: {
      match: ['https://dynasty-scans.com/chapters/some_series_ch01', 'https://www.dynasty-scans.com/chapters/x'],
      noMatch: ['https://dynasty-scans.com/', 'https://dynasty-scansXcom/chapters/x']
    },
    run: function () {
      const pageList = pageWindow.pages;
      if (!Array.isArray(pageList)) {
        throw new Error('Dynasty-Scans: page variable "pages" is missing or not an array');
      }
      const images = sitesAImageList(pageList.map(function (item) {
        return item ? item.image : '';
      }));
      return {
        title: queryText('#chapter-title'),
        series: queryAttribute('#chapter-title a', 'href'),
        pages: images.length,
        prev: queryAttribute('#prev_link', 'href'),
        next: queryAttribute('#next_link', 'href'),
        listImages: images
      };
    }
  };

  // Flame Comics: Next.js embedded JSON (#__NEXT_DATA__), image URLs built on the Flame CDN.
  const siteFlamecomics = {
    name: 'Flame Comics',
    url: /https?:\/\/(www\.)?(flamecomics)\.(xyz)\/series\/.+/,
    homepage: 'https://flamecomics.xyz/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://flamecomics.xyz/series/123/abcdef0123', 'https://www.flamecomics.xyz/series/45'],
      noMatch: ['https://flamecomics.xyz/', 'https://flamecomicsXxyz/series/1']
    },
    run: function () {
      const cdn = 'https://cdn.flamecomics.xyz/uploads/images/series';
      let json = null;
      try {
        const nextElement = document.getElementById('__NEXT_DATA__');
        json = JSON.parse(nextElement ? nextElement.innerHTML : '');
      } catch (error) {
        throw new Error('Flame Comics: parse #__NEXT_DATA__ failed: ' + error.message);
      }
      const pageProps = (json && json.props && json.props.pageProps) || {};
      const chapter = pageProps.chapter || {};
      const chapterImages = chapter.images;
      const images = safeKeys(chapterImages).map(function (key) {
        const image = chapterImages[key];
        return cdn + '/' + chapter.series_id + '/' + chapter.token + '/' + (image ? image.name : undefined) + '?' + chapter.unix_timestamp;
      });
      return {
        title: chapter.title + ' ' + chapter.chapter,
        series: '../' + chapter.series_id,
        pages: images.length,
        prev: pageProps.previous,
        next: pageProps.next,
        listImages: images
      };
    }
  };

  // FoOlSlide: DOM query; listImages when the page shows every image, otherwise one HTML page per image.
  const siteFoolslide = {
    name: ['FoOlSlide', 'Kireicake'],
    url: /^(?!.*jaiminisbox).*\/read\/.+/,
    homepage: ['https://github.com/saintly2k/FoOlSlideX', 'https://reader.kireicake.com'],
    language: [Language.ENGLISH],
    obs: 'Any Site that uses FoOLSlide',
    category: Category.MANGA,
    waitEle: 'img.open',
    testUrls: {
      match: ['https://reader.kireicake.com/read/some_series/en/0/12/page/1', 'https://example.org/foolslide/read/abc/en/1/2/'],
      noMatch: ['https://reader.kireicake.com/', 'https://jaiminisbox.com/reader/read/abc/en/0/1/']
    },
    run: function () {
      const chapter = queryAll('.topbar_left .dropdown_parent:last-of-type li');
      const origin = chapter.findIndex(function (item) {
        const url = queryAttribute('a', 'href', item);
        if (url) {
          return window.location.href.indexOf(url) === 0;
        }
        return false;
      });
      // FIX (vs NEW): NEW used Array.at with origin +/- 1, which wraps for negative indexes (docs/03 5.6 and 5.8, NEW:12611-12615).
      // Here a missing neighbor (newest chapter, oldest chapter, or chapter not found) gives no link instead of a wrong one.
      /** Chapter list item at 'index', or null when out of range or the current chapter is unknown. */
      function chapterAt(index) {
        if (origin < 0 || index < 0 || index >= chapter.length) {
          return null;
        }
        return chapter[index];
      }
      const pages = queryAll('.topbar_right .dropdown li');
      const images = queryAll('.inner img:not(.open)');
      const num = images.length > 1 ? images.length : pages.length;
      const currentItem = chapterAt(origin);
      const currentLink = currentItem ? query('a', currentItem) : null;
      const prevItem = chapterAt(origin + 1);
      const nextItem = chapterAt(origin - 1);
      const manga = {
        title: currentLink ? readText(currentLink) : getPageTitle(),
        series: queryAttribute('div.tbtitle div.text a', 'href'),
        pages: num,
        prev: prevItem ? queryAttribute('a', 'href', prevItem) : undefined,
        next: nextItem ? queryAttribute('a', 'href', nextItem) : undefined
      };
      if (images.length > 1) {
        const listImages = sitesAImageList(images.map(function (img) {
          return readAttribute(img, 'src');
        }));
        return Object.assign({}, manga, { pages: listImages.length, listImages: listImages });
      }
      const baseUrl = window.location.href.replace(/\/\d+$/, '');
      return Object.assign({}, manga, {
        listPages: Array(num).fill(0).map(function (unused, index) {
          return baseUrl + '/' + (index + 1);
        }),
        img: 'img.open'
      });
    }
  };

  // Ikigai Mangas: parses the Qwik state script (script[type="qwik/json"]) for .webp URLs.
  const siteIkigai = {
    name: ['Ikigai Mangas - EltaNews', 'Ikigai Mangas - Ajaco'],
    url: /https?:\/\/(visorikigai|visualikigai)\.(ajaco|eltanews|foodib|jobswu)\.(com|net|site)\/capitulo\/\d+/,
    homepage: ['https://visorikigai.eltanews.com/', 'https://visorikigai.ajaco.net/'],
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://visorikigai.eltanews.com/capitulo/123456789', 'https://visualikigai.ajaco.net/capitulo/42'],
      noMatch: ['https://visorikigai.eltanews.com/', 'https://visorikigaiXeltanews.com/capitulo/1']
    },
    run: function () {
      const scriptText = queryText('script[type="qwik/json"]');
      const images = scriptText.match(/http[^'"]+webp/gi) || [];
      return {
        title: getPageTitle().replace(' — Manga en línea | MangaOni', ''),
        pages: images.length,
        // UNCLEAR (kept from NEW and OLD): prev comes from "Siguiente" and next from "Anterior" (docs/03 5.5).
        prev: readAttribute(findClosestByContentEq('span', 'Siguiente'), 'href'),
        next: readAttribute(findClosestByContentEq('span', 'Anterior'), 'href'),
        listImages: images
      };
    }
  };

  /**
   * Kagane: scrolls '.reader-content' 2000 px at a time (up to 200 times, 300 ms apart) and collects
   * the 'src' of every '.reader-page img' until 'expectedCount' distinct sources are found.
   * @param {number} expectedCount - Pages expected (from the API).
   * @returns {Promise<string[]>}
   */
  async function kaganeCollectAllSources(expectedCount) {
    const container = query('.reader-content');
    const sources = new Set();
    const scrollIncrement = 2000;
    const maxScrollAttempts = 200;
    if (!container) {
      logError('Kagane: collectAllSources', new Error('.reader-content container not found'), { url: window.location.href });
      return [];
    }
    let attempts = 0;
    while (sources.size < expectedCount && attempts < maxScrollAttempts) {
      container.scrollTop += scrollIncrement;
      attempts++;
      await sleep(300);
      for (const img of queryAll('.reader-page img')) {
        const src = img.getAttribute('src');
        if (src && src.length > 0) {
          sources.add(src);
        }
      }
      logDebug('Kagane: scroll attempt ' + attempts + ': collected ' + sources.size + ' of ' + expectedCount + ' sources');
      if (container.scrollHeight - container.clientHeight <= container.scrollTop && sources.size < expectedCount) {
        log('Kagane: reached end of scrollable content, stopping with ' + sources.size + ' of ' + expectedCount + ' sources');
        break;
      }
    }
    return Array.from(sources);
  }

  // Kagane: JSON API for title/navigation, images collected by scrolling the site's reader container.
  const siteKagane = {
    name: 'Kagane',
    homepage: 'https://kagane.org/',
    url: /https:\/\/(www\.)?kagane\.org\/series\/.+\/reader\/.+/,
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '.reader-page img',
    testUrls: {
      match: ['https://kagane.org/series/abc123/reader/def456', 'https://www.kagane.org/series/x/reader/y'],
      noMatch: ['https://kagane.org/', 'http://kagane.org/series/x/reader/y']
    },
    run: async function () {
      const url = window.location.href;
      const seriesMatch = url.match(/series\/([^/]+)/);
      const chapterMatch = url.match(/reader\/([^/]+)/);
      const seriesId = seriesMatch ? seriesMatch[1] : undefined;
      const chapterId = chapterMatch ? chapterMatch[1] : undefined;
      const rschDid = localStorage.getItem('rsch_did');
      const headers = {};
      if (rschDid) {
        headers['X-Rsch-Did'] = rschDid;
      }
      const seriesData = await fetchJson('https://api.kagane.org/api/v1/series/' + seriesId, { headers: headers });
      const booksData = await fetchJson('https://api.kagane.org/api/v1/books/' + seriesId, { headers: headers });
      if (!booksData || !booksData.data || !Array.isArray(booksData.data.content)) {
        throw new Error('Kagane: books API returned no data.content list for series ' + seriesId);
      }
      const content = booksData.data.content;
      const chapterIndex = content.findIndex(function (item) {
        return item.id === chapterId;
      });
      const currentChapter = chapterIndex !== -1 ? content[chapterIndex] : undefined;
      const prevChapter = content[chapterIndex + 1];
      const nextChapter = chapterIndex > 0 ? content[chapterIndex - 1] : undefined;
      const expectedCount = (currentChapter && currentChapter.media && currentChapter.media.pagesCount) || 0;
      const seriesName = seriesData && seriesData.data ? seriesData.data.name : undefined;
      const chapterTitle = currentChapter && currentChapter.metadata ? currentChapter.metadata.title : undefined;
      // FIX (vs NEW): NEW returned pages from the API while the list came from scrolling and could stop early (docs/03 5.8 and 5.9, NEW:12704, 12707).
      const listImages = sitesAImageList(await kaganeCollectAllSources(expectedCount));
      return {
        title: seriesName + ' - ' + chapterTitle,
        series: '/series/' + seriesId,
        pages: listImages.length,
        // UNCLEAR (kept from NEW): links use /books/ while the url regex expects /reader/ (docs/03 5.8).
        prev: prevChapter ? '/series/' + seriesId + '/books/' + prevChapter.id : undefined,
        next: nextChapter ? '/series/' + seriesId + '/books/' + nextChapter.id : undefined,
        listImages: listImages
      };
    }
  };

  // KuManga: page JavaScript variable 'pUrl' (array of { imgURL }), chapter select for navigation.
  const siteKumanga = {
    name: 'KuManga',
    url: /https?:\/\/(www\.)?kumanga\.com\/manga\/leer\/.+/,
    homepage: 'https://www.kumanga.com/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    // FIX (vs NEW): NEW read the 'pUrl' global with no wait, a TypeError if it appears late (docs/03 5.8, NEW:12724).
    waitVar: 'pUrl',
    testUrls: {
      match: ['https://www.kumanga.com/manga/leer/123456', 'https://kumanga.com/manga/leer/abc'],
      noMatch: ['https://www.kumanga.com/', 'https://www.kumangaXcom/manga/leer/1']
    },
    run: function () {
      const pageList = pageWindow.pUrl;
      if (!Array.isArray(pageList)) {
        throw new Error('KuManga: page variable "pUrl" is missing or not an array');
      }
      const chapterSelect = queryAll('select')[1];
      const chapter = chapterSelect ? query('option[selected]', chapterSelect) : null;
      // FIX (vs NEW): NEW built '/manga/leer/undefined' when a neighbor option is missing (docs/03 5.6 and 5.8, NEW:12725-12726).
      const prevValue = chapter ? readAttribute(chapter.previousElementSibling, 'value') : '';
      const nextValue = chapter ? readAttribute(chapter.nextElementSibling, 'value') : '';
      const images = sitesAImageList(pageList.map(function (item) {
        return item ? item.imgURL : '';
      }));
      return {
        title: getPageTitle(),
        series: queryAttribute('h2 a', 'href'),
        pages: images.length,
        prev: prevValue ? '/manga/leer/' + prevValue : '',
        next: nextValue ? '/manga/leer/' + nextValue : '',
        listImages: images
      };
    }
  };

  // LeerCapitulo: DOM query; image URLs are the values of the page <select> options.
  const siteLeercapitulo = {
    name: 'LeerCapitulo',
    url: /https?:\/\/(www\.)?leercapitulo\.co\/leer\/.+/,
    homepage: 'https://www.leercapitulo.co/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    waitEle: '#page_select',
    testUrls: {
      match: ['https://www.leercapitulo.co/leer/abc123/some-manga/12/', 'https://leercapitulo.co/leer/x'],
      noMatch: ['https://www.leercapitulo.co/', 'https://www.leercapituloXco/leer/x']
    },
    run: function () {
      const images = sitesAImageList(queryAll('#page_select option').map(function (option) {
        return readAttribute(option, 'value');
      }));
      return {
        title: queryText('h1'),
        series: queryAttribute('.chapter-title a', 'href'),
        pages: images.length,
        prev: queryAttribute('.pre', 'href'),
        next: queryAttribute('.next', 'href'),
        listImages: images
      };
    }
  };

  // LectorXD: DOM query of page images, falling back to image URLs inside the Astro island props.
  const siteLectorxd = {
    name: 'LectorXD',
    url: /https?:\/\/(www\.)?lectorxd\.com\/.+\/leer\/.+/,
    homepage: 'https://lectorxd.com/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    waitEle: '.page-container img, img.page-image, .page-container, astro-island',
    testUrls: {
      match: ['https://lectorxd.com/manga/some-title/leer/12', 'https://www.lectorxd.com/x/leer/y'],
      noMatch: ['https://lectorxd.com/', 'https://lectorxd.com/manga/some-title']
    },
    run: function () {
      let images = sitesAImageList(queryAll('.page-container img, img.page-image, .page-image img, div[class*="page-container"] img').map(function (img) {
        return readFirstAttribute(img, ['data-src', 'src']);
      }));
      if (images.length === 0) {
        const props = queryAttribute('astro-island', 'props');
        if (props) {
          const matches = props.match(/https?:\/\/[^"'\s\\]+\.(jpg|jpeg|png|webp|avif)[^"'\s\\]*/gi);
          if (matches) {
            images = matches;
          }
        }
      }
      const documentTitle = (document.title || '').replace(/\s*[-|]\s*Lector\s*XD.*$/i, '').trim();
      return {
        title: queryText('h1') || documentTitle || undefined,
        series: queryAttribute('a[href*="/manga/"], a[href*="/manhwa/"], a[href*="/manhua/"], a[href*="/comic/"], a[href*="/series/"]', 'href') || window.location.pathname.replace(/\/leer\/.*/, ''),
        pages: images.length,
        prev: queryAttribute('a[title*="Anterior" i], a[aria-label*="Anterior" i], a[name*="anterior" i]', 'href') || readAttribute(findClosestByContentStarts('a, span, button', 'Anterior'), 'href') || queryAttribute('link[rel="prev"]', 'href') || undefined,
        next: queryAttribute('a[title*="Siguiente" i], a[aria-label*="Siguiente" i], a[name*="siguiente" i]', 'href') || readAttribute(findClosestByContentStarts('a, span, button', 'Siguiente'), 'href') || queryAttribute('link[rel="next"]', 'href') || undefined,
        listImages: images
      };
    }
  };

  // LHTranslation: DOM query; chapter <select> is newest first, so prev is the NEXT option.
  const siteLhtranslation = {
    name: 'LHTranslation',
    url: /https?:\/\/(www\.)?lhtranslation\.net\/read.+/,
    homepage: 'https://lhtranslation.net/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://lhtranslation.net/read-some-manga-chapter-12.html', 'https://www.lhtranslation.net/read/x'],
      noMatch: ['https://lhtranslation.net/', 'https://lhtranslationXnet/read-x']
    },
    run: function () {
      const chapter = query('.form-control option:checked');
      const images = sitesAImageList(queryAll('img.chapter-img').map(function (img) {
        return readAttribute(img, 'src');
      }));
      return {
        title: queryText('.chapter-img.tieude font'),
        series: queryAttribute('.navbar-brand.manga-name', 'href'),
        pages: images.length,
        prev: chapter ? readAttribute(chapter.nextElementSibling, 'value') : '',
        next: chapter ? readAttribute(chapter.previousElementSibling, 'value') : '',
        listImages: images
      };
    }
  };

  // Local Files: test page; generated SVG placeholders, declines unless the page is the script's own test page.
  const siteLocalhost = {
    name: 'Local Files',
    url: /(file:\/\/\/.+(index)?\.html)/,
    homepage: '/index.html?raw=1',
    language: [Language.RAW],
    category: Category.MANGA,
    testUrls: {
      match: ['file:///C:/test/index.html', 'file:///home/user/viewer/test.html?pages=10'],
      noMatch: ['https://example.com/index.html', 'file:///C:/test/indexXhtml']
    },
    run: function (count) {
      if (typeof placeholder !== 'function' || typeof randomPlaceholder !== 'function') {
        throw new Error('Local Files: placeholder() / randomPlaceholder() are not provided by the core');
      }
      const searchNumber = /\d+/.exec(window.location.search);
      const num = count !== null && count !== undefined ? count : parseInt(searchNumber ? searchNumber.toString() : '5', 10);
      return {
        title: 'Placeholder Manga Loaded',
        series: '?reload',
        // Deliberately not the list length (kept from NEW): declines (0) unless this is the script's own test page.
        pages: document.title === 'MangaViewerMM Script' ? num : 0,
        begin: 1,
        prev: '?pages=50',
        next: '?pages=1',
        listImages: [
          placeholder(1970, 1400, '#2D1657'),
          placeholder(985, 1400, '#152C55'),
          placeholder(985, 1400, '#7A1420'),
          placeholder(1970, 1400, '#806D15'),
          placeholder(985, 1400, '#0F5B30'),
          placeholder(1970, 1400, '#1a3e3c'),
          placeholder(985, 1400, '#480f5b'),
          placeholder(985, 1400, '#a9bf7a'),
          placeholder(985, 1400, '#147a56'),
          placeholder(1970, 1400, '#190343'),
          placeholder(985, 1400, '#d5b91e'),
          placeholder(985, 1400, '#836ecd'),
          placeholder(985, 1400, '#bf19b2'),
          placeholder(985, 1400, '#152055'),
          ...Array(num).fill(0).map(function () {
            return randomPlaceholder();
          })
        ]
      };
    }
  };


  // ---- Site modules, batch B (ported from NEW:12839-13333) ----

  /**
   * Batch B local helper: maps a list of raw image URLs to absolute URLs and drops empty ones.
   * @param {string[]} list - Raw URLs (may contain '' or relative paths).
   * @returns {string[]}
   */
  function toAbsoluteImageListB(list) {
    return list.map(function (url) {
      return toAbsoluteUrl(url);
    }).filter(function (url) {
      return url !== '';
    });
  }

  // M440: DOM query of lazy images (data-src); chapter list is newest first.
  const siteM440 = {
    name: 'M440',
    url: /https?:\/\/(www\.)?m440\.in\/manga\/.+\/.+\/\d+/,
    homepage: 'https://m440.in/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://m440.in/manga/some-title/chapter-slug/12', 'https://www.m440.in/manga/a/b/3'],
      noMatch: ['https://m440.in/', 'https://m440xin/manga/a/b/3']
    },
    run: function () {
      const images = queryAll('#all img');
      const chapter = query('#chapter-list li.active');
      const prevItem = chapter ? chapter.nextElementSibling : null;
      const nextItem = chapter ? chapter.previousElementSibling : null;
      const listImages = toAbsoluteImageListB(images.map(function (img) {
        return readAttribute(img, 'data-src');
      }));
      return {
        title: queryText('title'),
        series: queryAttribute('#navbar-collapse-1 ul:nth-child(2) a', 'href'),
        pages: listImages.length,
        prev: prevItem ? readAttribute(prevItem.firstElementChild, 'href') : '',
        next: nextItem ? readAttribute(nextItem.firstElementChild, 'href') : '',
        listImages: listImages
      };
    }
  };

  /** Madara: an image URL, possibly preceded by tabs/newlines and possibly without protocol. */
  const madaraImageRegex = /^([\t\n])*(https?:\/\/)?.+\.(jpg|jpeg|png|gif|bmp|webp).*$/;

  /**
   * Madara: reads every reader image, choosing the first attribute whose NAME contains src/url,
   * whose value is not a placeholder, and that looks like an image URL. Same logic as NEW:12862-12868.
   * @returns {string[]} One entry per image element ('' when nothing usable was found).
   */
  function findMadaraImages() {
    const images = queryAll('.wp-manga-chapter-img, .blocks-gallery-item img, .reading-content img, #chapter-images img, #chapterContent img');
    return images.map(function (img) {
      const attributes = Array.prototype.slice.call(img.attributes).filter(function (attribute) {
        return /.*(src|url).*/i.test(attribute.name) && !/^.*(blank|lazy|loading).*$/.test(attribute.value);
      });
      if (attributes.length === 0) {
        return '';
      }
      const found = attributes.find(function (attribute) {
        return madaraImageRegex.test(attribute.value);
      });
      if (found) {
        return found.value;
      }
      return readAttribute(img, 'src');
    });
  }

  // Madara WordPress plugin (generic): DOM query over several reader layouts, waits for real image URLs.
  const siteMadarawp = {
    name: [
      'Madara WordPress Plugin',
      'MangaHaus',
      'Isekai Scan',
      'Comic Kiba',
      'Zinmanga',
      'mangatx',
      'Toonily',
      'Mngazuki',
      'JaiminisBox',
      'DisasterScans',
      'ManhuaPlus',
      'TopManhua',
      'NovelMic',
      'Reset-Scans',
      'LeviatanScans',
      'Dragon Tea',
      'SetsuScans',
      'ToonGod',
      'Hades Scans'
    ],
    url: /https?:\/\/.+\/(manga|series|manhua|comic|ch|novel|webtoon|tmo)\/.+\/.+/,
    homepage: [
      'https://mangabooth.com/',
      'https://manhuaus.com',
      'https://isekaiscan.com/',
      'https://comickiba.com/',
      'https://zinmanga.com/',
      'https://mangatx.com/',
      'https://toonily.net/',
      'https://mangazuki.me/',
      'https://jaiminisbox.net',
      'https://disasterscans.com/',
      'https://manhuaplus.org/',
      'https://www.topmanhua.com/',
      'https://novelmic.com/',
      'https://reset-scans.com/',
      'https://leviatanscans.com/',
      'https://dragontea.ink/',
      'https://setsuscans.com/',
      'https://toongod.org/home/',
      'https://lectorhades.latamtoon.com'
    ],
    language: [Language.ENGLISH],
    obs: 'Any Site that uses Madara WordPress Plugin',
    category: Category.MANGA,
    testUrls: {
      match: ['https://toonily.net/manga/some-title/chapter-1/', 'https://example.com/series/title/chapter-2'],
      noMatch: ['https://toonily.net/', 'https://example.com/manga/title']
    },
    waitFunc: function () {
      const images = findMadaraImages();
      return images.length > 0 && images.every(function (source) {
        return source !== '' && madaraImageRegex.test(source);
      });
    },
    run: function () {
      const listImages = toAbsoluteImageListB(findMadaraImages());
      const seriesLink = query('.breadcrumb li:nth-child(3) a') || query('.breadcrumb li:nth-child(2) a');
      return {
        title: queryText('#chapter-heading'),
        series: readAttribute(seriesLink, 'href'),
        pages: listImages.length,
        prev: queryAttribute('.prev_page', 'href'),
        next: queryAttribute('.next_page', 'href'),
        listImages: listImages
      };
    }
  };

  /**
   * MangaBall: first capture group of a regex over a text, or undefined (same as NEW's optional [1]).
   * @param {string} text - Text to search.
   * @param {RegExp} regex - Regex with one capture group.
   * @returns {string|undefined}
   */
  function matchFirstGroupB(text, regex) {
    const match = text.match(regex);
    return match ? match[1] : undefined;
  }

  // MangaBall: inline script text parsed by regex, plus a POST API (CSRF token) for the chapter list.
  const siteMangaball = {
    name: 'MangaBall',
    homepage: 'https://mangaball.net/',
    url: /https?:\/\/mangaball\.net\/chapter-detail\/.+/,
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://mangaball.net/chapter-detail/abc123', 'http://mangaball.net/chapter-detail/x/y'],
      noMatch: ['https://mangaball.net/', 'https://mangaball.net/title-detail/abc123']
    },
    run: async function () {
      const scriptElement = queryAll('script').find(function (element) {
        return element.textContent !== null && element.textContent.indexOf('chapterImages') !== -1;
      });
      const script = scriptElement ? scriptElement.textContent : '';
      if (!script) {
        // Declines on purpose (pages: 0) when the page has no chapter data, as NEW does.
        return {
          title: queryText('h1'),
          series: queryAttribute('a[href*="/title-detail/"]', 'href') || queryAttribute('a[href*="/manga-detail/"]', 'href'),
          pages: 0,
          listImages: []
        };
      }
      const titleId = matchFirstGroupB(script, /titleId\s*=\s*[\x60'"](.+?)[\x60'"]/);
      const chapterNumber = matchFirstGroupB(script, /chapterNumber\s*=\s*[\x60'"](.+?)[\x60'"]/);
      const chapterVolume = matchFirstGroupB(script, /chapterVolume\s*=\s*[\x60'"](.+?)[\x60'"]/);
      const chapterLanguage = matchFirstGroupB(script, /chapterLanguage\s*=\s*[\x60'"](.+?)[\x60'"]/);
      const imagesJson = matchFirstGroupB(script, /chapterImages\s*=\s*JSON\.parse\(\s*[\x60'"](.+?)[\x60'"]\s*\)/) || matchFirstGroupB(script, /chapterImages\s*=.*(\[.*?\])/) || '[]';
      let images = [];
      try {
        images = JSON.parse(imagesJson);
      } catch (error) {
        throw new Error('MangaBall: chapterImages JSON parse failed: ' + error.message);
      }
      const csrfToken = queryAttribute('meta[name="csrf-token"]', 'content');
      const listing = await fetchJson('/api/v1/chapter/chapter-listing-by-title-id/', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded',
          'X-CSRF-TOKEN': csrfToken
        },
        body: 'title_id=' + titleId + '&lang=' + chapterLanguage
      });
      const allChapters = ((listing && listing.ALL_CHAPTERS) || []).sort(function (a, b) {
        return b.number_float - a.number_float;
      });

      /** Walks the descending chapter list to the neighbor with the same language and volume. */
      function findChapter(direction) {
        const currentNumberFloat = parseFloat(chapterNumber || '0');
        const currentIndex = allChapters.findIndex(function (chapter) {
          return chapter.number_float === currentNumberFloat;
        });
        if (currentIndex === -1) {
          return '';
        }
        const step = direction === 'next' ? -1 : 1;
        const volumeText = String(chapterVolume);
        for (let i = currentIndex + step; i >= 0 && i < allChapters.length; i += step) {
          const translations = allChapters[i].translations || [];
          const translation = translations.find(function (item) {
            return item.language === chapterLanguage && String(item.volume) === volumeText;
          });
          if (translation) {
            return translation.url;
          }
        }
        return '';
      }

      const listImages = toAbsoluteImageListB(images.filter(function (url) {
        return typeof url === 'string';
      }));
      return {
        title: queryText('h1'),
        series: queryAttribute('a[href*="' + titleId + '"]', 'href') || queryAttribute('a[href*="/title-detail/"]', 'href') || queryAttribute('a[href*="/manga-detail/"]', 'href'),
        pages: listImages.length,
        prev: findChapter('prev'),
        next: findChapter('next'),
        listImages: listImages
      };
    }
  };

  // MangaBuddy: page global chapImages (comma-separated), host rewritten to the CDN.
  const siteMangabuddy = {
    name: 'MangaBuddy',
    url: /https?:\/\/(www\.)?mangabuddy\.com\/.+\/chapter.+/,
    homepage: 'https://mangabuddy.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'chapImages',
    testUrls: {
      match: ['https://mangabuddy.com/some-title/chapter-1', 'https://www.mangabuddy.com/a/chapter-10-5'],
      noMatch: ['https://mangabuddy.com/', 'https://mangabuddyXcom/a/chapter-1']
    },
    run: function () {
      const rawList = String(pageWindow.chapImages).split(',');
      const listImages = toAbsoluteImageListB(rawList.map(function (entry) {
        // FIX (vs NEW): NEW called new URL(s) directly, which throws on an empty or relative entry
        // (docs/03 5.8 MangaBuddy). Resolve against the page first and skip empties.
        const absolute = toAbsoluteUrl(entry);
        if (absolute === '') {
          return '';
        }
        return new URL(absolute).pathname.replace('/res/', 'https://sb.mbcdn.xyz/');
      }));
      return {
        title: queryText('.chapter-info'),
        series: queryAttribute('#breadcrumbs-container div:nth-child(2) a', 'href'),
        pages: listImages.length,
        prev: queryAttribute('a.prev', 'href'),
        next: queryAttribute('a.next', 'href'),
        listImages: listImages
      };
    }
  };

  // MangaDex: SPA gated by waitEle; images from the public at-home JSON API.
  const siteMangadex = {
    name: 'MangaDex',
    url: /https?:\/\/(www\.)?mangadex\.org/,
    homepage: 'https://mangadex.org/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#chapter-selector a',
    testUrls: {
      match: ['https://mangadex.org/chapter/0a1b2c3d-1111-2222-3333-444455556666/1', 'https://www.mangadex.org/chapter/abc'],
      noMatch: ['https://example.com/', 'https://mangadexxorg.com/chapter/abc']
    },
    run: async function () {
      const idMatch = /\/chapter\/([^/]+)(\/\d+)?/.exec(window.location.pathname);
      if (!idMatch) {
        throw new Error('MangaDex: chapter id not found in path ' + window.location.pathname);
      }
      const server = await fetchJson('https://api.mangadex.org/at-home/server/' + idMatch[1]);
      let listImages = [];
      try {
        listImages = server.chapter.data.map(function (image) {
          return server.baseUrl + '/data/' + server.chapter.hash + '/' + image;
        });
      } catch (error) {
        throw new Error('MangaDex: unexpected at-home API shape: ' + error.message);
      }
      const chapters = queryAll('#chapter-selector a');
      return {
        title: getPageTitle().replace(' - MangaDex', ''),
        series: queryAttribute('a.text-primary[href^=\'/title/\']', 'href'),
        pages: listImages.length,
        prev: readAttribute(chapters[0] || null, 'href'),
        next: readAttribute(chapters[1] || null, 'href'),
        listImages: listImages
      };
    }
  };

  // MangaFox / MangaHere: page globals + per-page API returning packed JavaScript, evaluated to get d.
  const siteMangafox = {
    name: ['MangaFox', 'MangaHere'],
    url: /https?:\/\/(www\.)?(fanfox\.net|mangahere\.cc)\/manga\/.+\/.+\//,
    homepage: ['https://fanfox.net/', 'https://www.mangahere.cc/'],
    language: [Language.ENGLISH],
    category: Category.MANGA,
    // Kept as NEW. The rewrite's isNothing treats numbers as present, so a numeric chapterid passes.
    waitVar: 'chapterid',
    testUrls: {
      match: ['https://fanfox.net/manga/some_title/c001/1.html', 'https://www.mangahere.cc/manga/some_title/v01/c002/'],
      noMatch: ['https://fanfox.net/', 'https://fanfoxXnet/manga/a/b/']
    },
    run: async function () {
      const key = queryAttribute('#dm5_key', 'value');
      const options = {
        method: 'GET',
        headers: { 'Content-Type': 'text/plain' }
      };
      const imageCount = Number(pageWindow.imagecount);
      if (!(imageCount > 0)) {
        throw new Error('MangaFox: page global imagecount missing or invalid: ' + pageWindow.imagecount);
      }
      const chapterId = pageWindow.chapterid !== undefined && pageWindow.chapterid !== null ? pageWindow.chapterid : pageWindow.chapter_id;
      const requests = [];
      for (let i = 0; i < imageCount; i++) {
        requests.push((async function (pageIndex) {
          const url = 'chapterfun.ashx?cid=' + chapterId + '&page=' + pageIndex + '&key=' + key;
          const api = await fetchTextOrThrow(url, options);
          try {
            // The API returns packed JS that defines a global d (array of image URLs).
            // Appending ';d' makes the indirect eval return it, whatever scope the manager uses (docs/03 5.7).
            return (0, eval)(api + '\n;d');
          } catch (error) {
            throw new Error('MangaFox: evaluating chapterfun response for page ' + pageIndex + ' failed: ' + error.message);
          }
        })(i));
      }
      const results = await Promise.all(requests);
      // Page 1 takes d[0], the others d[1], as NEW does (NEW:13057).
      const listImages = toAbsoluteImageListB(results.map(function (result, index) {
        const entry = result ? result[index === 0 ? 0 : 1] : '';
        return typeof entry === 'string' ? entry : '';
      }));
      return {
        title: queryText('.reader-header-title div'),
        series: queryAttribute('.reader-header-title a', 'href'),
        // FIX (vs NEW): NEW returned imagecount; use the list length so no blank pages (docs/03 5.9).
        pages: listImages.length,
        prev: pageWindow.prechapterurl,
        next: pageWindow.nextchapterurl,
        listImages: listImages
      };
    }
  };

  // Mangago: page global imgsrcs, AES-decrypted with the PAGE's CryptoJS.
  const siteMangago = {
    name: 'Mangago',
    url: /https?:\/\/(www\.)?mangago\.me\/.*\/.*\/.*/,
    homepage: 'https://www.mangago.me/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'imgsrcs',
    testUrls: {
      match: ['https://www.mangago.me/read-manga/some_title/mf/v01/c001/', 'https://mangago.me/a/b/c'],
      noMatch: ['https://www.mangago.me/', 'https://www.mangagoXme/a/b/c']
    },
    run: function () {
      const CryptoJS = pageWindow.CryptoJS;
      if (!CryptoJS) {
        throw new Error('Mangago: page global CryptoJS not available');
      }
      let images = [];
      try {
        const key = CryptoJS.enc.Hex.parse('e11adc3949ba59abbe56e057f20f883e');
        const cipherOptions = {
          iv: CryptoJS.enc.Hex.parse('1234567890abcdef1234567890abcdef'),
          padding: CryptoJS.pad.ZeroPadding
        };
        images = CryptoJS.AES.decrypt(pageWindow.imgsrcs, key, cipherOptions).toString(CryptoJS.enc.Utf8).split(',');
      } catch (error) {
        throw new Error('Mangago: imgsrcs decryption failed: ' + error.message);
      }
      const hasEmptyEntry = images.some(function (source) {
        return source === '';
      });
      const listImages = toAbsoluteImageListB(images);
      return {
        title: pageWindow.manga_name + ' ' + pageWindow.chapter_name,
        // UNCLEAR (docs/03 5.8): mid looks like an id rather than a URL; kept as NEW.
        series: pageWindow.mid,
        // FIX (vs NEW): NEW returned total_pages; use the list length (docs/03 5.8 Mangago, 5.9).
        pages: listImages.length,
        prev: queryAttribute('.recom p:nth-child(5) a', 'href'),
        next: pageWindow.next_c_url,
        listImages: listImages,
        before: function () {
          // Retry by submitting the site's form when a decrypted entry was empty, as NEW does.
          if (hasEmptyEntry) {
            const form = query('#nform');
            if (form) {
              form.submit();
            }
          }
        }
      };
    }
  };

  // MangaHub: POST GraphQL API with an access header copied from a cookie.
  const siteMangahub = {
    name: 'MangaHub',
    url: /https?:\/\/(www\.)?(mangahub)\.io\/chapter\/.+\/.+/,
    homepage: 'https://mangahub.io/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#select-chapter',
    testUrls: {
      match: ['https://mangahub.io/chapter/some-title/chapter-12', 'https://www.mangahub.io/chapter/a/chapter-1.5'],
      noMatch: ['https://mangahub.io/', 'https://mangahubXio/chapter/a/chapter-1']
    },
    run: async function () {
      /** Reads a cookie value, or null. */
      function getCookie(name) {
        const value = new RegExp(name + '=([^;]+)').exec(document.cookie);
        return value !== null ? decodeURIComponent(value[1]) : null;
      }
      const pathParts = window.location.pathname.split('/');
      const slug = pageWindow.CURRENT_MANGA_SLUG !== undefined && pageWindow.CURRENT_MANGA_SLUG !== null ? pageWindow.CURRENT_MANGA_SLUG : pathParts[2];
      const number = (pathParts[3] || '').replace('chapter-', '');
      const data = { query: '{chapter(x:m01,slug:"' + slug + '",number:' + number + '){pages}}' };
      const options = {
        method: 'POST',
        body: JSON.stringify(data),
        headers: {
          'Content-Type': 'application/json',
          'x-mhub-access': getCookie('mhub_access') || ''
        }
      };
      const api = await fetchJson('https://api.mghcdn.com/graphql', options);
      let images = null;
      try {
        images = JSON.parse(api.data.chapter.pages.toString());
      } catch (error) {
        throw new Error('MangaHub: chapter pages parse failed: ' + error.message);
      }
      const listImages = (images.i || []).map(function (item) {
        return 'https://imgx.mghcdn.com/' + (images.p + item);
      });
      return {
        title: queryText('#mangareader h3'),
        series: queryAttribute('#mangareader a', 'href'),
        pages: listImages.length,
        prev: queryAttribute('.previous a', 'href'),
        next: queryAttribute('.next a', 'href'),
        listImages: listImages
      };
    }
  };

  // MangaKakalot family: DOM query of reader images; several brand layouts.
  const siteMangakakalot = {
    name: [
      'MangaKakalot',
      'NeloManga ',
      'MangaNato',
      'NatoManga',
      'MangaBats'
    ],
    url: /https?:\/\/(www\.)?(read|chap)?(nelomanga|mangakakalot|natomanga|manganato|mangabats|mangakakalove)\.(com|gg|net).*\/(chapter|manga)\/.+\/.+/,
    homepage: [
      'https://mangakakalot.gg/',
      'https://www.nelomanga.com/',
      'https://www.manganato.gg/',
      'https://www.natomanga.com/',
      'https://www.mangabats.com/'
    ],
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '.navi-change-chapter-btn-prev, .next, .navi-change-chapter-btn-next, .back',
    testUrls: {
      match: ['https://www.natomanga.com/manga/some-title/chapter-5', 'https://mangakakalot.gg/manga/a/chapter-1', 'https://chapmanganato.com/manga/ab123/chapter-1'],
      noMatch: ['https://mangakakalot.gg/', 'https://mangakakalotXgg/manga/a/chapter-1']
    },
    run: function () {
      const images = queryAll('#vungdoc img, .container-chapter-reader img');
      const listImages = toAbsoluteImageListB(images.map(function (img) {
        return readAttribute(img, 'src');
      }));
      // FIX (vs NEW): NEW called .item(1).getAttribute without a null check and threw a TypeError
      // when fewer than two links exist (docs/03 5.8 MangaKakalot).
      const seriesLinks = queryAll('span a[title]');
      return {
        title: queryText('.info-top-chapter h2, .imageOptions-chapter-info-top h1, .panel-chapter-info-top h1'),
        series: readAttribute(seriesLinks[1] || null, 'href'),
        pages: listImages.length,
        // UNCLEAR (docs/03 5.5): prev includes .next and next includes .back; kept as NEW.
        prev: queryAttribute('.navi-change-chapter-btn-prev, .next', 'href'),
        next: queryAttribute('.navi-change-chapter-btn-next, .back', 'href'),
        listImages: listImages
      };
    }
  };

  // MangaOni: DOM query in cascade mode; chapter <select> is newest first.
  const siteMangaoni = {
    name: 'MangaOni',
    url: /https?:\/\/(www\.)?manga-oni\.com\/lector\/.+\/\d+\/cascada/,
    homepage: 'https://manga-oni.com/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://manga-oni.com/lector/some-title/123456/cascada', 'https://www.manga-oni.com/lector/a/1/cascada/'],
      noMatch: ['https://manga-oni.com/', 'https://manga-oni.com/lector/a/1/paginada']
    },
    run: function () {
      const chapterList = query('#c_list');
      if (chapterList) {
        chapterList.dispatchEvent(new Event('mouseover'));
      }
      const chapter = query('#c_list option:checked');
      const images = queryAll('#slider img');
      const listImages = toAbsoluteImageListB(images.map(function (img) {
        return readFirstAttribute(img, ['data-src', 'src']);
      }));
      return {
        title: getPageTitle().replace(' — Manga en línea | MangaOni', ''),
        pages: listImages.length,
        prev: chapter ? readAttribute(chapter.nextElementSibling, 'value') : '',
        next: chapter ? readAttribute(chapter.previousElementSibling, 'value') : '',
        listImages: listImages
      };
    }
  };

  // MangaPark: DOM query of full-width images; prev/next are the two primary buttons.
  const siteMangapark = {
    name: 'MangaPark',
    url: /https?:\/\/(www\.)?(mangapark|mpark|comicpark|readpark|parkmanga)\.(com|me|org|net|io|to)\/title\/.+\/.+/,
    homepage: 'https://mangapark.net/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'main div div a.btn-primary',
    testUrls: {
      match: ['https://mangapark.net/title/12345-en-some-title/678-chapter-1', 'https://www.mpark.to/title/a/b'],
      noMatch: ['https://mangapark.net/', 'https://mangaparkXnet/title/a/b']
    },
    run: function () {
      const images = queryAll('main div div > img.w-full');
      const listImages = toAbsoluteImageListB(images.map(function (img) {
        return readAttribute(img, 'src');
      }));
      const buttons = queryAll('main div div a.btn-primary');
      return {
        title: queryAll('.comic-detail h3 a, .comic-detail h6 span').map(function (element) {
          return readText(element);
        }).join(' '),
        series: queryAttribute('.comic-detail a', 'href'),
        pages: listImages.length,
        prev: readAttribute(buttons[0] || null, 'href'),
        next: readAttribute(buttons[1] || null, 'href'),
        listImages: listImages
      };
    }
  };

  // MangaReader: DOM query of data-url; shuffled images unscrambled with the PAGE's imgReverser.
  const siteMangareader = {
    name: 'MangaReader',
    url: /https?:\/\/(www\.)?mangareader\.to\/read\/.+\/.+\/.+/,
    homepage: 'https://mangareader.to',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    obs: 'Some galleries will not be usable',
    waitEle: '.ds-image, .iv-card',
    testUrls: {
      match: ['https://mangareader.to/read/some-title-123/en/chapter-1', 'https://www.mangareader.to/read/a/b/c'],
      noMatch: ['https://mangareader.to/', 'https://mangareaderXto/read/a/b/c']
    },
    run: async function () {
      const chapter = query('.chapter-item.active');
      const sources = queryAll('.ds-image[data-url], .iv-card[data-url]').map(async function (img) {
        const url = readAttribute(img, 'data-url');
        if (url && img.classList.contains('shuffled')) {
          const imgReverser = pageWindow.imgReverser;
          if (typeof imgReverser !== 'function') {
            throw new Error('MangaReader: page global imgReverser not available');
          }
          try {
            return (await imgReverser(url)).toDataURL();
          } catch (error) {
            throw new Error('MangaReader: unscrambling ' + url + ' failed: ' + error.message);
          }
        }
        return url;
      });
      const listImages = toAbsoluteImageListB(await Promise.all(sources));
      const prevItem = chapter ? chapter.nextElementSibling : null;
      const nextItem = chapter ? chapter.previousElementSibling : null;
      return {
        title: queryText('.hr-manga h2'),
        series: queryAttribute('.hr-manga', 'href'),
        pages: listImages.length,
        prev: prevItem ? queryAttribute('a', 'href', prevItem) : '',
        next: nextItem ? queryAttribute('a', 'href', nextItem) : '',
        listImages: listImages
      };
    }
  };

  /** MangaStream: selector of the reader images only (no navigation containers). */
  const mangastreamImageSelector = ':where(#readerarea, .check-box, #viewer-img) img:not(.asurascans):not([src*="loader"]):not([src*="chevron"])';

  // MangaStream WordPress theme (generic): DOM query of reader images; prev/next by link text.
  const siteMangastreamwp = {
    name: [
      'MangaStream WordPress Plugin',
      'Realm Oasis',
      'Voids-Scans',
      'Luminous Scans',
      'Shimada Scans',
      'Night Scans',
      'Manhwa-Freak',
      'OzulScansEn',
      'CypherScans',
      'MangaGalaxy',
      'LuaScans',
      'Drake Scans',
      'Rizzfables',
      'NovatoScans',
      'TresDaos',
      'Lectormiau',
      'NTRGod',
      'Threedaos'
    ],
    url: /https?:\/\/[^/]*(scans?|comic|realm|rizz|hivetoon|tresdaos|zonamiau|ntrgod|threedaos)[^/]*\/.+/,
    homepage: [
      'https://themesia.com/mangastream-wordpress-theme/',
      'https://realmoasis.com/',
      'https://void-scans.com/',
      'https://luminous-scans.com/',
      'https://shimadascans.com/',
      'https://night-scans.com/',
      'https://freakcomic.com/',
      'https://ozulscansen.com/',
      'https://cypherscans.xyz/',
      'https://mangagalaxy.me/',
      'https://luascans.com/',
      'https://drake-scans.com/',
      'https://rizzfables.com/',
      'https://www.novatoscans.top/',
      'https://tresdaos.com',
      'https://zonamiau.com/',
      'https://ntrgod.com/',
      'https://threedaos.zdrz.xyz/'
    ],
    language: [Language.ENGLISH, Language.SPANISH],
    category: Category.MANGA,
    waitEle: ':where(#readerarea, .check-box, #viewer-img) img:not(.asurascans):not([src*="loader"]):not([src*="chevron"]),:where(.nextprev, .inner_nPL)',
    testUrls: {
      match: ['https://luminous-scans.com/some-title-chapter-1/', 'https://rizzfables.com/chapter/some-title-1'],
      noMatch: ['https://example.com/manga/a', 'https://luminous-scans.com/']
    },
    run: function () {
      // FIX (vs NEW): NEW reused waitEle, whose second part matches navigation containers, so they
      // were counted as pages with '' URLs (docs/03 5.8 MangaStream WP). Use the image part only.
      const images = queryAll(mangastreamImageSelector);
      const listImages = toAbsoluteImageListB(images.map(function (img) {
        return readFirstAttribute(img, ['data-src', 'data-lazy-src', 'src']);
      }));
      const prevLinks = findByContentEq(':where(.nextprev, .inner_nPL) a', ['Prev', 'Anterior']);
      const nextLinks = findByContentEq(':where(.nextprev, .inner_nPL) a', ['Next', 'Siguiente']);
      const breadcrumbLinks = queryAll('[class*="breadcrumb"] a');
      return {
        title: queryText('title'),
        series: queryAttribute(':where(.allc, .tac) a', 'href') || readAttribute(breadcrumbLinks[1] || null, 'href'),
        pages: listImages.length,
        prev: readAttribute(prevLinks[0] || null, 'href'),
        next: readAttribute(nextLinks[0] || null, 'href'),
        listImages: listImages
      };
    }
  };

  // MangaToons: DOM query of lazy images (data-src), cover excluded.
  const siteMangatoon = {
    name: 'MangaToons',
    url: /https?:\/\/.*mangatoon\.mobi\/.+\/watch\/.+/,
    homepage: 'https://mangatoon.mobi/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '.pictures img:not(.cover)',
    testUrls: {
      match: ['https://mangatoon.mobi/en/watch/123456/7890', 'https://www.mangatoon.mobi/es/watch/1/2'],
      noMatch: ['https://mangatoon.mobi/', 'https://mangatoonXmobi/en/watch/1/2']
    },
    run: function () {
      const images = queryAll('.pictures img:not(.cover)');
      const listImages = toAbsoluteImageListB(images.map(function (img) {
        return readAttribute(img, 'data-src');
      }));
      return {
        title: queryText('title'),
        series: queryAttribute('.top-left a', 'href'),
        pages: listImages.length,
        prev: queryAttribute('.page-icons-prev', 'href'),
        next: queryAttribute('.page-icons-next', 'href'),
        listImages: listImages
      };
    }
  };

  // MangaTown: listImages when the page shows all images, otherwise listPages from the page <select>.
  const siteMangatown = {
    name: 'MangaTown',
    url: /https?:\/\/www\.mangatown\.com\/manga\/.+\/.+\//,
    homepage: 'https://www.mangatown.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'div.page_select select option, .read_img .image',
    testUrls: {
      match: ['https://www.mangatown.com/manga/some_title/c001/', 'https://www.mangatown.com/manga/a/v01/c002/2.html'],
      noMatch: ['https://www.mangatown.com/', 'https://mangatown.com/manga/a/c001/']
    },
    run: function () {
      const options = queryAll('div.page_select select option');
      const images = toAbsoluteImageListB(queryAll('.read_img .image').map(function (img) {
        return readAttribute(img, 'src');
      }));
      const pageUrls = toAbsoluteImageListB(options.map(function (element) {
        return element.value;
      }).filter(function (url) {
        return !url.endsWith('featured.html');
      }));
      const checkedChapter = query('.chapter_select option:checked');
      const result = {
        title: queryText('div.title h1'),
        series: queryAttribute('div.title a', 'href'),
        prev: checkedChapter ? readAttribute(checkedChapter.previousElementSibling, 'value') : '',
        next: checkedChapter ? readAttribute(checkedChapter.nextElementSibling, 'value') : ''
      };
      // FIX (vs NEW): NEW returned total_pages || pages.length for both strategies; use the length
      // of the list actually returned so no blank pages appear (docs/03 5.9 MangaTown).
      if (images.length > 1) {
        return Object.assign({}, result, { pages: images.length, listImages: images });
      }
      return Object.assign({}, result, { pages: pageUrls.length, listPages: pageUrls, img: '#image' });
    }
  };


  // ---- Site modules, batch C (ported from NEW:13334-13770) ----

  /**
   * Reads a nested property path safely, e.g. siteCReadPath(data, ['chapter', 'images']).
   * Returns undefined as soon as a step is null/undefined (replaces optional-chaining chains).
   * @param {*} source - Starting value.
   * @param {Array<string|number>} path - Property names / indexes.
   * @returns {*}
   */
  function siteCReadPath(source, path) {
    let current = source;
    for (let i = 0; i < path.length; i++) {
      if (current === null || current === undefined) {
        return undefined;
      }
      current = current[path[i]];
    }
    return current;
  }

  /**
   * Returns the first value that is not null/undefined (replaces nullish-coalescing chains).
   * @param {Array<*>} values - Candidates in priority order.
   * @returns {*}
   */
  function siteCFirstDefined(values) {
    for (let i = 0; i < values.length; i++) {
      if (values[i] !== null && values[i] !== undefined) {
        return values[i];
      }
    }
    return undefined;
  }

  // ManhwaWeb: JSON API on a separate backend host (chapter data + prev/next ids).
  const siteManhwaweb = {
    name: 'ManhwaWeb',
    url: /https?:\/\/(www\.)?manhwaweb\.com\/leer\/.+/,
    homepage: 'https://manhwaweb.com/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://manhwaweb.com/leer/some-series_123-1', 'https://www.manhwaweb.com/leer/abc'],
      noMatch: ['https://manhwaweb.com/', 'https://manhwawebXcom/leer/abc']
    },
    run: async function () {
      const slug = window.location.pathname.replace('/leer', '');
      const api = await fetchJson('https://manhwawebbackend-production.up.railway.app/chapters/see' + slug);
      const data = await fetchJson('https://manhwawebbackend-production.up.railway.app/chapters/seeprevpost' + slug);
      const episodesLabel = queryAll('div').filter(function (element) {
        return element.textContent === 'Episodios';
      })[0];
      const images = (siteCReadPath(api, ['chapter', 'img']) || []).filter(function (url) {
        return typeof url === 'string' && url !== '';
      });
      // FIX (vs NEW): docs/03 5.8 - NEW called .replace on chapterAnterior/chapterSiguiente without a null check; missing links now become ''.
      const prevId = data && typeof data.chapterAnterior === 'string' ? data.chapterAnterior : '';
      const nextId = data && typeof data.chapterSiguiente === 'string' ? data.chapterSiguiente : '';
      return {
        title: api.name + ' ' + api.chapter.chapter,
        series: episodesLabel ? readAttribute(episodesLabel.parentElement, 'href') : '',
        pages: images.length,
        prev: prevId ? prevId.replace(api._id, api.real_id) : '',
        next: nextId ? nextId.replace(api._id, api.real_id) : '',
        listImages: images
      };
    }
  };

  // MangaGeko: DOM query of reader images and nav links.
  const siteMgeko = {
    name: ['MangaGeko.com', 'MangaGeko.cc'],
    url: /https?:\/\/(www\.)?mgeko\.(com|cc)?\/reader\/.*/,
    homepage: ['https://www.mgeko.com/', 'https://www.mgeko.cc/'],
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://www.mgeko.com/reader/en/some-manga-chapter-1/', 'https://mgeko.cc/reader/en/x-chapter-2/'],
      noMatch: ['https://www.mgeko.com/', 'https://www.mgekoXcom/reader/x']
    },
    run: function () {
      const images = queryAll('img[id^="image-"]').map(function (img) {
        return toAbsoluteUrl(readAttribute(img, 'src'));
      }).filter(function (url) {
        return url !== '';
      });
      return {
        title: queryText('.titles'),
        series: queryAttribute('.titles a', 'href'),
        pages: images.length,
        prev: queryAttribute('.chnav.prev:not(.isDisabled)', 'href'),
        next: queryAttribute('.chnav.next:not(.isDisabled)', 'href'),
        listImages: images
      };
    }
  };

  // NineAnime: listPages from the page <select>, one image per page.
  const siteNineanime = {
    name: 'NineAnime',
    url: /https?:\/\/(www\.)?nineanime\.com\/chapter\/.+/,
    homepage: 'https://www.nineanime.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://www.nineanime.com/chapter/some-manga-chapter-1/123/', 'https://nineanime.com/chapter/x'],
      noMatch: ['https://www.nineanime.com/', 'https://www.nineanimeXcom/chapter/x']
    },
    run: function () {
      const pageList = queryAll('.sl-page option').map(function (option) {
        return toAbsoluteUrl(readAttribute(option, 'value'));
      }).filter(function (url) {
        return url !== '';
      });
      const chapter = query('.mangaread-pagenav select option[selected]');
      return {
        title: queryText('.title h1') + '/' + queryText('.title h2'),
        // Note: ':has()' needs a recent browser (docs/03 5.10); kept as in NEW.
        series: queryAttribute('.title a:has(h2)', 'href'),
        pages: pageList.length,
        prev: chapter ? readAttribute(chapter.nextElementSibling, 'value') : '',
        next: chapter ? readAttribute(chapter.previousElementSibling, 'value') : '',
        listPages: pageList,
        img: '.manga_pic'
      };
    }
  };

  // Olympus: DOM query of reader images and named nav links.
  const siteOlympus = {
    name: 'Olympus',
    url: /https?:\/\/(www\.)?olympusxyz\.com\/capitulo\/\d+\/.+/,
    homepage: 'https://olympusxyz.com/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://olympusxyz.com/capitulo/12345/some-series', 'https://www.olympusxyz.com/capitulo/1/x'],
      noMatch: ['https://olympusxyz.com/', 'https://olympusxyz.com/capitulo/abc/x']
    },
    run: function () {
      const images = queryAll('section img.w-full.h-full').map(function (img) {
        return toAbsoluteUrl(readAttribute(img, 'src'));
      }).filter(function (url) {
        return url !== '';
      });
      const heading = query('h1');
      return {
        title: queryText('title').replace(/\|.+/, '').trim(),
        series: heading ? readAttribute(heading.closest('a'), 'href') : '',
        pages: images.length,
        prev: queryAttribute('a[name="capitulo anterior"]', 'href'),
        next: queryAttribute('a[name="capitulo siguiente"]', 'href'),
        listImages: images
      };
    }
  };

  // Qi Manga: DOM images + regex over the Angular state script (#ng-state) for prev/next slugs.
  const siteQimanga = {
    name: 'Qi Manga',
    url: /https?:\/\/(www\.)?qimanga\.com\/series\/[^/]+\/chapter-.+/,
    homepage: 'https://qimanga.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'img.r-page-img',
    testUrls: {
      match: ['https://qimanga.com/series/some-series/chapter-12', 'https://www.qimanga.com/series/x/chapter-1-5'],
      noMatch: ['https://qimanga.com/', 'https://qimanga.com/series/some-series']
    },
    run: function () {
      const images = queryAll('img.r-page-img').map(function (img) {
        return toAbsoluteUrl(readAttribute(img, 'src'));
      }).filter(function (url) {
        return url !== '';
      });
      const state = queryText('#ng-state');
      const nav = /"navigation":\{"prev":\{"slug":"([^"]+)"[\s\S]*?"next":\{"slug":"([^"]+)"/.exec(state);
      const series = queryAttribute('.r-back', 'href');
      /** Builds a chapter link from a slug, or '' without a series link. */
      function chapterUrl(slug) {
        return series ? series + '/' + slug : '';
      }
      return {
        title: queryText('h1.sr-only'),
        series: series,
        // FIX (vs NEW): docs/03 5.9 - NEW used the '.r-page-counter' total, which can exceed the list and leave blank pages; now the list length.
        pages: images.length,
        prev: nav ? chapterUrl(nav[1]) : '',
        next: nav ? chapterUrl(nav[2]) : '',
        listImages: images
      };
    }
  };

  // QiManhwa: DOM images; prev/next via Lucide chevron icons, series via the "Home" button.
  const siteQimanhwa = {
    name: 'QiManhwa',
    url: /https?:\/\/qimanhwa\.com\/series\/[^/]+\/chapter-.+/,
    homepage: 'https://qimanhwa.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'img[alt*="Chapter"]',
    testUrls: {
      match: ['https://qimanhwa.com/series/some-series/chapter-3', 'http://qimanhwa.com/series/x/chapter-10'],
      noMatch: ['https://qimanhwa.com/', 'https://qimanhwa.com/series/some-series']
    },
    run: function () {
      const images = queryAll('img[alt*="Chapter"]').map(function (img) {
        return toAbsoluteUrl(readAttribute(img, 'src'));
      }).filter(function (url) {
        return url !== '';
      });
      const prevIcon = query('.lucide-chevron-left');
      const nextIcon = query('.lucide-chevron-right');
      return {
        title: queryText('h1'),
        series: readAttribute(findClosestByContentEq('button', 'Home', 'a'), 'href'),
        pages: images.length,
        prev: prevIcon ? readAttribute(prevIcon.closest('a'), 'href') : '',
        next: nextIcon ? readAttribute(nextIcon.closest('a'), 'href') : '',
        listImages: images
      };
    }
  };

  // ReadComicsOnline: DOM images (data-src) + page globals for title and prev/next.
  const siteReadcomicsonline = {
    name: 'ReadComicsOnline',
    url: /https?:\/\/(www\.)?readcomicsonline\.ru\/comic\/.*\/\d*/,
    homepage: 'https://readcomicsonline.ru/',
    language: [Language.ENGLISH],
    category: Category.COMIC,
    testUrls: {
      match: ['https://readcomicsonline.ru/comic/some-comic-2020/1', 'https://www.readcomicsonline.ru/comic/x/12'],
      noMatch: ['https://readcomicsonline.ru/', 'https://readcomicsonlineXru/comic/x/1']
    },
    run: function () {
      // FIX (vs NEW): docs/03 5.8 - NEW read the page globals with no wait and threw a bare TypeError if they were late; now a named error.
      if (typeof pageWindow.title !== 'string') {
        throw new Error('ReadComicsOnline: page global "title" not found');
      }
      const images = queryAll('#all img').map(function (img) {
        return toAbsoluteUrl(readAttribute(img, 'data-src'));
      }).filter(function (url) {
        return url !== '';
      });
      return {
        title: pageWindow.title.replace(/ - Page \d+/, ''),
        series: queryAttribute('div.pager-cnt a', 'href'),
        // Spec rule: pages = list length (NEW used pageWindow.pages.length, which can differ from the DOM list).
        pages: images.length,
        prev: pageWindow.prev_chapter || '',
        next: pageWindow.next_chapter || '',
        listImages: images
      };
    }
  };

  // ReaperScans: DOM images (data-src || src); prev/next via Font Awesome icons.
  const siteReaperscans = {
    name: 'ReaperScans',
    url: /https?:\/\/(www\.)?reaperscans\.com\/series\/.+\/chapter.+/,
    homepage: 'https://reaperscans.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#content .container img:not(.rounded)',
    testUrls: {
      match: ['https://reaperscans.com/series/some-series/chapter-10', 'https://www.reaperscans.com/series/x/chapter-1'],
      noMatch: ['https://reaperscans.com/', 'https://reaperscans.com/series/some-series']
    },
    run: function () {
      const images = queryAll('#content .container img:not(.rounded)').map(function (img) {
        return toAbsoluteUrl(readFirstAttribute(img, ['data-src', 'src']));
      }).filter(function (url) {
        return url !== '';
      });
      const homeIcon = query('button .fa-house');
      const prevIcon = query('.fa-chevron-left');
      const nextIcon = query('.fa-chevron-right');
      return {
        title: queryText('title'),
        series: homeIcon ? readAttribute(homeIcon.closest('a'), 'href') : '',
        pages: images.length,
        prev: prevIcon ? readAttribute(prevIcon.closest('a'), 'href') : '',
        next: nextIcon ? readAttribute(nextIcon.closest('a'), 'href') : '',
        listImages: images
      };
    }
  };

  // ReiManga: embedded Next.js JSON (__NEXT_DATA__), else a DOM keyword heuristic; prev/next from the chapter <select>.
  const siteReimanga = {
    name: 'ReiManga',
    url: /https?:\/\/(www\.)?reimanga\.com\/manga\/[^/]+-\d+\/\d+/,
    homepage: 'https://reimanga.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'img',
    testUrls: {
      match: ['https://reimanga.com/manga/some-series-123/456', 'https://www.reimanga.com/manga/x-1/2'],
      noMatch: ['https://reimanga.com/', 'https://reimanga.com/manga/some-series-123']
    },
    run: function () {
      const nextData = document.getElementById('__NEXT_DATA__');
      let data = null;
      if (nextData) {
        try {
          const parsed = JSON.parse(nextData.textContent || '{}');
          const pageProps = siteCReadPath(parsed, ['props', 'pageProps']);
          data = pageProps === undefined || pageProps === null ? null : pageProps;
        } catch (error) {
          throw new Error('ReiManga: __NEXT_DATA__ JSON parse failed: ' + error.message);
        }
      }
      const images = siteCFirstDefined([siteCReadPath(data, ['chapter', 'images']), siteCReadPath(data, ['chapter', 'img']), null]);
      let listImages = [];
      if (Array.isArray(images)) {
        listImages = images.map(function (img) {
          if (typeof img === 'string') {
            return toAbsoluteUrl(img);
          }
          return toAbsoluteUrl(siteCFirstDefined([siteCReadPath(img, ['url']), siteCReadPath(img, ['src']), '']));
        });
      } else {
        listImages = queryAll('img').filter(function (img) {
          const src = readAttribute(img, 'src');
          return /\/(manga|chapter|uploads?|files?|images?)\//i.test(src) || /(cdn|media|img|static)/i.test(src);
        }).map(function (img) {
          return readAttribute(img, 'src');
        }).filter(function (src) {
          return src.indexOf('http') === 0;
        });
      }
      listImages = listImages.filter(function (url) {
        return url !== '';
      });
      const pathParts = window.location.pathname.split('/').filter(Boolean);
      const basePath = pathParts.length > 1 ? '/' + pathParts.slice(0, 2).join('/') : '';
      const seriesSlug = siteCReadPath(data, ['series', 'slug']);
      const series = seriesSlug !== undefined ? '/manga/' + seriesSlug : (queryAttribute('a[href*="/manga/"]', 'href') || basePath);
      const select = query('select[aria-label="Jump to chapter"]');
      const options = select ? Array.prototype.slice.call(select.options) : [];
      const index = select ? select.selectedIndex : -1;
      const prevId = index + 1 < options.length && options[index + 1] ? options[index + 1].value : '';
      const nextId = index - 1 >= 0 && options[index - 1] ? options[index - 1].value : '';
      /** Builds a chapter link from an id, or '' when id or series is missing. */
      function chapterUrl(id) {
        return id && series ? series + '/' + id : '';
      }
      let title = siteCReadPath(data, ['title']);
      if (title === undefined || title === null) {
        const seriesName = siteCFirstDefined([siteCReadPath(data, ['chapters', 0, 'series_name']), siteCReadPath(data, ['manga', 'name']), siteCReadPath(data, ['series', 'name']), '']);
        const chapterName = siteCFirstDefined([siteCReadPath(data, ['chapters', 0, 'name']), siteCReadPath(data, ['chapter', 'name']), siteCReadPath(data, ['chapter', 'number']), '']);
        title = (seriesName + ' ' + chapterName).trim() || document.title.replace(/ –.*| -.*| \|.*/, '').trim();
      }
      return {
        title: title,
        series: series,
        pages: listImages.length,
        prev: chapterUrl(prevId),
        next: chapterUrl(nextId),
        listImages: listImages
      };
    }
  };

  /**
   * TuMangaOnline engine (zonatmo + tmohentai): shared run() body for the site modules that use
   * this reader. Single-image pages are followed through the page selector (listPages + img);
   * when all images are already in the DOM their URLs are read directly.
   * @returns {Object} The manga object (see docs/03 section 2.2).
   */
  function runTmoReader() {
    const images = queryAll('.img-container img, .viewer-container img, .content-image, .viewer-image, .img-fluid, .reader-img-wrap img, .viewer-img, #viewer-container img, .viewer-page');
    const pageOptions = queryAll('div.container:nth-child(4) select#viewer-pages-select option, #viewer-pages-select option, select#chapter-pages option, select#pages option');
    const seriesLink = query('a[title="Volver"]') || query('.breadcrumb-item:nth-child(2) a') || query('.book-name a') || query('.breadcrumb-item a');
    const prevLink = query('.chapter-prev a') || query('.prev_page') || query('a.prev-chapter') || query('.chapter-prev-btn');
    const nextLink = query('.chapter-next a') || query('.next_page') || query('a.next-chapter') || query('.chapter-next-btn');
    const result = {
      title: queryText('title'),
      series: readAttribute(seriesLink, 'href'),
      prev: readAttribute(prevLink, 'href'),
      next: readAttribute(nextLink, 'href'),
      img: '#viewer-container img, .viewer-page, .img-container img, .content-image, .viewer-image, .reader-img-wrap img, .viewer-img',
      before: function () {
        if (window.location.pathname.indexOf('paginated') !== -1) {
          window.location.pathname = window.location.pathname.replace(/paginated.*/, 'cascade');
        }
        if (window.location.pathname.indexOf('view_uploads') !== -1) {
          const button = query('a.btn.btn-primary, .btn-primary a, a.btn-block');
          if (button) {
            button.click();
          }
        }
      }
    };
    if (images.length <= 1 && pageOptions.length > 1) {
      const baseUrl = window.location.href.replace(/\/\d+$/, '');
      const listPages = [];
      for (let i = 0; i < pageOptions.length; i++) {
        listPages.push(baseUrl + '/' + (i + 1));
      }
      result.listPages = listPages;
      result.pages = listPages.length;
    } else {
      const listImages = images.map(function (item) {
        return toAbsoluteUrl(readFirstAttribute(item, ['data-src', 'data-original', 'src']));
      }).filter(function (url) {
        return url !== '';
      });
      result.listImages = listImages;
      // FIX (vs NEW): docs/03 5.8 - NEW returned 'pages: num || 1', so it never declined and could win with one empty page; now the list length (0 declines).
      result.pages = listImages.length;
    }
    return result;
  }

  // TuMangaOnline: DOM images (cascade view) or listPages built from the page <select> (paginated view); before() switches layout.
  const siteTmofans = {
    name: 'TuMangaOnline',
    url: /https?:\/\/(www\.)?zonatmo\.(com|org|app)\/(viewer|news|view_uploads|reader|library)\/.+/,
    homepage: 'https://zonatmo.org/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://zonatmo.org/viewer/abc123/cascade', 'https://zonatmo.com/view_uploads/123456', 'https://www.zonatmo.app/news/1/2'],
      noMatch: ['https://zonatmo.org/', 'https://zonatmo.org/profile/x']
    },
    run: function () {
      return runTmoReader();
    }
  };

  // WebNovel: page global g_data (chapter info and page list).
  const siteWebNovel = {
    name: 'WebNovel',
    url: /https?:\/\/(www\.)?webnovel\.com\/comic\/.+/,
    homepage: 'https://www.webnovel.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'g_data',
    testUrls: {
      match: ['https://www.webnovel.com/comic/some-comic_123/chapter-1_456', 'https://webnovel.com/comic/x'],
      noMatch: ['https://www.webnovel.com/', 'https://www.webnovelXcom/comic/x']
    },
    run: function () {
      const chapterInfo = siteCReadPath(pageWindow.g_data, ['chapter', 'chapterInfo']);
      if (!chapterInfo || !Array.isArray(chapterInfo.chapterPage)) {
        throw new Error('WebNovel: g_data.chapter.chapterInfo.chapterPage not found');
      }
      const images = chapterInfo.chapterPage.map(function (img) {
        return img ? img.url : '';
      }).filter(function (url) {
        return typeof url === 'string' && url !== '';
      });
      /** Builds '<name>_<id>' or '' when the id is missing. */
      function chapterLink(name, id) {
        if (id === undefined || id === null || id === '') {
          return '';
        }
        return (name === undefined || name === null ? '' : name) + '_' + id;
      }
      return {
        title: queryText('title'),
        series: './',
        pages: images.length,
        // FIX (vs NEW): docs/03 5.6/5.8 - NEW built 'undefined_undefined' when a neighbor chapter is missing; now ''.
        prev: chapterLink(chapterInfo.preChapterName, chapterInfo.preChapterId),
        next: chapterLink(chapterInfo.nextChapterName, chapterInfo.nextChapterId),
        listImages: images
      };
    }
  };

  // WebToons: DOM query of #_imageList images (data-url || data-src || src).
  const siteWebToons = {
    name: 'WebToons',
    url: /https?:\/\/(www\.)?webtoons\.com\/.+viewer.+/,
    homepage: 'https://www.webtoons.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://www.webtoons.com/en/fantasy/some-title/episode-1/viewer?title_no=1&episode_no=1'],
      noMatch: ['https://www.webtoons.com/', 'https://www.webtoons.com/en/fantasy/some-title/list?title_no=1']
    },
    run: function () {
      const images = queryAll('#_imageList img').map(function (img) {
        return toAbsoluteUrl(readFirstAttribute(img, ['data-url', 'data-src', 'src']));
      }).filter(function (url) {
        return url !== '';
      });
      return {
        title: queryText('.subj_info'),
        series: queryAttribute('.subj_info a', 'href'),
        pages: images.length,
        prev: queryAttribute('._prevEpisode', 'href'),
        next: queryAttribute('._nextEpisode', 'href'),
        listImages: images
      };
    }
  };

  // WeebCentral: HTMX fragments (images endpoint and chapter-select endpoint) fetched with the HX-Request header.
  const siteWeebCentral = {
    name: 'WeebCentral',
    url: /https?:\/\/(www\.)?(weebcentral)\.com\/chapters\/.+/,
    homepage: 'https://weebcentral.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'section[hx-get*="/images"]',
    testUrls: {
      match: ['https://weebcentral.com/chapters/01J76XYZABCDEF', 'https://www.weebcentral.com/chapters/abc'],
      noMatch: ['https://weebcentral.com/', 'https://weebcentral.com/series/01J76XYZ/some-title']
    },
    run: async function () {
      // Re-run guard: the viewer marks <html> with the 'mvm' attribute after takeover; decline quietly.
      if (document.documentElement.hasAttribute('mvm')) {
        return {
          pages: 0,
          listImages: []
        };
      }
      const imagesUrlBase = queryAttribute('section[hx-get*="/images"]', 'hx-get');
      if (!imagesUrlBase) {
        throw new Error('WeebCentral: images HTMX endpoint not found');
      }
      const htmxOptions = { headers: { 'HX-Request': 'true' } };
      const imagesUrl = imagesUrlBase.replace(/&amp;/g, '&') + '&reading_style=long_strip';
      const imagesDocument = parseHtml(await fetchTextOrThrow(imagesUrl, htmxOptions));
      const sources = queryAll('img', imagesDocument).map(function (img) {
        return readFirstAttribute(img, ['src', 'data-src']);
      }).filter(function (source) {
        return source !== '' && source.indexOf('broken_image') === -1;
      }).map(function (source) {
        return source.indexOf('http') === 0 ? source : new URL(source, window.location.origin).href;
      });
      /** Page number taken from '-<n>.<ext>' at the end of the URL, 0 when absent. */
      function pageNumber(url) {
        const match = url.match(/-(\d+)\.[^.]+$/);
        return match ? parseInt(match[1], 10) : 0;
      }
      const uniqueImages = uniqueValues(sources).sort(function (a, b) {
        return pageNumber(a) - pageNumber(b);
      });
      const chapterSelectUrl = queryAttribute('button[hx-get*="chapter-select"]', 'hx-get');
      let selectedChapter = null;
      if (chapterSelectUrl) {
        const chaptersDocument = parseHtml(await fetchTextOrThrow(chapterSelectUrl, htmxOptions));
        selectedChapter = query('#selected_chapter', chaptersDocument);
      }
      /** Absolute URL for a path, '' when the path is empty. */
      function absoluteUrl(path) {
        if (!path) {
          return '';
        }
        return path.indexOf('http') === 0 ? path : new URL(path, window.location.origin).href;
      }
      return {
        title: document.title.split(' - ')[0].trim(),
        series: absoluteUrl(queryAttribute('main section a.btn-ghost', 'href')),
        pages: uniqueImages.length,
        prev: absoluteUrl(selectedChapter ? readAttribute(selectedChapter.nextElementSibling, 'href') : ''),
        next: absoluteUrl(selectedChapter ? readAttribute(selectedChapter.previousElementSibling, 'href') : ''),
        listImages: uniqueImages,
        // Kept from NEW for fidelity, but the rewrite displays images with a plain <img src> (no fetch),
        // so these options are ignored for display. 'Referer' is also a forbidden fetch header, and
        // 'HX-Request' would force a CORS preflight on the image host (docs/03 4.11).
        fetchOptions: {
          headers: {
            'HX-Request': 'true',
            Referer: window.location.href
          }
        }
      };
    }
  };

  // WeebDex: JSON API (api.weebdex.org) for the chapter and the manga aggregate (prev/next).
  const siteWeebDex = {
    name: 'WeebDex',
    url: /https?:\/\/(www\.)?weebdex\.org\/.+/,
    homepage: 'https://weebdex.org/',
    // NEW passed a bare value (Language.ENGLISH); the spec requires an array.
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'a[href^="/title/"]',
    testUrls: {
      match: ['https://weebdex.org/chapter/abc123', 'https://www.weebdex.org/chapter/xyz/1'],
      noMatch: ['https://weebdex.org/', 'https://weebdexXorg/chapter/abc']
    },
    run: async function () {
      const chapterMatch = /\/chapter\/([^/]+)/.exec(window.location.pathname);
      if (!chapterMatch) {
        throw new Error('WeebDex: not a chapter URL (no /chapter/<id> in path)');
      }
      const chapterId = chapterMatch[1];
      const chapterData = await fetchJson('https://api.weebdex.org/chapter/' + chapterId);
      const mangaId = siteCReadPath(chapterData, ['relationships', 'manga', 'id']);
      const aggregate = await fetchJson('https://api.weebdex.org/manga/' + mangaId + '/aggregate?lang=' + chapterData.language);
      const chapters = aggregate.chapters || [];
      const currentIndex = chapters.findIndex(function (item) {
        return item.chapter === chapterData.chapter;
      });
      // lodash keys() in NEW -> safeKeys (docs/03 5.10: never throws on a missing 'entries').
      const prevEntry = chapters[currentIndex + 1];
      const nextEntry = chapters[currentIndex - 1];
      const images = (chapterData.data || []).map(function (img) {
        return chapterData.node + '/data/' + chapterId + '/' + img.name;
      });
      return {
        title: queryText('title').replace(/Page \d+:/, ''),
        series: queryAttribute('a[href^="/title/"]', 'href'),
        pages: images.length,
        prev: prevEntry ? '/chapter/' + safeKeys(prevEntry.entries)[0] : '',
        next: nextEntry ? '/chapter/' + safeKeys(nextEntry.entries)[0] : '',
        listImages: images
      };
    }
  };

  // Vortex Scans: Next.js app-router flight data (pageWindow.__next_f), image URLs extracted by regex.
  const siteVortexscans = {
    name: 'Vortex Scans',
    url: /https?:\/\/(www\.)?(vortexscans)\.(org)\/.+/,
    homepage: 'https://vortexscans.org/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: '__next_f',
    waitFunc: function () {
      // Self-contained: does not rely on waitVar having passed first, and does not use this.
      const flight = pageWindow.__next_f;
      if (!Array.isArray(flight)) {
        return false;
      }
      const entry = flight.find(function (item) {
        return Boolean(item) && /images/.test(item[1]);
      });
      return entry !== undefined && entry.length > 0;
    },
    testUrls: {
      match: ['https://vortexscans.org/series/some-series/chapter-1', 'https://www.vortexscans.org/x'],
      noMatch: ['https://vortexscans.org/', 'https://vortexscansXorg/series/x']
    },
    run: function () {
      const flight = Array.isArray(pageWindow.__next_f) ? pageWindow.__next_f : [];
      const entry = flight.find(function (item) {
        return Boolean(item) && /images/.test(item[1]);
      });
      const data = entry ? entry[1] : undefined;
      if (typeof data !== 'string') {
        throw new Error('Vortex Scans: __next_f entry with "images" not found');
      }
      const images = data.slice(data.indexOf('images')).match(/http[^"]+\.(png|gif|jpg|jpeg|webp)/g) || [];
      const time = query('time');
      const timeDiv = time ? time.closest('div') : null;
      return {
        title: timeDiv ? readText(timeDiv.querySelector('div')) : '',
        series: time ? readAttribute(time.closest('a'), 'href') : '',
        pages: images.length,
        prev: readAttribute(findClosestByContentEq('button', 'Prev', 'a'), 'href'),
        next: readAttribute(findClosestByContentEq('button', 'Next', 'a'), 'href'),
        listImages: images
      };
    }
  };

  // ZeroScans: page global __ZEROSCANS__ (Nuxt state) with the high-quality image list.
  const siteZeroScans = {
    name: 'ZeroScans',
    url: /https?:\/\/(www\.)?zscans\.com\/comics\/.+/,
    homepage: 'https://zscans.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: '__ZEROSCANS__',
    testUrls: {
      match: ['https://zscans.com/comics/some-series/12345', 'https://www.zscans.com/comics/x'],
      noMatch: ['https://zscans.com/', 'https://zscansXcom/comics/x']
    },
    run: function () {
      // at(0) in NEW -> index [0].
      const images = siteCReadPath(pageWindow.__ZEROSCANS__, ['data', 0, 'current_chapter', 'high_quality']);
      if (!Array.isArray(images)) {
        throw new Error('ZeroScans: page global __ZEROSCANS__ has no data[0].current_chapter.high_quality');
      }
      const chapters = queryAll('.v-btn--router');
      return {
        title: queryText('title'),
        series: queryAttribute('.v-breadcrumbs li:nth-child(2) + a', 'href'),
        pages: images.length,
        prev: readAttribute(chapters[0], 'href'),
        next: readAttribute(chapters[1], 'href'),
        listImages: images
      };
    }
  };


  // ---- Site modules, batch E (ported from a user-supplied reference script, 2026-10) ----
  // Conversion notes (docs/07 Part B): unsafeWindow -> pageWindow, _.keys -> safeKeys, .at(i)
  // -> [i], flatMap/replaceAll -> ES2017 equivalents, DOM reads -> section 06 helpers, relative
  // URLs -> toAbsoluteUrl, waitVar names unchanged (MM resolves them on pageWindow with a timeout).

  /**
   * File extension from a one-letter server code ('p' = png...), as the HentaiFox and Imhentai
   * modules receive it (page global g_th).
   * @param {string} code - One-letter code.
   * @returns {string}
   */
  function extensionByTypeCode(code) {
    switch (code) {
      case 'p':
        return 'png';
      case 'b':
        return 'bmp';
      case 'g':
        return 'gif';
      case 'w':
        return 'webp';
      default:
        return 'jpg';
    }
  }

  /** nHentai.net API image-type codes: the 't' field of each page -> file extension. */
  const GALLERY_API_IMAGE_EXT = { j: 'jpg', p: 'png', w: 'webp', g: 'gif' };

  // 3Hentai/HentaiVox: page global readerPages (needs waitVar; MM rejects numeric/empty values).
  const site3hentai = {
    name: ['3Hentai', 'HentaiVox'],
    url: /https?:\/\/(www|es|fr|it|pt|ru)?\.?(3hentai|hentaivox)\.(net|com)\/(d|view)\/.+\/.+/,
    homepage: ['https://3hentai.net/', 'https://hentaivox.com/'],
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'readerPages',
    testUrls: {
      match: ['https://3hentai.net/d/some-title/1/', 'https://hentaivox.com/view/abc/def'],
      noMatch: ['https://3hentai.net/', 'https://example.com/d/a/b']
    },
    run: function () {
      const readerPages = pageWindow.readerPages;
      if (!readerPages || !readerPages.pages) {
        throw new Error('3Hentai: page global readerPages.pages not found');
      }
      const baseUri = String(readerPages.baseUriImg || '');
      const listImages = sitesAImageList(safeKeys(readerPages.pages).map(function (key) {
        const page = readerPages.pages[key] || {};
        return baseUri.replace('%s', page.f || '');
      }));
      return {
        title: String(readerPages.title || '').replace(/- Page.+/, '').trim(),
        series: String(readerPages.baseUri || '').replace('%s', ''),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // 8Muses: page global link_images; falls back to clicking "next" through every page (bruteforceClick).
  const siteEightMuses = {
    name: ['8Muses.com', '8Muses.io'],
    obs: 'Slow start, bruteforce may be required',
    url: /https?:\/\/(comics\.)?8muses\.(com|io)\/(comics\/)?picture\/.+/,
    homepage: ['https://comics.8muses.com/', 'https://8muses.io/'],
    language: [Language.ENGLISH],
    category: Category.COMIC,
    testUrls: {
      match: ['https://comics.8muses.com/comics/picture/12345/a', 'https://8muses.io/picture/abc'],
      noMatch: ['https://comics.8muses.com/', 'https://example.com/picture/1']
    },
    run: async function () {
      const linkImages = pageWindow.link_images;
      const startImages = Array.isArray(linkImages) ? linkImages.slice(1) : [];
      const lastHref = queryAttribute('link[rel="last"]', 'href');
      const lastMatch = lastHref ? lastHref.match(/\d+$/) : null;
      const num = startImages.length > 0 ? startImages.length : (lastMatch ? parseInt(lastMatch[0], 10) : 0);
      if (!(num > 0)) {
        throw new Error('8Muses: no link_images and no last-page link found');
      }
      const breadcrumb = queryAll('.top-menu-breadcrumb li:not(:last-child)').map(function (item) {
        return readText(item).trim();
      }).join('/');
      const seriesLink = query('.top-menu-breadcrumb li:nth-last-child(2) a');
      const manga = {
        title: breadcrumb,
        series: readAttribute(seriesLink, 'href'),
        pages: num,
        prev: '#',
        next: '#',
        lazy: false,
        timer: 10,
        // [''] placeholder when the page global is missing: an empty list would make the
        // viewer reject the result before before() runs (validateMangaResult needs a strategy).
        listImages: startImages.length > 0 ? startImages : [''],
        before: async function () {
          if (pageWindow.link_images && pageWindow.link_images.length > 0) {
            return;
          }
          manga.listImages = await bruteforceClick(function () {
            const prev = query('.page-prev');
            let guard = 0;
            while (queryText('.c-dropdown-toggle').match(/\d+/) !== null && queryText('.c-dropdown-toggle').match(/\d+/)[0] !== '1' && guard < 200) {
              if (prev) {
                prev.dispatchEvent(new Event('click'));
              }
              guard += 1;
            }
          }, num, '.page-next', '.p-picture', '.photo img', 'src');
        }
      };
      return manga;
    }
  };

  // 9Hentai: same-origin POST API /api/getBookByID.
  const siteNineHentai = {
    name: '9Hentai',
    url: /https?:\/\/(www\.)?9hentai\.(so)\/g\/.+\/.+/,
    homepage: 'https://9hentai.so',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitAttr: ['#jumpPageModal input', 'max'],
    testUrls: {
      match: ['https://9hentai.so/g/12345/some-title', 'https://www.9hentai.so/g/1/x'],
      noMatch: ['https://9hentai.so/', 'https://9hentai.com/g/1/x']
    },
    run: async function () {
      const idMatch = window.location.pathname.match(/\d+/);
      const bookId = parseInt(idMatch ? idMatch[0] : '0', 10);
      const api = await fetchJson('/api/getBookByID', {
        method: 'POST',
        body: JSON.stringify({ id: bookId }),
        headers: { 'Content-Type': 'application/json' }
      });
      if (!api || !api.results) {
        throw new Error('9Hentai: /api/getBookByID returned no results');
      }
      const total = api.results.total_page;
      const server = String(api.results.image_server || '').replace('.com', '.so') + api.results.id;
      const listImages = [];
      for (let i = 0; i < total; i++) {
        listImages.push(server + '/' + (i + 1) + '.jpg');
      }
      return {
        title: api.results.title,
        series: '/g/' + api.results.id + '/',
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // AkumaMoe: page globals img_lst / img_prt (wait until every option has its image).
  const siteAkumamoe = {
    name: 'AkumaMoe',
    url: /https?:\/\/(www\.)?akuma\.moe\/g\/.+\/.+/,
    homepage: 'https://akuma.moe',
    language: [Language.RAW],
    category: Category.MANGA,
    waitFunc: function () {
      const list = pageWindow.img_lst;
      if (!Array.isArray(list) || list.length === 0) {
        return false;
      }
      return list.length === queryAll('.reader-nav:first-child .nav-select option').length;
    },
    testUrls: {
      match: ['https://akuma.moe/g/some-title/1/', 'https://www.akuma.moe/g/x/y'],
      noMatch: ['https://akuma.moe/', 'https://akuma.moe/g/']
    },
    run: async function () {
      const list = pageWindow.img_lst;
      const prefix = pageWindow.img_prt;
      if (!Array.isArray(list) || typeof prefix !== 'string') {
        throw new Error('AkumaMoe: page globals img_lst/img_prt not found');
      }
      const slugMatch = window.location.pathname.match(/\/g\/([^/]+)\//);
      const listImages = sitesAImageList(list.map(function (img) {
        return prefix + '/' + img;
      }));
      return {
        title: queryText('h1.sr-only').replace(/^Reading /, ''),
        series: 'https://akuma.moe/g/' + (slugMatch ? slugMatch[1] : '') + '/',
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // BestPornComix: gallery links (<figure><a href>) are the full images.
  const siteBestporncomix = {
    name: 'BestPornComix',
    url: /https?:\/\/(www\.)?bestporncomix\.com\/gallery\/.+/,
    homepage: 'https://www.bestporncomix.com',
    language: [Language.ENGLISH],
    category: Category.COMIC,
    waitTime: 5000,
    testUrls: {
      match: ['https://bestporncomix.com/gallery/some-comic/', 'https://www.bestporncomix.com/gallery/x'],
      noMatch: ['https://bestporncomix.com/', 'https://bestporncomix.com/gallery']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('figure a').map(function (link) {
        return readAttribute(link, 'href');
      }));
      return {
        title: queryText('h1.entry-title'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // DoujinMoeNM: .doujin elements carry the file in data-file.
  const siteDoujinmoe = {
    name: 'DoujinMoeNM',
    url: /https?:\/\/(www\.)?doujins\.com\/.+/,
    homepage: 'https://doujins.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '.doujin',
    testUrls: {
      match: ['https://doujins.com/abc123', 'https://www.doujins.com/x/y'],
      noMatch: ['https://doujins.com/', 'https://example.com/abc']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('.doujin').map(function (img) {
        return readAttribute(img, 'data-file');
      }));
      const titles = queryAll('.folder-title a');
      return {
        title: readText(titles[titles.length - 1]).trim(),
        series: readAttribute(titles[titles.length - 2], 'href'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // Dragon Translation: chapter images, minus the discord banner.
  const siteDragontranslation = {
    name: 'Dragon Translation',
    url: /https?:\/\/(www\.)?dragontranslation\.net\/leer\/.+/,
    homepage: 'https://dragontranslation.net/es',
    language: [Language.SPANISH],
    category: Category.MANGA,
    waitEle: '#chapter_imgs img',
    testUrls: {
      match: ['https://dragontranslation.net/leer/some-title/1', 'https://www.dragontranslation.net/leer/x'],
      noMatch: ['https://dragontranslation.net/', 'https://dragontranslation.net/leer']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('#chapter_imgs img').map(function (img) {
        return readAttribute(img, 'src');
      }).filter(function (src) {
        return src !== '' && src !== '/discord2.jpg';
      }));
      const prevIcon = query('.fa-chevron-circle-left');
      const nextIcon = query('.fa-chevron-circle-right');
      return {
        title: queryText('h1'),
        series: queryAttribute('h2 + div a', 'href'),
        pages: listImages.length,
        prev: prevIcon && prevIcon.parentElement ? readAttribute(prevIcon.parentElement, 'href') : '',
        next: nextIcon && nextIcon.parentElement ? readAttribute(nextIcon.parentElement, 'href') : '',
        listImages: listImages
      };
    }
  };

  // ExHentai/e-Hentai: gallery index pages give listPages; each page needs a fresh nl= token (reload).
  const siteExhentai = {
    name: ['ExHentai', 'e-Hentai'],
    url: /https?:\/\/(g\.)?(exhentai|e-hentai)\.org\/s\/.+\/.+/,
    homepage: ['https://exhentai.org/', 'https://e-hentai.org/'],
    language: [Language.ENGLISH],
    obs: 'May get your IP Banned, use with moderation',
    category: Category.MANGA,
    testUrls: {
      match: ['https://e-hentai.org/s/abc123/456-7', 'https://g.exhentai.org/s/x/1-1'],
      noMatch: ['https://e-hentai.org/', 'https://e-hentai.org/g/123/abc']
    },
    run: async function () {
      const num = parseInt(queryText('.sn div span:nth-child(2)') || '0', 10);
      if (!(num > 0)) {
        throw new Error('ExHentai: page count not found');
      }
      const gallery = queryAttribute('.sb a', 'href').replace(/\?p=\d+/, '');
      if (gallery === '') {
        throw new Error('ExHentai: gallery link not found');
      }
      const galleryCount = Math.ceil(num / 20);
      // FIX (vs the first port): the gallery index pages are fetched in parallel (as the reference
      // script does with Promise.all). A sequential await loop made startup take ceil(num/20)
      // round-trips added together before the reader could open.
      const galleryDocs = await Promise.all(sequence(galleryCount).map(function (pageIndex) {
        return fetchDocument(gallery + '?p=' + (pageIndex - 1));
      }));
      let listPages = [];
      galleryDocs.forEach(function (doc) {
        queryAll('#gdt a', doc).forEach(function (item) {
          listPages.push(readAttribute(item, 'href'));
        });
      });
      listPages = uniqueValues(listPages.filter(function (href) {
        return href !== '';
      }));
      return {
        title: queryText('#i1 h1'),
        series: gallery,
        pages: num,
        begin: parseInt(queryText('div#i2 span') || '1', 10),
        prev: '#',
        next: '#',
        listPages: listPages,
        img: '#img',
        lazy: true,
        reload: async function (page) {
          const oldUrl = String(listPages[page - 1] || '');
          const firstHtml = await fetchTextOrThrow(oldUrl);
          const slugMatch = firstHtml.match(/nl\('([\d-]+)'\)/);
          if (!slugMatch) {
            throw new Error('ExHentai: nl token not found on ' + oldUrl);
          }
          // FIX (vs the reference): it used indexOf('?') as a boolean (-1 is truthy), so it always
          // joined with '&'; join with '?' when the URL has no query string yet.
          const separator = oldUrl.indexOf('?') === -1 ? '?' : '&';
          const refreshed = await fetchDocument(oldUrl + separator + 'nl=' + slugMatch[1]);
          const image = refreshed.querySelector('#img');
          const source = image ? readAttribute(image, 'src') : '';
          if (source === '') {
            throw new Error('ExHentai: #img not found after token refresh');
          }
          return source;
        }
      };
    }
  };

  // FreeAdultComix: <figure> images.
  const siteFreeadultcomix = {
    name: 'FreeAdultComix',
    url: /https?:\/\/(www\.)?freeadultcomix\.com\/.+/,
    homepage: 'https://www.freeadultcomix.com',
    language: [Language.ENGLISH],
    category: Category.COMIC,
    waitTime: 5000,
    testUrls: {
      match: ['https://www.freeadultcomix.com/some-comic/', 'https://freeadultcomix.com/x'],
      noMatch: ['https://www.freeadultcomix.com/', 'https://example.com/x']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('figure img').map(function (img) {
        return readAttribute(img, 'src');
      }));
      return {
        title: queryText('.post-conteudo h1'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // FSIComics: WordPress gallery (data-large-file, src as fallback).
  const siteFsicomics = {
    name: 'FSIComics',
    url: /https?:\/\/(www\.)?fsicomics\.com\/.+/,
    homepage: 'https://fsicomics.com/',
    language: [Language.ENGLISH],
    category: Category.COMIC,
    testUrls: {
      match: ['https://fsicomics.com/some-comic/', 'https://www.fsicomics.com/x/y'],
      noMatch: ['https://fsicomics.com/', 'https://example.com/x']
    },
    run: function () {
      // FIX (vs the reference): it read data-large-file only; src fallback keeps pages whose
      // markup lacks the large-file attribute instead of failing them.
      const listImages = sitesAImageList(queryAll('.wp-block-gallery img').map(function (img) {
        return readFirstAttribute(img, ['data-large-file', 'src']);
      }));
      return {
        title: queryText('.s-title'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // GNTAI.net: image list inside #main > script ("var pages = [...]").
  const siteGntai = {
    name: 'GNTAI.net',
    url: /https?:\/\/(www\.)?gntai\.net\/(?!(category|tags|autores))[^/]+\/.+/,
    homepage: 'https://www.gntai.net/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://www.gntai.net/some-gallery/1/', 'https://gntai.net/x/y'],
      noMatch: ['https://www.gntai.net/category/x/', 'https://www.gntai.net/tags/y/', 'https://www.gntai.net/']
    },
    run: function () {
      const scriptText = queryText('#main > script');
      const pagesMatch = scriptText.match(/var pages = [^;]+/);
      const found = pagesMatch ? pagesMatch[0].match(/https?[^"]+/g) : null;
      const listImages = sitesAImageList(found || []);
      return {
        title: queryText('.entry-header h1'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // HDoujin: reader state in history.state.memo (same engine as SchaleNetwork).
  const siteHdoujin = {
    name: 'HDoujin',
    url: /https?:\/\/(www\.)?hdoujin\.org\/.+/,
    homepage: 'https://hdoujin.org/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'nav select option',
    testUrls: {
      match: ['https://hdoujin.org/g/12345/abc/', 'https://www.hdoujin.org/x'],
      noMatch: ['https://hdoujin.org/', 'https://example.com/x']
    },
    run: function () {
      const memo = history.state ? history.state.memo : null;
      const gallery = memo ? memo.gallery : null;
      const data = memo ? memo.data : null;
      if (!gallery || !data || !Array.isArray(data.entries)) {
        throw new Error('HDoujin: history.state.memo.gallery/data.entries not found');
      }
      const size = gallery.resolution;
      const listImages = sitesAImageList(data.entries.map(function (image) {
        return data.base + '/' + image.path + '?w=' + size;
      }));
      return {
        title: gallery.title,
        series: '/g/' + gallery.id + '/' + gallery.key + '/',
        pages: listImages.length,
        prev: '#',
        next: '#',
        fetchOptions: {
          method: 'GET',
          redirect: 'follow'
        },
        listImages: listImages
      };
    }
  };

  // Hentai2Read: page global gData (image paths are CDN-relative).
  const siteHentai2read = {
    name: 'Hentai2Read',
    url: /https?:\/\/(www\.)?hentai2read\.com\/[^/]+\/\d+(.\d+)?\//,
    homepage: 'https://hentai2read.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://hentai2read.com/some-manga/1/', 'https://www.hentai2read.com/x/2.5/'],
      noMatch: ['https://hentai2read.com/', 'https://hentai2read.com/some-manga/']
    },
    run: function () {
      const gData = pageWindow.gData;
      if (!gData || !Array.isArray(gData.images)) {
        throw new Error('Hentai2Read: page global gData.images not found');
      }
      const listImages = sitesAImageList(gData.images.map(function (path) {
        return 'https://static.hentaicdn.com/hentai' + path;
      }));
      return {
        title: queryText('.reader-left-text'),
        series: String(gData.mainURL || ''),
        pages: listImages.length,
        prev: String(gData.previousURL || ''),
        next: String(gData.nextURL || ''),
        listImages: listImages
      };
    }
  };

  // HentaiEra: one page per URL (listPages + #gimg, lazy data-src).
  const siteHentaiera = {
    name: 'HentaiEra',
    url: /https?:\/\/(www\.)?hentaiera\.com\/view\/.+\/\d+\/?/,
    homepage: 'https://hentaiera.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://hentaiera.com/view/some-title/3/', 'https://www.hentaiera.com/view/x/1'],
      noMatch: ['https://hentaiera.com/', 'https://hentaiera.com/view/x/']
    },
    run: function () {
      const num = parseInt(queryText('.total_pages') || '0', 10);
      if (!(num > 0)) {
        throw new Error('HentaiEra: page count not found');
      }
      const base = window.location.href.replace(/\/\d*\/?$/, '');
      const listPages = [];
      for (let i = 0; i < num; i++) {
        listPages.push(base + '/' + (i + 1));
      }
      return {
        title: queryText('h1').replace(/ - Page .+$/, ''),
        series: queryAttribute('.return_btn', 'href'),
        pages: num,
        prev: '#',
        next: '#',
        listPages: listPages,
        img: '#gimg',
        lazyAttr: 'data-src'
      };
    }
  };

  // HentaiForce: page global readerPages (baseUriImg has %c = folder and %s = file).
  const siteHentaiforce = {
    name: 'HentaiForce',
    url: /https?:\/\/(www\.)?hentaiforce\.net\/view\/.+\/\d+/,
    homepage: 'https://hentaiforce.net/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://hentaiforce.net/view/some-title/3', 'https://www.hentaiforce.net/view/x/1'],
      noMatch: ['https://hentaiforce.net/', 'https://hentaiforce.net/view/x/']
    },
    run: function () {
      const readerPages = pageWindow.readerPages;
      if (!readerPages || typeof readerPages.lastPage !== 'number' || !readerPages.pages) {
        throw new Error('HentaiForce: page global readerPages not found');
      }
      const template = String(readerPages.baseUriImg || '');
      const listImages = [];
      for (let i = 1; i <= readerPages.lastPage; i++) {
        const page = readerPages.pages[i] || {};
        listImages.push(template.replace('%c', page.l || '').replace('%s', page.f || ''));
      }
      const clean = sitesAImageList(listImages);
      return {
        title: queryText('h1').replace(/ - Page .+$/, ''),
        series: queryAttribute('.reader-go-back', 'href'),
        pages: clean.length,
        prev: '#',
        next: '#',
        listImages: clean
      };
    }
  };

  // HentaiFox: page count from .total_pages, URLs built from #gimg + g_th type codes.
  const siteHentaifox = {
    name: 'HentaiFox',
    url: /https?:\/\/(www\.)?hentaifox\.com\/g\/.+/,
    homepage: 'https://www.hentaifox.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'g_th',
    waitFunc: function () {
      const image = query('#gimg');
      return image !== null && image.classList.contains('loaded');
    },
    testUrls: {
      match: ['https://hentaifox.com/g/12345-some-title/1/', 'https://www.hentaifox.com/g/x/'],
      noMatch: ['https://hentaifox.com/', 'https://hentaifox.com/g/']
    },
    run: function () {
      const num = parseInt(queryText('.total_pages') || '', 10);
      if (!(num > 0)) {
        throw new Error('HentaiFox: page count not found');
      }
      const table = pageWindow.g_th;
      const first = queryAttribute('#gimg', 'src').replace(/\d+\.\w+$/, '');
      const listImages = [];
      for (let i = 1; i <= num; i++) {
        const row = table ? table[i] : null;
        const code = row ? row[0] : '';
        listImages.push(first + i + '.' + extensionByTypeCode(code));
      }
      const cleanImages = sitesAImageList(listImages);
      return {
        title: queryText('title').replace(/ - Page .+/, '').trim(),
        series: queryAttribute('.browse_buttons a', 'href'),
        pages: cleanImages.length,
        prev: '#',
        next: '#',
        listImages: cleanImages
      };
    }
  };

  // HentaiHand/nHentai.com: DOM images first, then gallery-page thumbnails, then URL pattern.
  const siteHentaihand = {
    name: ['HentaiHand', 'nHentai.com'],
    url: /https?:\/\/(www\.)?(hentaihand|nhentai)\.com\/(?:[a-z]{2}\/)?(?:comic|reader)\/.+\/\d+/,
    homepage: ['https://hentaihand.com/', 'https://nhentai.com/'],
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '.reader img, #reader img, .comic-page img, .page-image img, #image-container img, .pages-selector, .comic-title',
    testUrls: {
      match: ['https://hentaihand.com/comic/some-title/1/', 'https://nhentai.com/reader/abc/5/'],
      noMatch: ['https://hentaihand.com/', 'https://nhentai.com/comic/x/']
    },
    run: async function () {
      const seriesLink = query('.reader-header h5 a, .comic-title a, a.back-to-gallery, a.go-back, a[href*="/comic/"]');
      const series = readAttribute(seriesLink, 'href') !== '' ? readAttribute(seriesLink, 'href') : window.location.href.replace(/\/\d+\/?$/, '');
      const titleElement = query('.reader-header h5, .comic-title, h1.title, h1');
      const title = titleElement ? readText(titleElement) : queryText('title').replace(/\s*-\s*Page.*$/i, '').trim();
      const domImages = queryAll('.reader img, #reader img, .comic-page img, .page-image img, .reader-images img');
      if (domImages.length > 1) {
        const listImages = sitesAImageList(domImages.map(function (img) {
          return readFirstAttribute(img, ['data-src', 'src']);
        }));
        return {
          title: title,
          series: series,
          pages: listImages.length,
          prev: '#',
          next: '#',
          listImages: listImages
        };
      }
      try {
        const fullSeriesUrl = series.indexOf('http') === 0 ? series : window.location.origin + series;
        const galleryDocument = await fetchDocument(fullSeriesUrl);
        const containers = queryAll('.comic-thumb, .gallery-thumb, .thumbnails > div, #thumbnail-container .thumb-container, .thumbs > div, .grid > div', galleryDocument);
        if (containers.length > 0) {
          const listImages = sitesAImageList(containers.map(function (container) {
            const img = container.querySelector('img');
            return (img ? readFirstAttribute(img, ['data-src', 'src']) : '').replace(/\/thumb\//, '/image/').replace(/t\.([a-zA-Z0-9]+)$/, '.$1');
          }));
          const galleryTitle = galleryDocument.querySelector('h1.title, h1, .comic-title');
          return {
            title: galleryTitle ? galleryTitle.textContent.trim() : title,
            series: series,
            pages: listImages.length,
            prev: '#',
            next: '#',
            listImages: listImages
          };
        }
      } catch (ignored) {
        logDebug('HentaiHand: gallery thumbnail fetch failed, trying the URL pattern');
      }
      const optionCount = queryAll('select.pages-selector option, select.page-select option, .page-dropdown option').length;
      const firstDom = domImages[0] ? readFirstAttribute(domImages[0], ['data-src', 'src']) : queryAttribute('#image-container img, .reader img', 'src');
      const pattern = firstDom.match(/^(.*\/)(0*1)(\.[a-zA-Z0-9]+)(?:\?.*)?$/);
      const total = optionCount > 0 ? optionCount : (domImages.length > 0 ? domImages.length : 1);
      if (pattern && total > 1) {
        const pad = pattern[2].length;
        const listImages = [];
        for (let i = 1; i <= total; i++) {
          let padded = String(i);
          while (padded.length < pad) {
            padded = '0' + padded;
          }
          listImages.push(pattern[1] + padded + pattern[3]);
        }
        return {
          title: title,
          series: series,
          pages: listImages.length,
          prev: '#',
          next: '#',
          listImages: listImages
        };
      }
      const fallback = sitesAImageList(domImages.map(function (img) {
        return readFirstAttribute(img, ['data-src', 'src']);
      }));
      return {
        title: title,
        series: series,
        pages: fallback.length,
        prev: '#',
        next: '#',
        listImages: fallback
      };
    }
  };

  // HentaIHere: page globals rff_imageList / rff_pageTitle (needs waitVar).
  const siteHentaihere = {
    name: 'HentaIHere',
    url: /https?:\/\/(www\.)?hentaihere\.com\/.+\/.+\/.+/,
    homepage: 'https://www.hentaihere.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'rff_imageList',
    testUrls: {
      match: ['https://www.hentaihere.com/m/some-title/1/', 'https://hentaihere.com/a/b/c'],
      noMatch: ['https://www.hentaihere.com/', 'https://www.hentaihere.com/m/x/']
    },
    run: function () {
      const imageList = pageWindow.rff_imageList;
      if (!Array.isArray(imageList) || imageList.length === 0) {
        throw new Error('HentaIHere: page global rff_imageList not found');
      }
      const prefix = queryAttribute('#arf-reader-img', 'src').replace(/\d.+/, '');
      const listImages = sitesAImageList(imageList.map(function (img) {
        return prefix + img;
      }));
      return {
        title: String(pageWindow.rff_pageTitle || '').replace(/.+\|/, '').trim(),
        series: String(pageWindow.rff_thisManga || ''),
        pages: listImages.length,
        prev: String(pageWindow.rff_previousChapter || ''),
        next: String(pageWindow.rff_nextChapter || ''),
        listImages: listImages
      };
    }
  };

  // HentaiNexus: page global pageData (avif preferred, fallback chain per page).
  const siteHentainexus = {
    name: 'HentaiNexus',
    url: /https?:\/\/(www\.)?hentainexus\.com\/read\/.+/,
    homepage: 'https://hentainexus.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'pageData',
    testUrls: {
      match: ['https://hentainexus.com/read/12345', 'https://www.hentainexus.com/read/x'],
      noMatch: ['https://hentainexus.com/', 'https://example.com/read/1']
    },
    run: function () {
      const pageData = pageWindow.pageData;
      if (!Array.isArray(pageData) || pageData.length === 0) {
        throw new Error('HentaiNexus: page global pageData not found');
      }
      const listImages = sitesAImageList(pageData.map(function (item) {
        if (item) {
          if (item.image_avif !== null && item.image_avif !== undefined) {
            return item.image_avif;
          }
          if (item.image_fallback !== null && item.image_fallback !== undefined) {
            return item.image_fallback;
          }
          return item.image;
        }
        return '';
      }));
      return {
        title: queryText('title').replace(/^\[[\d/]+\]/, '').trim(),
        series: queryAttribute('#returnGalleryFooter a', 'href'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // HenTalk: SvelteKit __data.json payload (fragile indexes, guarded with a clear error).
  const siteHentalk = {
    name: 'HenTalk',
    // FIX (vs the reference): it left the dot in '(www.)' unescaped, matching e.g. 'wwwXhentalk'.
    url: /https?:\/\/(www\.)?hentalk\.pw/,
    homepage: 'https://hentalk.pw/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://hentalk.pw/read/abc', 'https://www.hentalk.pw/x'],
      noMatch: ['https://example.com/', 'https://hentalkXpw/']
    },
    run: async function () {
      const payload = await fetchJson(window.location.pathname + '/__data.json?x-sveltekit-trailing-slash=1&x-sveltekit-invalidated=001');
      if (!payload || !Array.isArray(payload.nodes) || !payload.nodes[2] || !payload.nodes[2].data) {
        throw new Error('HenTalk: __data.json has no nodes[2].data');
      }
      const api = payload.nodes[2].data;
      const galleryRef = api.find(function (entry) {
        return entry && entry.gallery;
      });
      const gallery = galleryRef ? api[galleryRef.gallery] : null;
      if (!gallery || !gallery.images) {
        throw new Error('HenTalk: gallery reference not found in __data.json');
      }
      const hashRef = api.find(function (entry) {
        return entry && entry.hash && entry.id;
      });
      const slug = api[gallery.hash] || (hashRef ? api[hashRef.hash] : null);
      const imageRefs = api[gallery.images];
      if (!slug || !Array.isArray(imageRefs)) {
        throw new Error('HenTalk: image list reference not found in __data.json');
      }
      const listImages = sitesAImageList(imageRefs.map(function (ref) {
        const image = api[ref];
        const file = image ? api[image.filename] : null;
        const fileName = (file && file.filename) || file;
        return typeof fileName === 'string' ? 'https://hentalk.pw/image/' + slug + '/' + fileName : '';
      }));
      return {
        title: api[gallery.title],
        series: window.location.href.replace(/read\/.+/, ''),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // Hitomi: page globals galleryinfo + url_from_url_from_hash.
  const siteHitomi = {
    name: 'Hitomi',
    url: /https?:\/\/hitomi\.la\/reader\/.+/,
    homepage: 'https://hitomi.la/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitAttr: ['#comicImages img', 'src'],
    waitVar: 'galleryinfo',
    testUrls: {
      match: ['https://hitomi.la/reader/12345.html', 'https://hitomi.la/reader/abc.html#1'],
      noMatch: ['https://hitomi.la/', 'https://hitomi.la/galleries/1.html']
    },
    run: function () {
      const gallery = pageWindow.galleryinfo;
      const hashToUrl = pageWindow.url_from_url_from_hash;
      if (!gallery || !Array.isArray(gallery.files) || typeof hashToUrl !== 'function') {
        throw new Error('Hitomi: page globals galleryinfo/url_from_url_from_hash not found');
      }
      const listImages = sitesAImageList(gallery.files.map(function (file) {
        return hashToUrl(gallery, file, 'webp');
      }));
      return {
        title: queryText('title').replace('| Hitomi.la', '').trim(),
        series: queryAttribute('.brand', 'href'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // Imhentai: hidden inputs (gallery, dir, count) + g_th type codes + random_server host.
  const siteImhentai = {
    name: 'Imhentai',
    url: /https?:\/\/(www\.)?imhentai\.xxx\/view\/.+\/.+\//,
    homepage: 'https://imhentai.xxx/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitVar: 'g_th',
    testUrls: {
      match: ['https://imhentai.xxx/view/some-title/1/', 'https://www.imhentai.xxx/view/x/y/'],
      noMatch: ['https://imhentai.xxx/', 'https://imhentai.xxx/view/x/']
    },
    run: async function () {
      const galleryId = queryAttribute('#gallery_id', 'value');
      const imageDir = queryAttribute('#image_dir', 'value');
      const num = parseInt(queryAttribute('#pages', 'value') || '', 10);
      const server = pageWindow.random_server;
      if (galleryId === '' || imageDir === '' || !(num > 0) || typeof server !== 'string' || server === '') {
        throw new Error('Imhentai: gallery inputs or page global random_server not found');
      }
      const table = pageWindow.g_th;
      const listImages = [];
      for (let i = 1; i <= num; i++) {
        const row = table ? table[i] : null;
        listImages.push('//' + server + '/' + imageDir + '/' + galleryId + '/' + i + '.' + extensionByTypeCode(row ? row[0] : ''));
      }
      // sitesAImageList turns the protocol-relative '//host/...' entries into absolute URLs (the
      // ZIP download requests them through the userscript manager, which needs a full URL).
      const cleanImages = sitesAImageList(listImages);
      return {
        title: queryText('title'),
        series: queryAttribute('.return_btn', 'href'),
        pages: cleanImages.length,
        prev: '#',
        next: '#',
        listImages: cleanImages
      };
    }
  };

  // KingComix/Chochox/Comics18: WordPress article images (lazy attributes first).
  const siteKingcomix = {
    name: ['KingComix', 'Chochox', 'Comics18'],
    url: /https?:\/\/(www\.)?(kingcomix|chochox|comics18)\.(com|org)\/.+/,
    homepage: ['https://kingcomix.com/', 'https://chochox.com/porno/', 'https://comics18.org/'],
    language: [Language.ENGLISH, Language.SPANISH],
    category: Category.COMIC,
    testUrls: {
      match: ['https://kingcomix.com/some-comic/', 'https://chochox.com/porno/x/', 'https://comics18.org/y/'],
      noMatch: ['https://kingcomix.com/', 'https://example.com/x/']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('figure img, .entry-content img:not(a img), .wp-content img').map(function (img) {
        return readFirstAttribute(img, ['data-src', 'data-full-url', 'data-lazy-src', 'src']);
      }));
      return {
        title: queryText('h1.singleTitle-h1'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // Luscious: album GraphQL API on another host (manager request, not page fetch, for CORS).
  const siteLuscious = {
    name: 'Luscious',
    url: /https?:\/\/(www\.)?luscious\.net\/.+\/read\/.+/,
    homepage: 'https://luscious.net/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '.album-info div',
    testUrls: {
      match: ['https://luscious.net/albums/x_123/read/1', 'https://www.luscious.net/c/y_1/read/2'],
      noMatch: ['https://luscious.net/', 'https://luscious.net/albums/x_123/']
    },
    run: async function () {
      const countText = queryText('input[name="page_number"] + span');
      const countMatch = countText ? countText.match(/\d+/) : null;
      const num = parseInt(countMatch ? countMatch.pop() : '0', 10);
      if (!(num > 0)) {
        throw new Error('Luscious: page count not found');
      }
      const albumHref = queryAttribute('.album-heading a', 'href');
      const idMatch = albumHref ? albumHref.match(/\d+\//) : null;
      const albumId = parseInt(idMatch ? idMatch.toString() : '0', 10);
      if (!(albumId > 0)) {
        throw new Error('Luscious: album id not found');
      }
      const graphqlQuery = '&query=%20query%20PictureListInsideAlbum(%24input%3A%20PictureListInput!)%20%7B%20picture%20%7B%20list(input%3A%20%24input)%20%7B%20info%20%7B%20...FacetCollectionInfo%20%7D%20items%20%7B%20__typename%20id%20title%20description%20created%20like_status%20number_of_comments%20number_of_favorites%20moderation_status%20width%20height%20resolution%20aspect_ratio%20url_to_original%20url_to_video%20is_animated%20position%20permissions%20url%20tags%20%7B%20category%20text%20url%20%7D%20thumbnails%20%7B%20width%20height%20size%20url%20%7D%20%7D%20%7D%20%7D%20%7D%20fragment%20FacetCollectionInfo%20on%20FacetCollectionInfo%20%7B%20page%20has_next_page%20has_previous_page%20total_items%20total_pages%20items_per_page%20url_complete%20%7D%20';
      const blockCount = Math.ceil(num / 50);
      // Same fix as ExHentai: the album blocks are independent, so they are fetched in parallel
      // (as the reference script does with Promise.all), not one per round-trip.
      const blockUrls = [];
      for (let block = 0; block < blockCount; block++) {
        blockUrls.push('https://apicdn.luscious.net/graphql/nobatch/?operationName=PictureListInsideAlbum&variables={"input":{"filters":[{"name":"album_id","value":"' + albumId + '"}],"display":"position","items_per_page":50,"page":' + (block + 1) + '}}' + graphqlQuery);
      }
      const blocks = await Promise.all(blockUrls.map(function (apiUrl) {
        return fetchJsonViaManager(apiUrl, { method: 'GET' });
      }));
      let listImages = [];
      blocks.forEach(function (answer) {
        const items = siteCReadPath(answer, ['data', 'picture', 'list', 'items']);
        if (Array.isArray(items)) {
          listImages = listImages.concat(items.map(function (img) {
            return img ? img.url_to_original : '';
          }));
        }
      });
      listImages = sitesAImageList(listImages);
      if (listImages.length === 0) {
        throw new Error('Luscious: GraphQL answers contain no images');
      }
      return {
        title: queryText('.album-heading a'),
        series: queryAttribute('.album-heading a', 'href'),
        pages: num,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // MultPorn: player config URL in <head>, image list as XML.
  const siteMultporn = {
    name: 'MultPorn',
    url: /https?:\/\/(www\.)?multporn\.net\/(comics|hentai_manga)\/.+/,
    homepage: 'https://multporn.net/',
    language: [Language.ENGLISH],
    category: Category.COMIC,
    testUrls: {
      match: ['https://multporn.net/comics/some-title', 'https://www.multporn.net/hentai_manga/x'],
      noMatch: ['https://multporn.net/', 'https://multporn.net/tags/x']
    },
    run: async function () {
      const headText = queryText('head');
      const configMatch = headText.match(/"configUrl":"(.+?)",/);
      const configUrl = configMatch ? configMatch[1].replace(/\\/g, '') : '';
      if (configUrl === '') {
        throw new Error('MultPorn: configUrl not found in <head>');
      }
      const xmlText = await fetchTextOrThrow(configUrl);
      const xml = parseHtml(xmlText, 'text/xml');
      const listImages = sitesAImageList(queryAll('image', xml).map(function (img) {
        return readAttribute(img, 'imageURL');
      }));
      return {
        title: queryText('#page-title'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // MyHentaiGallery: one page per URL (listPages + .gallery-slide img).
  const siteMyhentaigallery = {
    name: 'MyHentaiGallery',
    url: /https?:\/\/(www\.)?myhentaigallery\.com\/g\/.+\/\d+/,
    homepage: 'https://www.myhentaigallery.com',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://www.myhentaigallery.com/g/some-title/1', 'https://myhentaigallery.com/g/x/2'],
      noMatch: ['https://www.myhentaigallery.com/', 'https://www.myhentaigallery.com/g/x/']
    },
    run: function () {
      const nextItem = query('#js__pagination__next');
      const lastLink = nextItem && nextItem.parentElement ? nextItem.parentElement.previousElementSibling : null;
      const lastAnchor = lastLink ? lastLink.querySelector('a') : null;
      const num = parseInt(lastAnchor ? readText(lastAnchor) : '', 10);
      if (!(num > 0)) {
        throw new Error('MyHentaiGallery: page count not found');
      }
      const base = window.location.href.replace(/\/\d+$/, '');
      const listPages = [];
      for (let i = 1; i <= num; i++) {
        listPages.push(base + '/' + i);
      }
      return {
        title: queryText('title'),
        series: queryAttribute('.back-to-gallery a', 'href'),
        pages: num,
        prev: '#',
        next: '#',
        listPages: listPages,
        img: '.gallery-slide img'
      };
    }
  };

  // nHentai.net: API first (media_id + type codes), then v2 API, gallery thumbnails, URL pattern.
  const siteNhentaiNet = {
    name: 'nHentai.net',
    url: /https?:\/\/(www\.)?nhentai\.net\/g\/.+/,
    homepage: 'https://nhentai.net/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#image-container img',
    testUrls: {
      match: ['https://nhentai.net/g/12345/1/', 'https://www.nhentai.net/g/abc/'],
      noMatch: ['https://nhentai.net/', 'https://nhentai.net/search/?q=x']
    },
    run: async function () {
      const parts = window.location.pathname.split('/');
      const galleryId = parts[2] || '';
      const backLink = query('.go-back, .back_btn');
      const series = readAttribute(backLink, 'href') !== '' ? readAttribute(backLink, 'href') : '/g/' + galleryId + '/';
      const title = queryText('title').split('- Page')[0].trim();
      try {
        const api = await fetchJson('https://nhentai.net/api/gallery/' + galleryId);
        if (api && api.media_id && api.images && Array.isArray(api.images.pages)) {
          const listImages = sitesAImageList(api.images.pages.map(function (page, i) {
            return 'https://i.nhentai.net/galleries/' + api.media_id + '/' + (i + 1) + '.' + (GALLERY_API_IMAGE_EXT[page.t] || 'jpg');
          }));
          const pretty = api.title ? (api.title.pretty || api.title.english) : '';
          return {
            title: pretty || title,
            series: series,
            pages: listImages.length,
            prev: '#',
            next: '#',
            listImages: listImages
          };
        }
      } catch (ignored) {
        logDebug('nHentai.net: v1 API failed, trying v2');
      }
      try {
        const cdn = await fetchJson('https://nhentai.net/api/v2/config');
        const gallery = await fetchJson('https://nhentai.net/api/v2/galleries/' + galleryId);
        if (gallery && Array.isArray(gallery.pages) && cdn && Array.isArray(cdn.image_servers) && cdn.image_servers.length > 0) {
          const listImages = sitesAImageList(gallery.pages.map(function (img) {
            const host = cdn.image_servers[Math.floor(Math.random() * cdn.image_servers.length)];
            return host + '/' + img.path;
          }));
          return {
            title: title,
            series: series,
            pages: listImages.length,
            prev: '#',
            next: '#',
            listImages: listImages
          };
        }
      } catch (ignored2) {
        logDebug('nHentai.net: v2 API failed, trying gallery thumbnails');
      }
      try {
        const galleryDocument = await fetchDocument('https://nhentai.net/g/' + galleryId + '/');
        const containers = queryAll('#thumbnail-container .thumb-container', galleryDocument);
        if (containers.length > 0) {
          const listImages = sitesAImageList(containers.map(function (container) {
            const img = container.querySelector('img');
            return (img ? readFirstAttribute(img, ['data-src', 'src']) : '').replace(/\/\/t(\d*)\.nhentai\.net/, '//i$1.nhentai.net').replace(/t\.([a-zA-Z0-9]+)$/, '.$1');
          }));
          const infoTitle = galleryDocument.querySelector('#info h1');
          return {
            title: infoTitle ? infoTitle.textContent.trim() : title,
            series: series,
            pages: listImages.length,
            prev: '#',
            next: '#',
            listImages: listImages
          };
        }
      } catch (ignored3) {
        logDebug('nHentai.net: gallery fetch failed, trying the URL pattern');
      }
      const firstSrc = queryAttribute('#image-container img', 'src');
      const pattern = firstSrc.match(/^(https?:\/\/.*\/galleries\/[^/]+\/)\d+\.([a-zA-Z0-9]+)$/);
      const countText = queryText('.num-pages');
      const total = parseInt(countText || '0', 10) || 1;
      const listImages = pattern ? [] : [firstSrc];
      if (pattern) {
        for (let i = 1; i <= total; i++) {
          listImages.push(pattern[1] + i + '.' + pattern[2]);
        }
      }
      return {
        title: title,
        series: series,
        pages: pattern ? total : listImages.length,
        prev: '#',
        next: '#',
        listImages: sitesAImageList(listImages)
      };
    }
  };

  // nHentai.to: gallery thumbnails first, then the URL pattern.
  const siteNhentaiTo = {
    name: 'nHentai.to',
    url: /https?:\/\/(www\.)?nhentai\.to\/g\/\d+\/\d+/,
    homepage: 'https://nhentai.to/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#image-container img',
    testUrls: {
      match: ['https://nhentai.to/g/12345/1/', 'https://www.nhentai.to/g/1/2/'],
      noMatch: ['https://nhentai.to/', 'https://nhentai.to/g/12345/']
    },
    run: async function () {
      const backLink = query('.back_btn, .go-back');
      const backHref = readAttribute(backLink, 'href');
      const seriesUrl = backHref !== '' ? backHref : window.location.pathname.replace(/\/\d+\/?$/, '/');
      const firstSrc = queryAttribute('#image-container img', 'src');
      const countText = queryText('.num-pages');
      const total = parseInt(countText || '0', 10);
      try {
        const fullSeriesUrl = seriesUrl.indexOf('http') === 0 ? seriesUrl : window.location.origin + seriesUrl;
        const galleryDocument = await fetchDocument(fullSeriesUrl);
        const containers = queryAll('#thumbnail-container .thumb-container', galleryDocument);
        if (containers.length > 0) {
          const listImages = sitesAImageList(containers.map(function (container) {
            const img = container.querySelector('img');
            return (img ? readFirstAttribute(img, ['data-src', 'src']) : '').replace(/t\.([a-zA-Z0-9]+)$/, '.$1');
          }));
          const infoTitle = galleryDocument.querySelector('#info h1');
          const pageTitle = queryText('title').split('> Page')[0].trim();
          return {
            title: infoTitle ? infoTitle.textContent.trim() : pageTitle,
            series: seriesUrl,
            pages: listImages.length,
            prev: '#',
            next: '#',
            listImages: listImages
          };
        }
      } catch (ignored) {
        logDebug('nHentai.to: gallery fetch failed, trying the URL pattern');
      }
      const pattern = firstSrc.match(/^(https?:\/\/.*\/galleries\/[^/]+\/)\d+\.([a-zA-Z0-9]+)$/);
      const count = total > 0 ? total : 1;
      const listImages = pattern ? [] : [firstSrc];
      if (pattern) {
        for (let i = 1; i <= count; i++) {
          listImages.push(pattern[1] + i + '.' + pattern[2]);
        }
      }
      return {
        title: queryText('title').split('> Page')[0].trim(),
        series: seriesUrl,
        pages: pattern ? count : listImages.length,
        prev: '#',
        next: '#',
        listImages: sitesAImageList(listImages)
      };
    }
  };

  // nHentai.xxx: hidden inputs (server, dir, id, count) + first-image extension.
  const siteNhentaiXxx = {
    name: 'nHentai.xxx',
    url: /https?:\/\/(www\.)?nhentai\.xxx\/g\/\d+\/\d+/,
    homepage: 'https://nhentai.xxx/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#pages, #fimg, .reader_wrap',
    testUrls: {
      match: ['https://nhentai.xxx/g/12345/1/', 'https://www.nhentai.xxx/g/1/2/'],
      noMatch: ['https://nhentai.xxx/', 'https://nhentai.xxx/g/12345/']
    },
    run: function () {
      const serverInput = query('#server_id') || query('#load_server');
      const dirInput = query('#image_dir') || query('#load_dir');
      const idInput = query('#gallery_id') || query('#load_id');
      const pagesInput = query('#pages') || query('#load_pages');
      const server = serverInput ? serverInput.value : '';
      const dir = dirInput ? dirInput.value : '';
      const galleryId = idInput ? idInput.value : '';
      const pagesValue = pagesInput ? pagesInput.value : queryText('.pages_btn .tp');
      const num = parseInt(pagesValue || '0', 10);
      const firstImg = query('#fimg');
      const firstSrc = firstImg ? readFirstAttribute(firstImg, ['data-src', 'src']) : '';
      const extMatch = firstSrc.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/);
      const ext = extMatch ? extMatch[1] : 'webp';
      const base = firstSrc !== '' ? firstSrc.replace(/\/\d+\.[a-zA-Z0-9]+(?:\?.*)?$/, '') : (server !== '' && dir !== '' && galleryId !== '' ? 'https://i' + server + '.nhentaimg.com/' + dir + '/' + galleryId : '');
      if (!(num > 0) || base === '') {
        throw new Error('nHentai.xxx: hidden inputs and first image not found');
      }
      const listImages = [];
      for (let i = 1; i <= num; i++) {
        listImages.push(base + '/' + i + '.' + ext);
      }
      const backLink = query('.back_btn, .go-back');
      return {
        title: queryText('title').split('- Page')[0].trim(),
        series: readAttribute(backLink, 'href') !== '' ? readAttribute(backLink, 'href') : window.location.pathname.replace(/\/\d+\/?$/, '/'),
        pages: num,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // PornComicsHD: Angular reader images (plain DOM query, no page globals).
  const sitePorncomicshd = {
    name: 'PornComicsHD',
    url: /https?:\/\/(www\.)?porncomicshd\.com\/es.*/,
    homepage: 'https://porncomicshd.com/es',
    language: [Language.SPANISH],
    category: Category.COMIC,
    waitEle: 'app-comic-reader img',
    testUrls: {
      match: ['https://porncomicshd.com/es/comic/x', 'https://www.porncomicshd.com/es/y'],
      noMatch: ['https://porncomicshd.com/', 'https://porncomicshd.com/en/x']
    },
    run: async function () {
      const listImages = sitesAImageList(queryAll('app-comic-reader img').map(function (img) {
        return readAttribute(img, 'src');
      }));
      return {
        title: queryText('h1'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        lazy: false,
        listImages: listImages
      };
    }
  };

  // Pururin: first-image URL with the page number swapped per option.
  const sitePururin = {
    name: 'Pururin',
    url: /https?:\/\/(www\.)?pururin\.me\/(view|read)\/.+\/.+\/.+/,
    homepage: 'https://pururin.me/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitAttr: ['.img-viewer img', 'src'],
    testUrls: {
      match: ['https://pururin.me/view/12345/1/some-title/', 'https://www.pururin.me/read/x/y/z'],
      noMatch: ['https://pururin.me/', 'https://pururin.me/view/x/']
    },
    run: function () {
      const src = queryAttribute('.img-viewer img', 'src');
      const options = queryAll('.img-select option');
      if (src === '' || options.length === 0) {
        throw new Error('Pururin: viewer image or page options not found');
      }
      const listImages = [];
      for (let i = 0; i < options.length; i++) {
        listImages.push(src.replace(/\/\d+\./, '/' + (i + 1) + '.'));
      }
      return {
        title: queryText('.title'),
        series: queryAttribute('.breadcrumb-item:nth-child(4) a', 'href'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // SchaleNetwork: reader state in history.state.memo (same engine as HDoujin).
  const siteSchalenetwork = {
    name: 'SchaleNetwork',
    url: /https?:\/\/(www\.)?(niyaniya|shupogaki|hoshino)\.(moe|one)/,
    homepage: 'https://schale.network/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: 'nav select option',
    testUrls: {
      match: ['https://niyaniya.moe/g/abc/', 'https://hoshino.one/x', 'https://shupogaki.moe/y'],
      noMatch: ['https://example.com/', 'https://niyaniyaXmoe/x']
    },
    run: async function () {
      const memo = history.state ? history.state.memo : null;
      const gallery = memo ? memo.gallery : null;
      const data = memo ? memo.data : null;
      if (!gallery || !data || !Array.isArray(data.entries)) {
        throw new Error('SchaleNetwork: history.state.memo.gallery/data.entries not found');
      }
      const size = gallery.resolution;
      const listImages = sitesAImageList(data.entries.map(function (image) {
        return data.base + '/' + image.path + '?w=' + size;
      }));
      return {
        title: gallery.title,
        series: '/g/' + gallery.id + '/' + gallery.key + '/',
        pages: listImages.length,
        prev: '#',
        next: '#',
        fetchOptions: {
          method: 'GET',
          redirect: 'follow'
        },
        listImages: listImages
      };
    }
  };

  // Simply-Hentai: Next.js payload (#__NEXT_DATA__) holds the full-size URLs.
  const siteSimplyhentai = {
    name: 'Simply-Hentai',
    url: /https?:\/\/(www\.)?simply-hentai\.com\/.+\/page\/.+/,
    homepage: 'https://simply-hentai.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    waitEle: '#__NEXT_DATA__',
    testUrls: {
      match: ['https://simply-hentai.com/x/page/1/', 'https://www.simply-hentai.com/a/b/page/2'],
      noMatch: ['https://simply-hentai.com/', 'https://simply-hentai.com/x/']
    },
    run: async function () {
      let payload = null;
      try {
        payload = JSON.parse(queryText('#__NEXT_DATA__') || '');
      } catch (error) {
        throw new Error('Simply-Hentai: #__NEXT_DATA__ JSON parse failed: ' + error.message);
      }
      const pages = siteCReadPath(payload, ['props', 'pageProps', 'data', 'pages']);
      if (!Array.isArray(pages)) {
        throw new Error('Simply-Hentai: props.pageProps.data.pages not found');
      }
      const listImages = sitesAImageList(pages.map(function (img) {
        return img ? img.sizes.full : '';
      }));
      const headline = query('.content-headline a');
      return {
        title: readText(headline),
        series: readAttribute(headline, 'href'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // TMOHentai: same reader engine as TuMangaOnline, shared run() body.
  const siteTmohentai = {
    name: 'TMOHentai',
    url: /https?:\/\/(www\.)?tmohentai\.(com|app)\/(reader|library|view_uploads)\/.+/,
    homepage: 'https://tmohentai.app/',
    language: [Language.SPANISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://tmohentai.app/reader/abc/cascade', 'https://www.tmohentai.com/library/123'],
      noMatch: ['https://tmohentai.app/', 'https://tmohentai.app/profile/x']
    },
    run: function () {
      return runTmoReader();
    }
  };

  // Tsumino: reader API (data-opt token) + CDN template with [PAGE].
  const siteTsumino = {
    name: 'Tsumino',
    url: /https?:\/\/(www\.)?tsumino\.com\/Read\/Index\/\d+(\?page=.+)?/,
    homepage: 'https://tsumino.com/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://www.tsumino.com/Read/Index/12345', 'https://tsumino.com/Read/Index/1?page=2'],
      noMatch: ['https://www.tsumino.com/', 'https://www.tsumino.com/Read/']
    },
    run: async function () {
      const dataOpt = queryAttribute('#image-container', 'data-opt');
      const dataCdn = queryAttribute('#image-container', 'data-cdn');
      if (dataOpt === '' || dataCdn === '') {
        throw new Error('Tsumino: #image-container data-opt/data-cdn not found');
      }
      const api = await fetchJson('https://www.tsumino.com/Read/Load?q=' + dataOpt);
      if (!api || typeof api.reader_page_total !== 'number') {
        throw new Error('Tsumino: reader API returned no page count');
      }
      const listImages = [];
      for (let i = 1; i <= api.reader_page_total; i++) {
        listImages.push(dataCdn.replace('[PAGE]', String(i)));
      }
      return {
        title: queryText('title').replace(/.+Read/, '').trim(),
        series: api.reader_start_url,
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // vermangasporno/vercomicsporno: .pp-comic-content images (lazy attributes first).
  const siteVermanga = {
    name: ['vermangasporno', 'vercomicsporno'],
    url: /https?:\/\/(www\.)?(vermangasporno|vercomicsporno)\.com\/.+/,
    homepage: ['https://vermangasporno.com/', 'https://vercomicsporno.com/'],
    language: [Language.SPANISH],
    category: Category.MANGA,
    waitEle: '.pp-comic-content img',
    testUrls: {
      match: ['https://vermangasporno.com/some-comic/', 'https://vercomicsporno.com/x/y'],
      noMatch: ['https://vermangasporno.com/', 'https://example.com/x']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('.pp-comic-content img').map(function (img) {
        return readFirstAttribute(img, ['data-lazy-src', 'data-src', 'src']);
      }));
      const titleElement = query('h1.titl') || query('title');
      return {
        title: readText(titleElement),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // wnacg: page selector gives listPages, the reader box (#picarea) gives the image.
  const siteWnacg = {
    name: 'wnacg',
    url: /https?:\/\/(www\.)?wnacg\.com\/photos-view-id-.+/,
    homepage: 'https://wnacg.com/',
    language: [Language.ENGLISH, Language.RAW, Language.CHINESE],
    category: Category.MANGA,
    testUrls: {
      match: ['https://wnacg.com/photos-view-id-12345.html', 'https://www.wnacg.com/photos-view-id-x'],
      noMatch: ['https://wnacg.com/', 'https://wnacg.com/albums-x.html']
    },
    run: function () {
      const options = queryAll('.pageselect option');
      if (options.length === 0) {
        throw new Error('wnacg: page selector not found');
      }
      const listPages = options.map(function (option) {
        return toAbsoluteUrl(window.location.pathname.replace(/\d+/, option.value));
      });
      return {
        title: queryText('.bread a:last-of-type'),
        pages: listPages.length,
        prev: '#',
        next: '#',
        listPages: listPages,
        img: '#picarea'
      };
    }
  };

  // XlecxOne: article images (data-src, linked full image, or src), deduplicated.
  const siteXlecxone = {
    name: 'XlecxOne',
    url: /https?:\/\/(www\.)?xlecx\.one\/.+/,
    homepage: 'https://xlecx.one/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://xlecx.one/some-post/', 'https://www.xlecx.one/x/y'],
      noMatch: ['https://xlecx.one/', 'https://example.com/x']
    },
    run: function () {
      const listImages = uniqueValues(sitesAImageList(queryAll('article .page__text img, article #content-2 img').map(function (img) {
        const direct = readFirstAttribute(img, ['data-src', 'data-srce', 'src']);
        if (direct !== '') {
          return direct;
        }
        const link = img.closest ? img.closest('a') : null;
        return readAttribute(link, 'href');
      })));
      return {
        title: queryText('title'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // xyzcomics: gallery links (.jig-link href) are the full images.
  const siteXyzcomics = {
    name: 'xyzcomics',
    url: /https?:\/\/(www\.)?xyzcomics\.com\/.+/,
    homepage: 'https://xyzcomics.com/',
    language: [Language.ENGLISH],
    category: Category.COMIC,
    testUrls: {
      match: ['https://xyzcomics.com/some-comic/', 'https://www.xyzcomics.com/x'],
      noMatch: ['https://xyzcomics.com/', 'https://example.com/x']
    },
    run: function () {
      const listImages = sitesAImageList(queryAll('.jig-link').map(function (link) {
        return readAttribute(link, 'href');
      }));
      return {
        title: queryText('.entry-title'),
        pages: listImages.length,
        prev: '#',
        next: '#',
        listImages: listImages
      };
    }
  };

  // Yabai: page count from the nav selector, images by clicking "Next" (bruteforceClick).
  const siteYabai = {
    name: 'Yabai',
    url: /https?:\/\/(www\.)?yabai\.si\/g\/.+\/read/,
    homepage: 'https://yabai.si/',
    language: [Language.ENGLISH],
    category: Category.MANGA,
    testUrls: {
      match: ['https://yabai.si/g/some-title/read/1', 'https://www.yabai.si/g/x/read'],
      noMatch: ['https://yabai.si/', 'https://yabai.si/g/x']
    },
    run: async function () {
      const num = queryAll('nav select option').length;
      if (!(num > 0)) {
        throw new Error('Yabai: page count not found');
      }
      const manga = {
        title: queryText('title'),
        series: '../',
        pages: num,
        prev: '#',
        next: '#',
        listImages: [''],
        before: async function () {
          manga.listImages = await bruteforceClick(function () {
            const first = query('select option');
            if (first) {
              first.selected = true;
            }
            const select = query('select');
            if (select) {
              select.dispatchEvent(new Event('change'));
            }
          }, num, 'button[title="Next"]', 'h1 + div', 'img.mx-auto', 'src');
        }
      };
      return manga;
    }
  };

  // Madara mirrors with /porncomic/ or /read-scan/ paths (any host) and the /title/ path of
  // Manytoon and Manga District only: same findMadaraImages engine. The /title/ path is limited
  // to those two hosts because Comix, DemonicScans, Batoto, MangaPark and MangaDex also use
  // /title/x/y addresses; a generic /title/ rule would run this module there too.
  const siteMadarawph = {
    name: ['Madara WordPress Plugin', 'AllPornComic', 'Manytoon', 'Manga District'],
    url: /https?:\/\/(.+\/(porncomic|read-scan)|(www\.)?(manytoon|mangadistrict)\.com\/title)\/.+\/.+/,
    homepage: ['#', 'https://allporncomic.com/', 'https://manytoon.com/', 'https://mangadistrict.com/'],
    language: [Language.ENGLISH],
    obs: 'Any site that uses the Madara WordPress Plugin with these URL paths',
    category: Category.MANGA,
    testUrls: {
      match: ['https://manytoon.com/title/some-title/chapter-1/', 'https://example.com/porncomic/x/y'],
      noMatch: ['https://manytoon.com/', 'https://manytoon.com/title/x', 'https://comix.to/title/some-manga/12345-chapter-1', 'https://demonicscans.org/title/Some-Manga/chapter/12/1', 'https://mangapark.net/title/12345-en-some-title/678-chapter-1', 'https://www.mangatoto.com/title/12345-some-title/678910-ch_1', 'https://mangadex.org/title/abc/some-title']
    },
    waitFunc: function () {
      const images = findMadaraImages();
      return images.length > 0 && images.every(function (source) {
        return source !== '' && madaraImageRegex.test(source);
      });
    },
    run: function () {
      const listImages = toAbsoluteImageListB(findMadaraImages());
      const seriesLink = query('.breadcrumb li:nth-child(3) a') || query('.breadcrumb li:nth-child(2) a');
      return {
        title: queryText('#chapter-heading'),
        series: readAttribute(seriesLink, 'href'),
        pages: listImages.length,
        prev: queryAttribute('.prev_page', 'href'),
        next: queryAttribute('.next_page', 'href'),
        listImages: listImages
      };
    }
  };


  /**
   * Every site module, in matching priority order. Generic CMS modules (MangaStream, FoOlSlide,
   * Madara) must stay LAST: their URL patterns are broad, so a specific module should win a tie.
   */
  const SITES = [
    siteAsura,
    siteBatoto,
    siteBilibilicomics,
    siteComick,
    siteComix,
    siteDemonicscans,
    siteDynastyscans,
    siteFlamecomics,
    siteIkigai,
    siteKagane,
    siteKumanga,
    siteLeercapitulo,
    siteLectorxd,
    siteLhtranslation,
    siteLocalhost,
    siteM440,
    siteMangabuddy,
    siteMangadex,
    siteMangafox,
    siteMangago,
    siteMangahub,
    siteMangakakalot,
    siteMangaball,
    siteMangaoni,
    siteMangapark,
    siteMangareader,
    siteMangatoon,
    siteMangatown,
    siteManhwaweb,
    siteMgeko,
    siteNineanime,
    siteOlympus,
    siteQimanga,
    siteQimanhwa,
    siteReadcomicsonline,
    siteReaperscans,
    siteReimanga,
    siteTmofans,
    siteWebNovel,
    siteWebToons,
    siteWeebCentral,
    siteWeebDex,
    siteVortexscans,
    siteZeroScans,
    site3hentai,
    siteEightMuses,
    siteNineHentai,
    siteAkumamoe,
    siteBestporncomix,
    siteDoujinmoe,
    siteDragontranslation,
    siteExhentai,
    siteFreeadultcomix,
    siteFsicomics,
    siteGntai,
    siteHdoujin,
    siteHentai2read,
    siteHentaiera,
    siteHentaiforce,
    siteHentaifox,
    siteHentaihand,
    siteHentaihere,
    siteHentainexus,
    siteHentalk,
    siteHitomi,
    siteImhentai,
    siteKingcomix,
    siteLuscious,
    siteMultporn,
    siteMyhentaigallery,
    siteNhentaiNet,
    siteNhentaiTo,
    siteNhentaiXxx,
    sitePorncomicshd,
    sitePururin,
    siteSchalenetwork,
    siteSimplyhentai,
    siteTmohentai,
    siteTsumino,
    siteVermanga,
    siteWnacg,
    siteXlecxone,
    siteXyzcomics,
    siteYabai,
    siteMadarawph,
    siteMangastreamwp,
    siteFoolslide,
    siteMadarawp
  ];

  // ==========================================================================================
  // ==== 24. ENTRY ============================================================================
  // Console helpers (type them in the browser console on a chapter page):
  //   MVMDiagnose()   - test every matching site module step by step, without opening the reader
  //   MVMDebug(true)  - turn verbose logging on (stored; reload the page). MVMDebug(false) = off
  //   MVMSettings()   - print the current settings
  //   MVMErrors()     - print the last errors with their context and details
  //   MVM(begin, end) - open the reader now (optionally for a page range) after a site was found
  //   MVMOpenFiles()  - show the local files panel (images, a folder or a ZIP/CBZ archive)
  // TEST MODE: when the page defines window.__MVM_TEST_MODE__ = true BEFORE this script runs,
  // the script does not start; it exposes its internals as window.__MVM_INTERNALS__ for
  // tests/logic-tests.html.
  // ==========================================================================================

  exposeToConsole('MVMDiagnose', function () {
    return diagnose(SITES);
  });
  exposeToConsole('MVMDebug', function (enabled) {
    const value = enabled !== false;
    writeStoredValue(CONFIG.verboseStorageKey, value);
    setVerbose(value);
    log('Verbose logging ' + (value ? 'ON' : 'OFF') + ' (stored). Reload the page to see the startup steps.');
    return value;
  });
  exposeToConsole('MVMSettings', function () {
    const report = {
      effective: deepClone(SETTINGS_STATE.effective),
      global: deepClone(SETTINGS_STATE.global),
      local: deepClone(SETTINGS_STATE.local),
      localScopeEnabled: isLocalScopeEnabled()
    };
    console.log(CONFIG.logPrefix + ' settings', report);
    return report;
  });
  exposeToConsole('MVMSiteData', function (asJson) {
    SITE_DATA_CAPTURES.forEach(logSiteDataReport);
    if (SITE_DATA_CAPTURES.length === 0) {
      log('MVMSiteData: no site data captured on this page (only some site modules record data).');
    }
    if (asJson === true) {
      return toShortJson(SITE_DATA_CAPTURES, 5000000);
    }
    return SITE_DATA_CAPTURES.slice();
  });
  exposeToConsole('MVMErrors', function () {
    if (typeof console.table === 'function') {
      console.table(ERROR_HISTORY);
    }
    return ERROR_HISTORY.slice();
  });
  exposeToConsole('MVMOpenFiles', function () {
    showLocalFilesPanel(openLocalImages);
    return 'Choose images, a folder or a ZIP/CBZ archive in the panel';
  });

  if (pageWindow.__MVM_TEST_MODE__ === true || window.__MVM_TEST_MODE__ === true) {
    const internals = {
      CONFIG: CONFIG,
      SITES: SITES,
      LOCALES: LOCALES,
      DEFAULT_SETTINGS: DEFAULT_SETTINGS,
      isNothing: isNothing,
      isPlainObject: isPlainObject,
      deepClone: deepClone,
      deepEqual: deepEqual,
      fillDefaults: fillDefaults,
      diffObjects: diffObjects,
      sequence: sequence,
      clamp: clamp,
      escapeHtml: escapeHtml,
      normalizeUrl: normalizeUrl,
      isValidUrl: isValidUrl,
      addCacheBuster: addCacheBuster,
      toAbsoluteUrl: toAbsoluteUrl,
      lastItem: lastItem,
      safeKeys: safeKeys,
      uniqueValues: uniqueValues,
      waitUntil: waitUntil,
      withTimeout: withTimeout,
      validateMangaResult: validateMangaResult,
      getLoadingStrategy: getLoadingStrategy,
      findMatchingSites: findMatchingSites,
      firstSuccessfulSite: firstSuccessfulSite,
      getLocaleString: getLocaleString,
      buildDefaultSettings: buildDefaultSettings,
      OLD_SETTING_RENAMES: OLD_SETTING_RENAMES,
      migrateOldSettingKeys: migrateOldSettingKeys,
      migrateStoredSettings: migrateStoredSettings,
      loadSettingsObject: loadSettingsObject,
      placeholder: placeholder,
      findUrlLikeValues: findUrlLikeValues,
      bruteforceClick: bruteforceClick,
      describeDataShape: describeDataShape,
      recordSiteData: recordSiteData,
      SITE_DATA_CAPTURES: SITE_DATA_CAPTURES,
      start: start,
      diagnose: diagnose,
      countImages: countImages,
      getSetting: getSetting,
      setSessionSetting: setSessionSetting,
      getStoredSetting: getStoredSetting,
      restoreStoredSetting: restoreStoredSetting,
      COLOR_PALETTES: COLOR_PALETTES,
      PALETTE_SHADES: PALETTE_SHADES,
      LEGACY_THEME_COLORS: LEGACY_THEME_COLORS,
      RECOMMENDED_THEME_COLORS: RECOMMENDED_THEME_COLORS,
      parseHexColor: parseHexColor,
      rgbToHex: rgbToHex,
      rgbToHsl: rgbToHsl,
      hslToRgb: hslToRgb,
      lightenColor: lightenColor,
      darkenColor: darkenColor,
      rgbToOkLch: rgbToOkLch,
      okLchToHex: okLchToHex,
      parseCssColor: parseCssColor,
      resolveThemeColor: resolveThemeColor,
      getLightnessStar: getLightnessStar,
      getReadableTextColor: getReadableTextColor,
      generateColorGradient: generateColorGradient,
      buildThemeVariables: buildThemeVariables,
      buildThemeCss: buildThemeCss,
      ICONS: ICONS,
      buildIconSvg: buildIconSvg,
      READER_LAYOUT_CSS: READER_LAYOUT_CSS,
      buildButtonHtml: buildButtonHtml,
      buildButtonGroupHtml: buildButtonGroupHtml,
      buildAppHtml: buildAppHtml,
      VIEW_MODE_BUTTONS: VIEW_MODE_BUTTONS,
      PAGE_BUTTONS: PAGE_BUTTONS,
      computeGlobalPageSize: computeGlobalPageSize,
      computeSteppedPageWidth: computeSteppedPageWidth,
      computeBookPageSides: computeBookPageSides,
      computeHeadroomPosition: computeHeadroomPosition,
      isHeaderShown: isHeaderShown,
      pickCurrentPage: pickCurrentPage,
      getPaginationState: getPaginationState,
      SETTING_APPLIERS: SETTING_APPLIERS,
      ACTIONS: ACTIONS,
      KEYBIND_ACTIONS: KEYBIND_ACTIONS,
      runAction: runAction,
      resolveDeviceMode: resolveDeviceMode,
      PANELS_CSS: PANELS_CSS,
      SETTING_CHOICES: SETTING_CHOICES,
      SETTING_SLIDERS: SETTING_SLIDERS,
      SETTINGS_PANEL_LAYOUT: SETTINGS_PANEL_LAYOUT,
      getAutoScrollMaximum: getAutoScrollMaximum,
      buildSwitchHtml: buildSwitchHtml,
      buildSegmentedHtml: buildSegmentedHtml,
      buildSelectHtml: buildSelectHtml,
      buildSliderParts: buildSliderParts,
      buildSwatchHtml: buildSwatchHtml,
      buildThemePickerHtml: buildThemePickerHtml,
      buildSettingsPanelHtml: buildSettingsPanelHtml,
      buildKeybindingsPanelHtml: buildKeybindingsPanelHtml,
      isRowConditionMet: isRowConditionMet,
      computePageStepTarget: computePageStepTarget,
      isBookRowStart: isBookRowStart,
      computeBookRowTarget: computeBookRowTarget,
      computeAutoScrollSpeed: computeAutoScrollSpeed,
      convertSettingValue: convertSettingValue,
      isAllowedSettingValue: isAllowedSettingValue,
      KEYBIND_IDS: KEYBIND_IDS,
      parseKeyBinding: parseKeyBinding,
      getEventKeyNames: getEventKeyNames,
      getEventCombos: getEventCombos,
      buildKeybindTable: buildKeybindTable,
      findKeybindForEvent: findKeybindForEvent,
      formatKeyListForEditor: formatKeyListForEditor,
      parseKeyListText: parseKeyListText,
      validateKeybindEdits: validateKeybindEdits,
      buildKeybindListHtml: buildKeybindListHtml,
      buildKeybindEditorHtml: buildKeybindEditorHtml,
      isEditableTarget: isEditableTarget,
      // M3
      FEATURES_CSS: FEATURES_CSS,
      onSettingSaved: onSettingSaved,
      saveSetting: saveSetting,
      saveBookmark: saveBookmark,
      removeBookmark: removeBookmark,
      getBookmarkedPage: getBookmarkedPage,
      padPageNumber: padPageNumber,
      normalizePageRange: normalizePageRange,
      computeRangeFillPercent: computeRangeFillPercent,
      buildPageRangeHtml: buildPageRangeHtml,
      showToast: showToast,
      updateToast: updateToast,
      closeToast: closeToast,
      buildBookmarkListHtml: buildBookmarkListHtml,
      getSafeLinkHref: getSafeLinkHref,
      computeEagerPages: computeEagerPages,
      isRectNearViewport: isRectNearViewport,
      selectPagesNearViewport: selectPagesNearViewport,
      selectLookAheadPages: selectLookAheadPages,
      LOADER: LOADER,
      computeCrc32: computeCrc32,
      encodeUtf8: encodeUtf8,
      decodeUtf8: decodeUtf8,
      decodeZipFileName: decodeZipFileName,
      buildZipParts: buildZipParts,
      concatenateBytes: concatenateBytes,
      inflateRaw: inflateRaw,
      readZipDirectory: readZipDirectory,
      extractZipEntry: extractZipEntry,
      detectImageExtension: detectImageExtension,
      getExtensionFromName: getExtensionFromName,
      getImageExtension: getImageExtension,
      buildPageFileName: buildPageFileName,
      sanitizeFileName: sanitizeFileName,
      buildZipFileName: buildZipFileName,
      decodeDataUrl: decodeDataUrl,
      readResponseHeader: readResponseHeader,
      DOWNLOAD: DOWNLOAD,
      isChapterSettled: isChapterSettled,
      getDownloadReadiness: getDownloadReadiness,
      isPagePending: isPagePending,
      announcePartialDownload: announcePartialDownload,
      updateDownloadAvailability: updateDownloadAvailability,
      runDownload: runDownload,
      getSettingLabel: getSettingLabel,
      formatSettingValue: formatSettingValue,
      shouldRemoveResumeBookmark: shouldRemoveResumeBookmark,
      computeAutoScrollStep: computeAutoScrollStep,
      computeScrollRemaining: computeScrollRemaining,
      AUTO_SCROLL: AUTO_SCROLL,
      isElementNode: isElementNode,
      compareNaturally: compareNaturally,
      detectArchiveFormat: detectArchiveFormat,
      isLocalImageFile: isLocalImageFile,
      getImageMimeTypeFromName: getImageMimeTypeFromName,
      loadImagesFromArchive: loadImagesFromArchive,
      loadImagesFromFiles: loadImagesFromFiles,
      buildLocalFilesPanelHtml: buildLocalFilesPanelHtml,
      APP_STATE: APP_STATE,
      ERROR_HISTORY: ERROR_HISTORY,
      resolveChapterHref: resolveChapterHref,
      buildHeaderHtml: buildHeaderHtml,
      onReaderClick: onReaderClick,
      validateMangaResult: validateMangaResult
    };
    pageWindow.__MVM_INTERNALS__ = internals;
    window.__MVM_INTERNALS__ = internals;
    log('TEST MODE: not starting; internals exposed as window.__MVM_INTERNALS__');
  } else {
    start(SITES).catch(function (error) {
      logError('start', error);
    });
  }
})();