☰

Hitomi Tweak

Poster grid, dark mode, dan reader scroll vertikal ala webtoon untuk hitomi.la (anti-kedip) + panel Filter Tag

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

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

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

// ==UserScript==
// @name         Hitomi Tweak
// @namespace    hitomi-tweaks
// @version      3.72
// @description  Poster grid, dark mode, dan reader scroll vertikal ala webtoon untuk hitomi.la (anti-kedip) + panel Filter Tag
// @description:en  Poster grid, dark mode, vertical webtoon-style reader and Tag Filter panel for hitomi.la (flicker-free)
// @author       qxyraxp
// @license      MIT
// @homepageURL  https://github.com/qxyraxp/hitomi-tweaks
// @supportURL   https://github.com/qxyraxp/hitomi-tweaks/issues
// @match        https://hitomi.la/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(() => {
  'use strict';
  if (window.top !== window.self) return;
  if (document.getElementById('hmt-style')) return;

  /* ====================================================================
   * Helper dasar
   * ==================================================================== */
  const txt = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : '');
  const mk = (tag, cls, text) => {
    const e = document.createElement(tag);
    if (cls) e.className = cls;
    if (text != null) e.textContent = text;
    return e;
  };
  const onReady = (fn) => {
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
    else fn();
  };

  /* ====================================================================
   * Settings (localStorage)
   * ==================================================================== */
  const KEY = 'hmt:';
  const get = (k, d) => {
    try { const v = localStorage.getItem(KEY + k); return v === null ? d : JSON.parse(v); }
    catch { return d; }
  };
  const set = (k, v) => { try { localStorage.setItem(KEY + k, JSON.stringify(v)); } catch {} };

  const state = {
    lang: get('lang', 'id'),               // bahasa antarmuka: 'id' | 'en'
    dark: get('dark', true),
    grid: get('grid', true),
    webtoon: get('webtoon', true),
    cardW: get('cardW', 190),
    cardStyle: get('cardStyle', 'detail'), // 'detail' (cover + tabel info) | 'poster'
    readerW: get('readerW', 800),
    thumbs: get('thumbs', true),         // thumbnail besar di halaman galeri
    thumbReader: get('thumbReader', false), // thumbnail pakai gambar reader (berat); default: thumbnail situs (ringan)
    thumbW: get('thumbW', 160),
    pvOn: get('pvOn', true),             // hover preview
    pvSize: get('pvSize', 80),           // tinggi preview, % tinggi layar
    pvDelay: get('pvDelay', 150),        // ms
    pvMode: get('pvMode', 'side'),       // 'side' | 'cursor'
    dlPanel: get('dlPanel', true),       // klik Download -> panel pilih gambar dulu
    dlFmt: get('dlFmt', 'orig'),         // format hasil unduhan: 'orig' | 'jpg'
    dlQ: get('dlQ', 90),                 // kualitas JPG (%)
    dlOut: get('dlOut', 'zip'),          // hasil: 'zip' | 'pdf' | 'img' (gambar lepas, perlu folder)
    dlDest: get('dlDest', 'dl'),         // tujuan: 'dl' (unduhan browser) | 'dir' (folder pilihan)
    toastPos: get('toastPos', 'br'),       // posisi notifikasi: br bl bc tr tl tc
    introScroll: get('introScroll', true),   // animasi kartu/thumbnail saat di-scroll
    introScrollStyle: get('introScrollStyle', 'same'), // 'same' = ikut gaya kartu, atau kunci INTRO_FX / 'mix'
    introBlock: get('introBlock', 'rise'),  // animasi cover + info di halaman galeri -> INTRO_FX
    introNav: get('introNav', 'slide'),    // animasi navbar (menu atas) -> HEAD_FX
    introBanner: get('introBanner', 'wipe'), // animasi banner judul -> HEAD_FX
    introOn: get('introOn', true),         // animasi kartu saat halaman dimuat
    introStyle: get('introStyle', 'rise'), // gaya animasi (lihat INTRO_FX)
    introOrder: get('introOrder', 'seq'),  // urutan muncul: 'seq' | 'diag' | 'rand'
    introSpeed: get('introSpeed', 1),      // pengali durasi animasi
    introStep: get('introStep', 80),       // jeda antar kartu (ms)
    stickyNav: get('stickyNav', true),   // header/navbar tetap terlihat saat scroll
    searchOn: get('searchOn', true),     // perpanjang kolom search
    searchW: get('searchW', 520),        // lebar kolom search (px)
    accent: get('accent', '#5b7cff'),    // warna aksen
    dlMark: get('dlMark', true),         // tandai galeri yang sudah diunduh
    dlThumb: get('dlThumb', 150),        // lebar minimum thumbnail di panel pilih unduhan (px)
    romaji: get('romaji', true),         // ubah tulisan Jepang di judul jadi romaji
    romajiKanji: get('romajiKanji', true), // pakai kamus (kuromoji) supaya kanji ikut diubah
    romajiLong: get('romajiLong', 'simple'), // vokal panjang: 'simple' (ou->o) | 'keep'
    tfInc: get('tfInc', []),             // filter tag: tag yang disertakan (query pencarian)
    tfExc: get('tfExc', []),             // filter tag: tag yang dikecualikan (diawali "-")
  };

  /* ====================================================================
   * Versi + bahasa (Indonesia / English)
   * Teks sumber di script ini berbahasa Indonesia; T() menerjemahkannya ke English
   * lewat kamus EN di bawah. {0}, {1} = nilai yang disisipkan.
   * ==================================================================== */
  const VERSION = '3.72';
  const AUTHOR = 'qxyraxp';
  const EN = {
    // --- panel: tab & judul bagian
    'Pengaturan': 'Settings', 'Tampilan': 'Appearance', 'Animasi': 'Animations', 'Thumbnail': 'Thumbnails',
    'Hover preview': 'Hover preview', 'Unduhan': 'Downloads', 'Lainnya': 'More',
    'Umum': 'General', 'Header': 'Header', 'Daftar galeri': 'Gallery list', 'Reader': 'Reader',
    'Saat halaman dimuat': 'On page load', 'Halaman galeri': 'Gallery page', 'Kartu & thumbnail': 'Cards & thumbnails',
    'Saat di-scroll': 'While scrolling', 'Thumbnail galeri': 'Gallery thumbnails', 'Judul': 'Titles',
    'Pratinjau saat hover': 'On hover', 'Panel unduhan': 'Download panel', 'Riwayat': 'History',
    'Pintasan': 'Shortcuts', 'Diagnostik': 'Diagnostics', 'Tentang': 'About',
    // --- panel: tampilan
    'Bahasa antarmuka panel dan notifikasi': 'Interface language for panels and notifications',
    'Mode gelap': 'Dark mode', 'Tema gelap untuk seluruh situs': 'Dark theme for the whole site',
    'Warna aksen': 'Accent color', 'Warna tombol, slider, dan penanda': 'Color of buttons, sliders and highlights',
    'Warna kustom': 'Custom color',
    'Posisi notifikasi': 'Notification position',
    'Tempat muncul pesan seperti "Kamus kanji siap"': 'Where messages such as "Kanji dictionary ready" appear',
    'Kanan bawah': 'Bottom right', 'Kiri bawah': 'Bottom left', 'Tengah bawah': 'Bottom center',
    'Kanan atas': 'Top right', 'Kiri atas': 'Top left', 'Tengah atas': 'Top center',
    'Header tetap di atas': 'Sticky header', 'Navbar (menu & pencarian) tidak hilang saat halaman di-scroll': 'The navbar (menu & search) stays visible while scrolling',
    'Perpanjang kolom search': 'Wider search box', 'Kolom pencarian di header dibuat lebih panjang': 'Makes the header search box longer',
    'Lebar kolom search': 'Search box width', 'Lebar kolom pencarian (px)': 'Width of the search box (px)',
    'Poster grid': 'Poster grid', 'Tampilkan galeri sebagai kartu poster': 'Show galleries as poster cards',
    'Gaya kartu': 'Card style', 'Detail: cover + tabel info seperti halaman galeri. Poster: cover penuh': 'Detail: cover + info table like the gallery page. Poster: full cover',
    'Detail': 'Detail', 'Poster': 'Poster',
    'Ukuran kartu': 'Card size', 'Lebar minimum tiap kartu poster': 'Minimum width of each poster card',
    'Mode webtoon': 'Webtoon mode', 'Scroll vertikal, semua halaman berurutan': 'Vertical scroll, all pages in sequence',
    'Lebar gambar': 'Image width', 'Lebar halaman pada mode webtoon (px)': 'Page width in webtoon mode (px)', 'Penuh': 'Full',
    // --- panel: animasi
    'Animasi saat memuat': 'Load animation', 'Header dan kartu muncul dengan animasi saat halaman dimuat': 'Header and cards animate in when the page loads',
    'Navbar (menu atas)': 'Navbar (top menu)', 'Efek masuk untuk logo, menu, dan kolom search': 'Entrance effect for the logo, menu and search box',
    'Banner judul': 'Title banner', 'Efek masuk untuk bar "Recently Added" (halaman daftar)': 'Entrance effect for the "Recently Added" bar (list pages)',
    'Cover & info': 'Cover & info', 'Efek masuk untuk cover, judul, dan tabel info saat membuka sebuah galeri': 'Entrance effect for the cover, title and info table when opening a gallery',
    'Gaya animasi': 'Animation style', 'Pilih efek masuk tiap kartu. Perubahan langsung dipratinjau di halaman': 'Pick the entrance effect for each card. Changes are previewed on the page',
    'Urutan muncul': 'Order', 'Berurutan: kiri ke kanan lalu ke bawah. Diagonal: menyapu dari pojok kiri-atas. Acak: urutan dikocok': 'Sequential: left to right, then down. Diagonal: sweeps from the top-left corner. Random: shuffled order',
    'Berurutan (kiri-atas)': 'Sequential (top-left)', 'Diagonal': 'Diagonal', 'Acak': 'Random',
    'Kecepatan': 'Speed', 'Durasi animasi tiap kartu': 'Animation duration of each card', 'Cepat': 'Fast', 'Normal': 'Normal', 'Lambat': 'Slow',
    'Jeda antar kartu': 'Delay between cards', 'Selisih waktu muncul antar kartu (ms)': 'Time gap between cards appearing (ms)',
    'Putar ulang': 'Replay', 'Pratinjau': 'Preview', 'Putar ulang animasi pada kartu yang sedang terlihat': 'Replay the animation on the cards currently visible',
    'Animasi saat di-scroll': 'Scroll animation', 'Kartu dan thumbnail di bawah layar muncul dengan animasi ketika di-scroll sampai ke situ': 'Cards and thumbnails below the screen animate in as you scroll to them',
    'Gaya saat di-scroll': 'Scroll style', 'Efek masuk untuk kartu yang baru terlihat. Coba scroll halaman untuk melihatnya': 'Entrance effect for newly visible cards. Try scrolling the page to see it',
    'Sama dengan gaya kartu': 'Same as card style', 'Campur (acak tiap kartu)': 'Mixed (random per card)',
    'Naik': 'Rise', 'Turun': 'Drop', 'Geser dari kiri': 'Slide from left', 'Pop (membal)': 'Pop (bouncy)', 'Balik 3D': '3D flip',
    'Kabur ke jelas': 'Blur to sharp', 'Tirai (kiri ke kanan)': 'Curtain (left to right)', 'Fade polos': 'Plain fade',
    'Geser dari kanan': 'Slide from right', 'Geser diagonal': 'Diagonal slide', 'Zoom mendekat': 'Zoom in',
    'Memantul dari bawah': 'Bounce from below', 'Miring masuk': 'Tilt in', 'Geser miring': 'Skewed slide', 'Putar masuk': 'Spin in',
    'Balik horizontal': 'Horizontal flip', 'Rebah 3D': '3D tilt', 'Iris (lingkaran membuka)': 'Iris (circle opens)',
    'Tirai (bawah ke atas)': 'Curtain (bottom to top)',
    'Tanpa animasi': 'No animation', 'Geser dari atas': 'Slide from top', 'Buka dari atas': 'Unfold from top', 'Zoom masuk': 'Zoom in',
    'Turun memantul': 'Bouncing drop', 'Balik 3D (dari atas)': '3D flip (from top)', 'Iris (melebar dari tengah)': 'Iris (expands from center)',
    'Bagian satu per satu': 'Part by part',
    // --- panel: thumbnail, romaji, preview
    'Thumbnail besar': 'Large thumbnails', 'Ganti strip thumbnail dengan grid besar': 'Replace the thumbnail strip with a large grid',
    'Resolusi asli': 'Original resolution', 'Pakai gambar reader di thumbnail (berat). Mati = ringan': 'Use reader images for thumbnails (heavy). Off = light',
    'Ukuran thumbnail': 'Thumbnail size', 'Lebar minimum tiap thumbnail': 'Minimum width of each thumbnail',
    'Judul ke romaji': 'Titles to romaji', 'Ubah tulisan Jepang (hiragana, katakana, kanji) di judul jadi huruf latin. Arahkan kursor ke kartu untuk melihat judul asli': 'Convert Japanese text (hiragana, katakana, kanji) in titles to Latin letters. Hover a card to see the original title',
    'Ubah kanji juga': 'Convert kanji too', 'Unduh kamus kanji sekali (±17 MB, lalu tersimpan di cache browser). Mati = hanya kana yang diubah': 'Downloads the kanji dictionary once (~17 MB, then cached by the browser). Off = only kana is converted',
    'Vokal panjang': 'Long vowels', 'Cara menulis vokal panjang: toukyou atau tokyo': 'How long vowels are written: toukyou or tokyo',
    'Sederhana': 'Simple', 'Apa adanya': 'As is',
    'Tampilkan halaman besar saat kursor di thumbnail': 'Show a large page when the cursor is over a thumbnail',
    'Ukuran preview': 'Preview size', 'Tinggi preview (% tinggi layar)': 'Preview height (% of screen height)',
    'Jeda muncul': 'Show delay', 'Waktu tunggu sebelum preview muncul': 'Wait time before the preview appears',
    'Posisi preview': 'Preview position', 'Di samping thumbnail atau mengikuti kursor': 'Beside the thumbnail or following the cursor',
    'Samping': 'Side', 'Kursor': 'Cursor',
    // --- panel: unduhan
    'Panel pilih unduhan': 'Download picker', 'Klik Download → pilih gambar dulu; jalan di latar belakang antar halaman': 'Click Download → choose images first; runs in the background across pages',
    'Hasil default': 'Default output', 'Bentuk file yang dihasilkan': 'Type of file produced', 'Gambar': 'Images', 'gambar': 'images',
    'Kualitas JPG/PDF': 'JPG/PDF quality', 'Dipakai untuk PDF atau konversi JPG': 'Used for PDF or JPG conversion',
    'Ukuran thumbnail panel': 'Panel thumbnail size', 'Lebar thumbnail di panel pilih gambar': 'Thumbnail width in the image picker',
    'Tandai yang sudah diunduh': 'Mark downloaded galleries', 'Badge "✓ Diunduh" dan kartu diredupkan di daftar galeri': '"✓ Downloaded" badge and dimmed cards in the gallery list',
    'Daftar sudah diunduh': 'Downloaded list', 'Reset semua penanda "diunduh"': 'Reset all "downloaded" marks',
    'Hapus daftar ({0})': 'Clear list ({0})', 'Daftar sudah kosong': 'The list is already empty',
    'Hapus daftar sudah diunduh?': 'Clear the downloaded list?',
    '{0} galeri akan kehilangan penanda. File hasil unduhan tidak ikut terhapus.': '{0} galleries will lose their mark. Downloaded files are not deleted.',
    'Hapus': 'Delete', 'Batal': 'Cancel', 'Daftar penanda dihapus': 'Downloaded marks cleared', '✓ Diunduh': '✓ Downloaded',
    // --- panel: lainnya, footer
    'Buka/tutup pengaturan': 'Open/close settings', 'Buka/tutup Filter Tag': 'Open/close Tag Filter',
    'Dump latar terang': 'Light-background dump', 'Salin daftar elemen berlatar putih (untuk dicek)': 'Copy a list of elements with white backgrounds (for debugging)',
    'Salin dump': 'Copy dump', 'Tersalin ✓': 'Copied ✓', 'Lihat console (F12)': 'See console (F12)',
    'Poster grid, mode gelap, reader webtoon, dan filter tag untuk hitomi.la': 'Poster grid, dark mode, webtoon reader and tag filter for hitomi.la',
    'Reset default': 'Reset to default', 'Reset semua pengaturan?': 'Reset all settings?',
    'Semua pengaturan dikembalikan ke nilai awal.': 'All settings will be restored to their defaults.', 'Reset': 'Reset',
    'Pengaturan dikembalikan ke default': 'Settings restored to default', 'Notifikasi akan muncul di sini': 'Notifications will appear here',
    'Buka/tutup: ': 'Open/close: ', 'Tutup (Esc)': 'Close (Esc)', 'Konfirmasi': 'Confirm',
    '⚙ Pengaturan': '⚙ Settings', 'Pengaturan (Alt+S)': 'Settings (Alt+S)',
    // --- filter tag
    'Filter Tag': 'Tag Filter', 'Filter Tag (Alt+T)': 'Tag Filter (Alt+T)', 'Sertakan': 'Include', 'Kecualikan': 'Exclude',
    'Cari tag… (mis. female:insect)': 'Search tags… (e.g. female:insect)', 'Bersihkan': 'Clear',
    'Tag belum dikenal. Pilih namespace di bawah (♀ / ♂ / tag).': 'Unknown tag. Pick a namespace below (♀ / ♂ / tag).',
    'Pilih mode di atas, klik tag di bawah, lalu tekan Cari. Hasilnya dari seluruh galeri, bukan hanya halaman ini.': 'Pick a mode above, click tags below, then press Search. Results come from all galleries, not just this page.',
    'Tambah tag baru': 'Add new tag', 'pilih jenisnya': 'choose its type', 'Tag di halaman ini': 'Tags on this page', 'Dari halaman lain': 'From other pages',
    'Kosongkan': 'Clear', '🔍 Cari': '🔍 Search', '🔍 Cari ({0})': '🔍 Search ({0})', 'Pilih tag dulu': 'Pick a tag first',
    'Tidak ada yang cocok di halaman ini.': 'No matches on this page.', 'Belum ada tag terbaca di halaman ini': 'No tags found on this page yet',
    '{0} tag': '{0} tag{1}', '{0} dari {1} (ketik untuk cari)': '{0} of {1} (type to search)',
    // --- kamus kanji
    'Memuat kamus kanji… (pertama kali agak lama, halaman tetap bisa dipakai)': 'Loading kanji dictionary… (slow the first time; the page stays usable)',
    'Gagal memuat kamus kanji ({0}). Kalau diblokir ekstensi pemblokir iklan, izinkan cdn.jsdelivr.net. Detail di Console, cari [HMT].': 'Failed to load the kanji dictionary ({0}). If an ad blocker is blocking it, allow cdn.jsdelivr.net. Details in the Console, search for [HMT].',
    'tidak diketahui': 'unknown', 'Kamus kanji siap': 'Kanji dictionary ready',
    'Gagal dimuat, klik untuk coba lagi': 'Failed to load, click to retry',
    // --- folder, antrian & panel pilih gambar
    'Pemilihan folder dibatalkan': 'Folder selection canceled', 'Tidak bisa mengakses folder: {0}': 'Cannot access the folder: {0}',
    'Pilih folder tujuan': 'Choose destination folder', 'Pilih folder': 'Choose folder',
    'Pilih atau buat SUBFOLDER sendiri (mis. Documents\\Hitomi). Browser menolak folder sistem seperti Downloads, Documents, atau Desktop langsung.': 'Pick or create your own SUBFOLDER (e.g. Documents\\Hitomi). The browser rejects system folders such as Downloads, Documents or Desktop directly.',
    'Izin ke folder sebelumnya tidak tersedia.': 'Permission for the previous folder is not available.', 'Belum ada folder tujuan.': 'No destination folder yet.',
    'Hapus yang sudah selesai / gagal': 'Remove finished / failed items', 'Ciutkan / buka': 'Collapse / expand', '{0} berjalan': '{0} running',
    'Batalkan unduhan?': 'Cancel the download?', 'Batalkan': 'Cancel download', 'Lanjutkan': 'Keep going', 'Simpan': 'Save',
    'Selesai: {0} → {1}': 'Done: {0} → {1}', 'Unduhan selesai, tinggal disimpan ke folder: {0}': 'Download finished, just save it to the folder: {0}',
    'gagal': 'failed', 'Gagal': 'Failed', 'Semua gambar gagal diproses (lihat console F12)': 'All images failed to process (see console, F12)',
    'Gagal menyimpan: {0}': 'Failed to save: {0}',
    'toBlob gagal': 'toBlob failed', 'tidak ada URL': 'no URL', 'kamus gagal diunduh ({0})': 'dictionary download failed ({0})', 'jaringan/CORS': 'network/CORS',
    'Menunggu antrian · {0} gambar': 'Queued · {0} images', 'Menyusun file…': 'Building file…', 'Mengunduh {0} / {1}': 'Downloading {0} / {1}',
    ' · konversi JPG': ' · JPG conversion', ' (gagal: {0})': ' (failed: {0})', 'Siap — klik Simpan untuk menulis ke folder': 'Ready — click Save to write to the folder',
    'Selesai ✓ → {0}': 'Done ✓ → {0}', ' di folder "{0}"': ' in folder "{0}"',
    'Pilih gambar untuk diunduh': 'Choose images to download', 'Pilih semua': 'Select all', 'Balik pilihan': 'Invert selection',
    'Klik = pilih/batal · Shift+klik = rentang': 'Click = select/deselect · Shift+click = range',
    'Ukuran': 'Size', 'Kualitas JPG': 'JPG quality', 'Format': 'Format', 'Asli': 'Original', 'Simpan ke': 'Save to', 'Hasil': 'Output', 'Folder': 'Folder',
    'Pilih folder…': 'Choose folder…', 'Tutup': 'Close',
    '{0} / {1} dipilih': '{0} / {1} selected', 'Simpan {0} ({1})': 'Save {0} ({1})', 'Unduh {0} ({1})': 'Download {0} ({1})',
    'Siap mengunduh {0} gambar · berjalan di latar belakang, boleh pindah halaman': 'Ready to download {0} images · runs in the background, you can change pages',
    'Belum ada gambar dipilih': 'No images selected',
    'Galeri ini sudah pernah diunduh ({0}).': 'This gallery was already downloaded ({0}).', 'Hapus tanda': 'Remove mark',
    'Gagal menambahkan ke antrian: {0}': 'Failed to add to the queue: {0}',
    'Ditambahkan ke antrian: {0} gambar. Boleh pindah halaman, unduhan tetap jalan.': 'Added to queue: {0} images. You can change pages; the download keeps running.',
  };
  function T(s, ...a) {
    if (typeof s !== 'string') return s;
    const r = state.lang === 'en' && Object.prototype.hasOwnProperty.call(EN, s) ? EN[s] : s;
    return a.length ? r.replace(/\{(\d+)\}/g, (m, i) => (a[+i] != null ? a[+i] : m)) : r;
  }
  const tErr = (e) => { // pesan galat yang tersimpan/dibawa dari worker (bahasa Indonesia) -> bahasa aktif
    const x = String(e == null ? '' : e);
    let m = x.match(/^Gagal menyimpan: ([\s\S]*)$/); if (m) return T('Gagal menyimpan: {0}', m[1]);
    m = x.match(/^Error: ([\s\S]*)$/); if (m) return 'Error: ' + T(m[1]);
    m = x.match(/^kamus gagal diunduh \(([\s\S]*)\)$/); if (m) return T('kamus gagal diunduh ({0})', T(m[1]));
    return T(x);
  };
  const outName = (o) => T(o === 'pdf' ? 'PDF' : o === 'img' ? 'gambar' : 'ZIP');

  const isReader = location.pathname.startsWith('/reader/');
  const root = document.documentElement;
  let trimmed = false; // true setelah halaman siap: pagination bawah boleh tampil lagi
  const isGallery = !isReader && /^\/(doujinshi|manga|artistcg|gamecg|imageset|anime)\//.test(location.pathname);
  let thumbHost = null; // grid thumbnail besar buatan script
  let thumbOrig = null; // strip thumbnail asli situs (disembunyikan saat grid besar aktif)
  let navRO = null, navAttrMO = null, stk = [], stkRaf = 0, stkReady = false, stkBanTry = 0; // header tetap di atas (fixed + spacer); [{ el, sp, mt, mb }]
  let wtFailed = false; // true kalau webtoon gagal dibangun -> kembali ke reader asli

  /* ====================================================================
   * CSS
   * ==================================================================== */
  const css = `
  .hmt-hide { display: none !important; }
  /* pagination disembunyikan dulu sampai script memutuskan mana yang atas, supaya tidak muncul-hilang */
  html.hm-trim .page-container { visibility: hidden; }
  html.hm-dark { color-scheme: dark; }
  /* banner judul halaman ("Recently Added"): gelap sejak awal, tanpa menunggu deteksi JS */
  /* tanpa transisi: situs memberi transition pada latar, jadi saat digelapkan terlihat memudar dari terang -> kedip */
  html.hm-dark .hmt-banner, html.hm-dark .hmt-banner *, html.hm-dark .hmt-dk, html.hm-dark .navbar, html.hm-dark .navbar * { transition: none !important; }
  html.hm-dark .hmt-dk { background-color: #1a1a22 !important; color: #d6d6dc !important; border-color: #33334a !important; }
  /* ---------- GRID ---------- */
  html.hm-grid .container { width: 96% !important; max-width: 1900px !important; }
  html.hm-grid .gallery-content { display: none !important; }
  .hm-grid-wrap { display: none; }
  html.hm-grid .hm-grid-wrap {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(var(--hm-card, 190px), 1fr));
    gap: 12px; padding: 12px; box-sizing: border-box; width: 100%;
  }
  .hm-card {
    position: relative; display: block; overflow: hidden; aspect-ratio: 3 / 4.2;
    border-radius: 8px; background: #1d1d27; border: 1px solid #33334a;
    text-decoration: none !important;
    transition: transform .15s, box-shadow .15s, border-color .15s;
  }
  .hm-card:hover { transform: translateY(-2px); border-color: var(--hm-ac, #6b8cff); box-shadow: 0 6px 18px rgba(0,0,0,.45); z-index: 2; }
  .hm-card img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; transition: transform .25s; }
  .hm-card:hover img { transform: scale(1.04); }
  .hm-tags {
    position: absolute; inset: 0; padding: 30px 8px 74px; box-sizing: border-box;
    display: flex; flex-wrap: wrap; align-content: flex-start; gap: 3px; overflow: hidden;
    background: rgba(10,10,18,.88); opacity: 0; transition: opacity .15s; pointer-events: none;
  }
  .hm-card:hover .hm-tags { opacity: 1; }
  .hm-tags span { font: 10px/1 sans-serif; padding: 3px 5px; border-radius: 3px; background: #3b3f5c; color: #e6e8ff; }
  .hm-badges { position: absolute; top: 6px; left: 6px; right: 6px; display: flex; justify-content: space-between; pointer-events: none; }
  .hm-badge { font: 700 10px/1 sans-serif; padding: 3px 5px; border-radius: 4px; background: rgba(0,0,0,.75); color: #fff; }
  .hm-ov {
    position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 8px 7px;
    background: linear-gradient(transparent, rgba(0,0,0,.92)); pointer-events: none;
  }
  .hm-title {
    font: 700 12.5px/1.25 sans-serif; color: #fff; text-shadow: 0 1px 2px #000; word-break: break-word;
    display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
  }
  .hm-sub { font: 11px/1.3 sans-serif; color: var(--hm-act, #b9c4ff); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }


  /* ---------- KARTU DETAIL (cover kiri + tabel info kanan) ---------- */
  .hm-cover { position: absolute; inset: 0; overflow: hidden; }
  .hm-info { display: none; }
  html.hm-dt .hm-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(calc(var(--hm-card, 190px) * 2.3), 1fr)); }
  html.hm-dt .hm-card { display: flex; align-items: stretch; aspect-ratio: auto; background: #1a1a22; }
  html.hm-dt .hm-cover { position: relative; inset: auto; flex: 0 0 calc(var(--hm-card, 190px) * .78); aspect-ratio: 3 / 4.2; }
  html.hm-dt .hm-cover .hm-tags, html.hm-dt .hm-cover .hm-ov, html.hm-dt .hm-cover .hm-badges { display: none !important; }
  html.hm-dt .hm-info { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; overflow: hidden; }
  html.hm-dt .hm-it { padding: 8px 10px; background: var(--tc, #4a3340); color: #fff; font: 700 15px/1.25 sans-serif; word-break: break-word; flex: 0 0 auto; }
  html.hm-dt .hm-ia { flex: 0 0 auto; padding: 4px 10px; background: var(--ta, #33252f); color: var(--tt, #ffb3c8); font: 700 13px/1.3 sans-serif; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
  html.hm-dt .hm-rows { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 5px 14px; padding: 9px 10px; font: 12px/1.35 sans-serif; align-content: start; flex: 1 1 0; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: #4a4e70 transparent; overscroll-behavior: contain; }
  html.hm-dt .hm-k { font-weight: 700; color: #fff; }
  html.hm-dt .hm-v { color: #cfcfd8; word-break: break-word; }
  /* ---------- WARNA PER TYPE: --tc = bar judul, --ta = bar artis, --tt = teks aksen ---------- */
  .hm-card[data-type="doujinshi"] { --tc: #4a3340; --ta: #33252f; --tt: #ffb3c8; }
  .hm-card[data-type="manga"]     { --tc: #2d4a73; --ta: #213552; --tt: #9fc8ff; }
  .hm-card[data-type="imageset"]  { --tc: #2c5c4c; --ta: #1f4136; --tt: #8fe8c3; }
  .hm-card[data-type="artistcg"]  { --tc: #5b3a78; --ta: #3f2756; --tt: #d9aaff; }
  .hm-card[data-type="gamecg"]    { --tc: #6a4f22; --ta: #4a3817; --tt: #ffd277; }
  .hm-card[data-type="anime"]     { --tc: #2b5a63; --ta: #1e4047; --tt: #8fe3f0; }
  .hm-card[data-type] .hm-vtype { display: inline-block; justify-self: start; padding: 2px 8px; border-radius: 10px; font-weight: 700; background: var(--tc); color: var(--tt); box-shadow: inset 0 0 0 1px var(--tt); }
  .hm-card[data-type] .hm-badge:last-child { background: var(--tc); color: var(--tt); box-shadow: inset 0 0 0 1px var(--tt); }
  html.hm-dt .hm-chips { display: flex; flex-wrap: wrap; gap: 4px; }
  html.hm-dt .hm-chips span { font: 700 11px/1 sans-serif; padding: 4px 7px; border-radius: 4px; background: #3d4059; color: #e4e6ff; }
  html.hm-dt .hm-done { left: auto; right: 6px; top: 6px; }

  /* ---------- INTRO KARTU: kartu disembunyikan sebentar, lalu diputar animasinya satu per satu (lihat introWatch) ---------- */
  html.hmt-pre .hm-card, html.hmt-pre .hm-th { opacity: 0 !important; }
  [data-hmt-sr] { opacity: 0 !important; } /* menunggu masuk layar (animasi saat di-scroll) */
  html.hmt-pre-blk .cover-column, html.hmt-pre-blk .dj-gallery, html.hmt-pre-blk #gallery-brand, html.hmt-pre-blk .gallery-info { opacity: 0 !important; }
  html.hmt-pre-nav .navbar, html.hmt-pre-bn .hmt-banner:not(.navbar), html.hmt-pre-bn .list-title { opacity: 0 !important; }
  html.hmt-pre-navi .navbar *, html.hmt-pre-bni .hmt-banner:not(.navbar) *, html.hmt-pre-bni .list-title * { opacity: 0 !important; }

  /* ---------- DARK ---------- */
  html.hm-dark, html.hm-dark body { background: #14141a !important; color: #d6d6dc !important; }
  html.hm-dark .container, html.hm-dark .content, html.hm-dark .top-content,
  html.hm-dark .page-container, html.hm-dark .bottom-content,
  html.hm-dark .dj-gallery, html.hm-dark .cover-column {
    background: #14141a !important; color: #d6d6dc !important;
  }
  html.hm-dark td, html.hm-dark th, html.hm-dark .dj-content, html.hm-dark .dj-desc,
  html.hm-dark .gallery-info, html.hm-dark .gallery-info * { color: #cfcfd8 !important; }
  html.hm-dark h1, html.hm-dark h2 { color: #fff !important; }
  html.hm-dark a { color: #8fb0ff; }
  /* navbar tetap putih */
  html.hm-dark .navbar a, html.hm-dark .navbar-inner a, html.hm-dark .drop-button a { color: #fff !important; }
  html.hm-dark #lang-list { background: #22222e !important; }
  html.hm-dark #lang-list a { color: #ddd !important; }
  html.hm-dark #search-suggestions, html.hm-dark #search-suggestions * { background: #22222e !important; color: #ddd !important; }
  /* header judul gallery */
  html.hm-dark #gallery-brand { background: #4a3340 !important; }
  html.hm-dark #gallery-brand, html.hm-dark #gallery-brand a { color: #fff !important; }
  html.hm-dark #artists { background: #33252f !important; }
  html.hm-dark #artists a { color: #ffb3c8 !important; }
  /* tag chip */
  html.hm-dark .tags a { background: #3d4059 !important; color: #e4e6ff !important; }
  /* area thumbnail */
  html.hm-dark .gallery-preview, html.hm-dark .gallery-preview div { background: #1a1a22 !important; border-color: #33334a !important; }
  html.hm-dark .gallery-preview img { opacity: .9; }
  html.hm-dark .page-container li, html.hm-dark .page-container li a { color: #aab !important; background: transparent !important; }
  html.hm-dark input, html.hm-dark select { background: #2a2a36 !important; color: #ddd !important; border: 1px solid #444 !important; }

  /* ---------- NAVBAR FIX + HAPUS GLOW PUTIH ---------- */
  html.hm-grid .navbar { width: auto !important; max-width: none !important; }
  html.hm-dark body, html.hm-dark .container, html.hm-dark .content, html.hm-dark .top-content,
  html.hm-dark .page-container, html.hm-dark .bottom-content, html.hm-dark .dj-gallery,
  html.hm-dark .cover-column, html.hm-dark .gallery-content, html.hm-dark .navbar, html.hm-dark .navbar-inner {
    box-shadow: none !important; -webkit-box-shadow: none !important; filter: none !important;
  }
  html.hm-dark .container, html.hm-dark .content { border-color: transparent !important; }
  #hmt-nav a { cursor: pointer; }

  /* ---------- THUMBNAIL BESAR + HOVER PREVIEW ---------- */
  .hm-thumbs { display: none; }
  html.hm-thumbs-on .hm-thumbs {
    display: grid; grid-template-columns: repeat(auto-fill, minmax(var(--hm-tw, 160px), 1fr));
    gap: 10px; padding: 10px; align-items: start; box-sizing: border-box; width: 100%;
  }
  .hm-th { position: relative; display: block; line-height: 0; overflow: hidden; border-radius: 6px;
    background: #1d1d27; border: 1px solid #33334a; transition: border-color .12s; }
  .hm-th:hover { border-color: var(--hm-ac, #6b8cff); }
  .hm-th img { display: block; width: 100%; height: auto; background: #1a1a22; }
  .hm-thn { position: absolute; left: 6px; bottom: 6px; padding: 2px 6px; border-radius: 4px;
    background: rgba(0,0,0,.72); color: #fff; font: 700 11px/1.3 sans-serif; pointer-events: none; }
  #hmt-preview { position: fixed; left: 0; top: 0; z-index: 99998; display: none; pointer-events: none; line-height: 0;
    background: #0c0c10; border: 1px solid #33334a; border-radius: 8px; overflow: hidden;
    box-shadow: 0 12px 40px rgba(0,0,0,.6); will-change: transform; }
  #hmt-preview img { display: block; max-width: none !important; }
  .hmt-pv-n { position: absolute; left: 8px; bottom: 8px; padding: 2px 7px; border-radius: 5px;
    background: rgba(0,0,0,.72); color: #fff; font: 700 12px/1.3 sans-serif; }

  /* ---------- PANEL PENGATURAN ---------- */
  #hmt-overlay { position: fixed; inset: 0; z-index: 100000; display: none; align-items: center; justify-content: center; padding: 16px; box-sizing: border-box;
    background: rgba(8,8,14,.72); -webkit-backdrop-filter: blur(3px); backdrop-filter: blur(3px); transition: background-color .25s; }
  #hmt-overlay.open { display: flex; animation: hmt-fade .16s ease-out; }
  #hmt-overlay.open #hmt-panel { animation: hmt-pop .22s cubic-bezier(.22, 1, .36, 1); }
  #hmt-overlay.hmt-still, #hmt-overlay.hmt-still #hmt-panel { animation: none !important; }
  @keyframes hmt-fade { from { opacity: 0; } to { opacity: 1; } }
  @keyframes hmt-pop { from { opacity: 0; transform: translateY(12px) scale(.98); } to { opacity: 1; transform: none; } }
  @media (prefers-reduced-motion: reduce) { #hmt-overlay.open, #hmt-overlay.open #hmt-panel { animation: none; } }
  #hmt-overlay.hmt-pv { background: rgba(8,8,14,0) !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; }

  #hmt-panel { --p-card: #1f1f2b; --p-line: #2c2c3e; --p-mut: #8d8da8;
    width: min(840px, 100%); height: min(600px, 100%); display: flex; flex-direction: column; overflow: hidden; box-sizing: border-box;
    background: #171720; color: #e8e8f2; border: 1px solid #2f2f44; border-radius: 16px;
    box-shadow: 0 28px 80px rgba(0,0,0,.7); font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; }
  #hmt-panel *, #hmt-panel *::before, #hmt-panel *::after { box-sizing: border-box; }
  #hmt-panel button { all: unset; box-sizing: border-box; cursor: pointer; }
  #hmt-panel button:focus-visible, #hmt-panel .hmt-sel:focus-visible { outline: 2px solid var(--hm-ac, #5b7cff); outline-offset: 2px; }

  /* kepala */
  #hmt-panel .hmt-head { flex: none; display: flex; align-items: center; gap: 12px; padding: 14px 16px 14px 18px; border-bottom: 1px solid var(--p-line);
    background: linear-gradient(180deg, #1e1e2a, #171720); }
  #hmt-panel .hmt-logo { flex: none; width: 36px; height: 36px; display: grid; place-items: center; border-radius: 11px; font-size: 18px; color: #fff;
    background: var(--hm-acbg, #2c3050); box-shadow: inset 0 0 0 1px rgba(255,255,255,.08); }
  #hmt-panel .hmt-ttl { flex: 1; min-width: 0; }
  #hmt-panel .hmt-title { font-size: 16px; font-weight: 700; color: #fff; line-height: 1.2; }
  #hmt-panel .hmt-sub { margin-top: 2px; font-size: 12px; color: var(--p-mut); letter-spacing: .02em; }
  #hmt-panel .hmt-sub b { color: var(--hm-act, #b9c4ff); font-weight: 700; }
  #hmt-panel .hmt-close { flex: none; width: 32px; height: 32px; border-radius: 9px; text-align: center; line-height: 32px; font-size: 16px; color: #9a9ab5; }
  #hmt-panel .hmt-close:hover { background: #2a2a3b; color: #fff; }

  /* badan: menu kiri + isi */
  #hmt-panel .hmt-body { flex: 1; min-height: 0; display: flex; }
  #hmt-panel .hmt-nav { flex: none; width: 184px; padding: 12px 10px; border-right: 1px solid var(--p-line); background: #14141b;
    display: flex; flex-direction: column; gap: 3px; overflow-y: auto; scrollbar-width: thin; }
  #hmt-panel .hmt-tab { position: relative; display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 10px; color: #9a9ab5; font-weight: 600; font-size: 13px; transition: background .12s, color .12s; }
  #hmt-panel .hmt-tab:hover { background: #20202e; color: #fff; }
  #hmt-panel .hmt-tab.on { background: var(--hm-acbg, #2c3050); color: #fff; }
  #hmt-panel .hmt-tab.on::before { content: ''; position: absolute; left: -10px; top: 9px; bottom: 9px; width: 3px; border-radius: 0 3px 3px 0; background: var(--hm-ac, #5b7cff); }
  #hmt-panel .hmt-ic { flex: none; width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; background: rgba(255,255,255,.06); font-size: 14px; line-height: 1; }
  #hmt-panel .hmt-tab.on .hmt-ic { background: rgba(255,255,255,.12); }
  #hmt-panel .hmt-pane { display: none; flex: 1; min-width: 0; overflow-y: auto; padding: 14px 20px 22px; scrollbar-width: thin; scrollbar-color: #3a3a50 transparent; }
  #hmt-panel .hmt-pane.on { display: block; }

  /* bagian & baris */
  #hmt-panel .hmt-sec { margin: 20px 4px 8px; font-size: 11px; font-weight: 700; letter-spacing: .09em; text-transform: uppercase; color: #7f86b8; }
  #hmt-panel .hmt-pane > .hmt-sec:first-child { margin-top: 2px; }
  #hmt-panel .hmt-grp { background: var(--p-card); border: 1px solid var(--p-line); border-radius: 12px; overflow: hidden; }
  #hmt-panel .hmt-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 16px; transition: opacity .15s; }
  #hmt-panel .hmt-row + .hmt-row { border-top: 1px solid var(--p-line); }
  #hmt-panel .hmt-row.col { flex-direction: column; align-items: stretch; gap: 8px; }
  #hmt-panel .hmt-row.hmt-clk { cursor: pointer; }
  #hmt-panel .hmt-row.hmt-clk:hover { background: rgba(255,255,255,.025); }
  #hmt-panel .hmt-row.hmt-dis { opacity: .4; pointer-events: none; }
  #hmt-panel .hmt-txt { flex: 1; min-width: 0; }
  #hmt-panel .hmt-lbl { color: #ececf5; font-weight: 600; }
  #hmt-panel .hmt-desc { color: var(--p-mut); font-size: 12px; margin-top: 2px; line-height: 1.4; }

  /* kontrol */
  #hmt-panel .hmt-sw { position: relative; flex: none; width: 42px; height: 24px; border-radius: 12px; background: #3a3a50; transition: background .18s; }
  #hmt-panel .hmt-sw::after { content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .18s; }
  #hmt-panel .hmt-sw[aria-checked="true"] { background: var(--hm-ac, #5b7cff); }
  #hmt-panel .hmt-sw[aria-checked="true"]::after { transform: translateX(18px); }
  #hmt-panel .hmt-rangebox { flex: none; width: 230px; display: flex; align-items: center; gap: 12px; }
  #hmt-panel input.hmt-range { -webkit-appearance: none; appearance: none; flex: 1; height: 6px; border-radius: 3px; padding: 0; margin: 0; background: #3a3a50 !important; border: 0 !important; outline: none; }
  #hmt-panel input.hmt-range::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; cursor: pointer; }
  #hmt-panel input.hmt-range::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; cursor: pointer; }
  #hmt-panel .hmt-val { min-width: 56px; text-align: right; color: var(--hm-act, #b9c4ff); font-variant-numeric: tabular-nums; font-weight: 600; }
  #hmt-panel .hmt-seg { flex: none; width: 230px; display: flex; gap: 3px; padding: 3px; border-radius: 10px; background: #12121a; border: 1px solid var(--p-line); }
  #hmt-panel .hmt-seg button { flex: 1; text-align: center; padding: 6px 4px; border-radius: 7px; color: #9a9ab5; font-weight: 600; font-size: 12.5px; white-space: nowrap; }
  #hmt-panel .hmt-seg button:hover { color: #fff; }
  #hmt-panel .hmt-seg button.on { background: var(--hm-ac, #5b7cff); color: #fff; }
  #hmt-panel .hmt-sel { flex: none; width: 230px; padding: 8px 10px; border-radius: 9px; border: 1px solid #33334a; background: #12121a; color: #e6e6f0; font-family: inherit; font-size: 13px; font-weight: 600; line-height: 1.2; cursor: pointer; outline: none; }
  #hmt-panel .hmt-sel:focus { border-color: var(--hm-ac, #5b7cff); }
  #hmt-panel .hmt-sel option { background: #14141c; color: #e6e6f0; }
  #hmt-panel .hmt-reset { display: inline-block; padding: 7px 13px; border-radius: 8px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 12px; white-space: nowrap; text-align: center; }
  #hmt-panel .hmt-reset:hover { background: #3a3a52; color: #fff; }
  #hmt-panel .hmt-reset.warn:hover { background: #4b2733; color: #ffc0cc; }
  #hmt-panel kbd { font: 11px/1 ui-monospace, monospace; padding: 3px 6px; border-radius: 5px; background: #12121a; border: 1px solid #33334a; color: var(--hm-act, #b9c4ff); }
  #hmt-panel .hmt-badge { flex: none; padding: 5px 11px; border-radius: 999px; background: var(--hm-acbg, #2c3050); color: #fff; font-size: 12px; font-weight: 600; }
  #hmt-panel .hmt-badge b { color: var(--hm-act, #b9c4ff); }

  /* kaki */
  #hmt-panel .hmt-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 16px; border-top: 1px solid var(--p-line); background: #14141b; color: #7a7a95; font-size: 12px; }

  /* layar kecil */
  @media (max-width: 600px) {
    #hmt-overlay { padding: 8px; }
    #hmt-panel { height: 100%; }
    #hmt-panel .hmt-body { flex-direction: column; }
    #hmt-panel .hmt-nav { width: auto; flex-direction: row; border-right: 0; border-bottom: 1px solid var(--p-line); padding: 8px; overflow-x: auto; overflow-y: hidden; }
    #hmt-panel .hmt-tab { padding: 7px 10px; white-space: nowrap; }
    #hmt-panel .hmt-tab.on::before { display: none; }
    #hmt-panel .hmt-pane { padding: 12px 12px 18px; }
    #hmt-panel .hmt-row { flex-wrap: wrap; }
    #hmt-panel .hmt-rangebox, #hmt-panel .hmt-seg, #hmt-panel .hmt-sel { width: 100%; min-width: 0; }
  }
  #hmt-fab { position: fixed; right: 10px; bottom: 10px; z-index: 99999; width: 34px; height: 34px; border-radius: 50%; border: 1px solid #444; background: #222; color: #fff; cursor: pointer; opacity: .5; padding: 0; font-size: 16px; }
  #hmt-fab:hover { opacity: 1; }

  /* ---------- READER / WEBTOON ---------- */
  html.hm-webtoon { overflow-x: hidden !important; overflow-y: auto !important; height: auto !important; background: #0c0c10 !important; }
  html.hm-webtoon body { overflow: visible !important; height: auto !important; min-height: 100vh; background: #0c0c10 !important; }
  html.hm-webtoon #comicImages, html.hm-webtoon #mobileImages,
  html.hm-webtoon #preload, html.hm-webtoon #postload { display: none !important; }
  html.hm-webtoon .navbar-static-top { position: sticky !important; top: 0; z-index: 1000; }
  /* header tetap di atas saat scroll */
  .hmt-nav-fixed { position: fixed !important; z-index: 1000 !important; margin: 0 !important; box-sizing: border-box; background: #fff; }
  .hmt-banner.hmt-nav-fixed { z-index: 999 !important; }
  /* navbar + banner judul disembunyikan sampai posisi tetapnya siap (tidak ada loncat/kedip saat halaman dimuat) */
  html.hm-sticky:not(.hmt-ready) .navbar, html.hm-sticky:not(.hmt-ready) .container *:has(select):not(:has(.navbar, .gallery-content, .hm-grid-wrap, .page-container, .gallery-preview)):not(.navbar *):not(.gallery-content *):not(.hm-grid-wrap *) { visibility: hidden !important; }
  html.hm-dark .navbar, html.hm-dark .navbar-inner { background-color: #14141a !important; background-image: none !important; }
  html.hm-dark .hmt-nav-fixed, html.hm-dark .hmt-banner.hmt-nav-fixed { background: #14141a !important; background-image: none !important; }
  /* banner judul: polos seperti navbar, satu baris */
  .hmt-banner.hmt-nav-fixed { background-image: none !important; display: flex !important; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 12px !important; min-height: 40px; height: auto !important; overflow: visible !important; }
  .hmt-banner.hmt-nav-fixed .hmt-bt, .hmt-banner.hmt-nav-fixed :is(h1, h2, h3, h4) { position: static !important; float: none !important; transform: none !important; margin: 0 !important; padding: 0 !important; font-size: 22px !important; line-height: 1.2 !important; text-shadow: none !important; display: block; white-space: nowrap; }
  .hmt-banner.hmt-nav-fixed .hmt-bt:has(~ *), .hmt-banner.hmt-nav-fixed :is(h1, h2, h3, h4):has(~ *) { flex: 1 1 auto; }
  .hmt-banner.hmt-nav-fixed select { float: none !important; position: static !important; margin: 0 !important; }
  html.hm-webtoon li:has(> #nextPanel), html.hm-webtoon li:has(> #prevPanel),
  html.hm-webtoon li:has(> #fitVertical), html.hm-webtoon li:has(> #fitHorizontal),
  html.hm-webtoon li:has(> #fullSpread), html.hm-webtoon li:has(> #singlePage),
  html.hm-webtoon li:has(> #offsetPage) { display: none !important; }
  html:not(.hm-webtoon) #wt-container, html:not(.hm-webtoon) #wt-counter { display: none !important; }
  #wt-container { display: flex; flex-direction: column; align-items: center; width: 100%; padding: 0 0 80px; }
  #wt-container img { display: block; width: var(--hm-rw, 800px); max-width: 100%; height: auto; margin: 0; background: #1a1a22; }
  #wt-container img.wt-fail { min-height: 220px; outline: 2px dashed #a44; cursor: pointer; }
  #wt-counter { position: fixed; left: 10px; bottom: 10px; z-index: 99999; background: rgba(0,0,0,.7); color: #fff; padding: 4px 10px; border-radius: 12px; font: 13px sans-serif; }

  /* ---------- PANEL PILIH UNDUHAN (tanpa backdrop: situs di belakang tetap bisa dipakai) ---------- */
  #hmt-dl { position: fixed; inset: 0; z-index: 100001; pointer-events: none; color: #e6e6f0; text-align: left;
    font: 14px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; }
  #hmt-dl *, #hmt-dl *::before, #hmt-dl *::after { box-sizing: border-box; }
  #hmt-dl button { all: unset; box-sizing: border-box; cursor: pointer; }
  #hmt-dl .dl-box { position: fixed; pointer-events: auto; max-width: 100vw; max-height: 100vh; display: flex; flex-direction: column;
    background: #1b1b25; border: 1px solid #33334a; border-radius: 16px; box-shadow: 0 24px 70px rgba(0,0,0,.65); overflow: hidden; }
  #hmt-dl .dl-box.min { height: auto !important; min-height: 0 !important; }
  #hmt-dl .dl-box.min .dl-bar, #hmt-dl .dl-box.min .dl-grid, #hmt-dl .dl-box.min .dl-foot, #hmt-dl .dl-box.min .dl-rs { display: none !important; }
  #hmt-dl .dl-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 18px; border-bottom: 1px solid #2b2b3b;
    cursor: move; user-select: none; touch-action: none; }
  #hmt-dl .dl-title { font-size: 17px; font-weight: 700; color: #fff; }
  #hmt-dl .dl-sub { font-size: 12px; color: #8b8ba6; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #hmt-dl .dl-hb { display: flex; gap: 4px; flex: none; }
  #hmt-dl .dl-x { flex: none; width: 30px; height: 30px; border-radius: 8px; text-align: center; line-height: 30px; font-size: 18px; color: #9a9ab5; }
  #hmt-dl .dl-x:hover { background: #2a2a3b; color: #fff; }
  #hmt-dl .dl-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; padding: 10px 18px; border-bottom: 1px solid #2b2b3b; }
  #hmt-dl .dl-btn { padding: 7px 12px; border-radius: 8px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 12px; }
  #hmt-dl .dl-btn:hover { background: #3a3a52; color: #fff; }
  #hmt-dl .dl-btn.pri { background: var(--hm-ac, #5b7cff); color: #fff; font-size: 13px; padding: 9px 20px; }
  #hmt-dl .dl-btn.pri:hover { background: var(--hm-ac2, #6f8cff); }
  #hmt-dl .dl-btn.off { opacity: .4; pointer-events: none; }
  #hmt-dl .dl-lbl { font-size: 12px; font-weight: 600; color: #8b8ba6; margin-left: 6px; }
  #hmt-dl .dl-seg { display: flex; gap: 3px; padding: 3px; border-radius: 9px; background: #14141c; }
  #hmt-dl .dl-seg button { padding: 5px 12px; border-radius: 6px; color: #9a9ab5; font-weight: 600; font-size: 12px; }
  #hmt-dl .dl-seg button:hover { color: #fff; }
  #hmt-dl .dl-seg button.on { background: var(--hm-ac, #5b7cff); color: #fff; }
  #hmt-dl .dl-q { display: flex; align-items: center; gap: 8px; }
  #hmt-dl .dl-q input { -webkit-appearance: none; appearance: none; width: 110px; height: 6px; border-radius: 3px; padding: 0; margin: 0; background: #3a3a50 !important; border: 0 !important; outline: none; cursor: pointer; }
  #hmt-dl .dl-q input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; }
  #hmt-dl .dl-q input::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: var(--hm-ac, #5b7cff); border: 2px solid #fff; }
  #hmt-dl .dl-q span { min-width: 38px; color: var(--hm-act, #b9c4ff); font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; }
  #hmt-dl .dl-hide { display: none !important; }
  #hmt-dl .dl-folder { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #hmt-dl .dl-count { margin-left: auto; color: var(--hm-act, #b9c4ff); font-weight: 600; font-variant-numeric: tabular-nums; }
  #hmt-dl .dl-hint { font-size: 11px; color: #7a7a95; }
  /* spasi pakai padding pada .dl-item (bukan gap), supaya tidak bisa ditimpa CSS situs */
  #hmt-dl .dl-grid { flex: 1; min-height: 0; overflow-y: auto; display: grid; gap: 0;
    grid-template-columns: repeat(auto-fill, minmax(var(--dl-th, 150px), 1fr)); grid-auto-rows: max-content;
    padding: 12px 14px 20px; align-content: start; }
  #hmt-dl .dl-item { padding: 9px; cursor: pointer; user-select: none; }
  #hmt-dl .dl-cell { position: relative; aspect-ratio: 3 / 4.2; border-radius: 10px; overflow: hidden; border: 2px solid #33334a;
    background: #14141c; box-shadow: 0 3px 10px rgba(0,0,0,.35); transition: border-color .12s, transform .12s; }
  #hmt-dl .dl-item:hover .dl-cell { transform: translateY(-2px); }
  #hmt-dl .dl-cell img { display: block; width: 100%; height: 100%; object-fit: contain; transition: opacity .12s, filter .12s; pointer-events: none; }
  #hmt-dl .dl-cell.on { border-color: var(--hm-ac, #5b7cff); }
  #hmt-dl .dl-cell.off img { opacity: .28; filter: grayscale(1); }
  #hmt-dl .dl-n { position: absolute; left: 6px; bottom: 6px; padding: 2px 6px; border-radius: 4px; background: rgba(0,0,0,.72); color: #fff; font: 700 11px/1.3 sans-serif; }
  #hmt-dl .dl-ck { position: absolute; right: 6px; top: 6px; width: 22px; height: 22px; border-radius: 50%; background: rgba(0,0,0,.6);
    border: 2px solid #fff; color: #fff; font: 700 12px/18px sans-serif; text-align: center; }
  #hmt-dl .dl-cell.on .dl-ck { background: var(--hm-ac, #5b7cff); border-color: var(--hm-ac, #5b7cff); }
  #hmt-dl .dl-foot { display: flex; align-items: center; gap: 14px; padding: 12px 18px; border-top: 1px solid #2b2b3b; }
  #hmt-dl .dl-stat { flex: 1; min-width: 0; font-size: 12px; color: #8b8ba6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* handle resize di semua tepi & sudut */
  #hmt-dl .dl-rs { position: absolute; z-index: 6; touch-action: none; }
  #hmt-dl .dl-rs-n { top: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
  #hmt-dl .dl-rs-s { bottom: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
  #hmt-dl .dl-rs-e { right: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
  #hmt-dl .dl-rs-w { left: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
  #hmt-dl .dl-rs-ne { top: 0; right: 0; width: 14px; height: 14px; cursor: nesw-resize; }
  #hmt-dl .dl-rs-nw { top: 0; left: 0; width: 14px; height: 14px; cursor: nwse-resize; }
  #hmt-dl .dl-rs-se { bottom: 0; right: 0; width: 16px; height: 16px; cursor: nwse-resize; }
  #hmt-dl .dl-rs-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; }

  /* ---------- WIDGET ANTRIAN UNDUHAN (tetap tampil di semua halaman) ---------- */
  #hmt-dm { position: fixed; right: 16px; bottom: 16px; width: 340px; max-width: calc(100vw - 16px); z-index: 100002;
    background: #1b1b25; color: #e6e6f0; border: 1px solid #33334a; border-radius: 12px; overflow: hidden; text-align: left;
    box-shadow: 0 14px 40px rgba(0,0,0,.55); font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; }
  #hmt-dm *, #hmt-dm *::before, #hmt-dm *::after { box-sizing: border-box; }
  #hmt-dm.hide { display: none !important; }
  #hmt-dm button { all: unset; box-sizing: border-box; cursor: pointer; }
  #hmt-dm .dm-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 12px; background: #20202c;
    font-weight: 700; cursor: move; user-select: none; touch-action: none; }
  #hmt-dm .dm-hb { display: flex; gap: 2px; }
  #hmt-dm .dm-hb button { padding: 2px 8px; border-radius: 6px; color: #9a9ab5; font-size: 12px; font-weight: 600; }
  #hmt-dm .dm-hb button:hover { background: #2f2f44; color: #fff; }
  #hmt-dm .dm-body { max-height: 46vh; overflow-y: auto; padding: 2px 12px 8px; }
  #hmt-dm.min .dm-body { display: none; }
  #hmt-dm .dm-job { padding: 10px 0; border-top: 1px solid #2b2b3b; }
  #hmt-dm .dm-job:first-child { border-top: 0; }
  #hmt-dm .dm-t { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  #hmt-dm .dm-s { font-size: 12px; color: #8b8ba6; margin: 2px 0 7px; }
  #hmt-dm .dm-track { height: 6px; border-radius: 3px; background: #3a3a50; overflow: hidden; }
  #hmt-dm .dm-fill { height: 100%; width: 0; background: var(--hm-ac, #5b7cff); transition: width .25s; }
  #hmt-dm .dm-job.done .dm-fill, #hmt-dm .dm-job.ready .dm-fill { background: #3ecf8e; }
  #hmt-dm .dm-job.error .dm-fill { background: #ff6b6b; }
  #hmt-dm .dm-a { display: flex; gap: 6px; margin-top: 8px; justify-content: flex-end; }
  #hmt-dm .dm-a button { padding: 4px 11px; border-radius: 7px; background: #2a2a3b; color: #ddd; font-size: 12px; font-weight: 600; }
  #hmt-dm .dm-a button:hover { background: #3a3a52; color: #fff; }
  #hmt-dm .dm-a button.pri { background: var(--hm-ac, #5b7cff); color: #fff; }

  /* ---------- NOTIFIKASI (toast) & KONFIRMASI buatan sendiri ---------- */
  #hmt-toasts { position: fixed; z-index: 100010; display: flex; flex-direction: column;
    gap: 8px; align-items: stretch; pointer-events: none; width: min(420px, calc(100vw - 24px)); }
  /* posisi notifikasi (diatur di Pengaturan > Tampilan). bawah-kanan diberi jarak supaya tidak menimpa tombol ⚙ */
  #hmt-toasts, html[data-hmt-tp="br"] #hmt-toasts { right: 14px; bottom: 56px; left: auto; top: auto; transform: none; }
  html[data-hmt-tp="bl"] #hmt-toasts { left: 14px; bottom: 22px; right: auto; top: auto; transform: none; }
  html[data-hmt-tp="bc"] #hmt-toasts { left: 50%; bottom: 22px; right: auto; top: auto; transform: translateX(-50%); }
  html[data-hmt-tp="tr"] #hmt-toasts { right: 14px; top: 96px; left: auto; bottom: auto; transform: none; }
  html[data-hmt-tp="tl"] #hmt-toasts { left: 14px; top: 96px; right: auto; bottom: auto; transform: none; }
  html[data-hmt-tp="tc"] #hmt-toasts { left: 50%; top: 96px; right: auto; bottom: auto; transform: translateX(-50%); }
  .hmt-toast { pointer-events: auto; display: flex; align-items: center; gap: 10px; width: 100%; padding: 11px 12px 11px 14px; border-radius: 12px;
    background: #20202c; color: #eceef8; border: 1px solid #3a3a52; border-left: 4px solid var(--hm-ac, #5b7cff); box-shadow: 0 10px 30px rgba(0,0,0,.5);
    font: 13px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; animation: hmt-in .18s ease-out; }
  .hmt-toast.ok { border-left-color: #3ecf8e; }
  .hmt-toast.err { border-left-color: #ff6b6b; }
  .hmt-toast.out { opacity: 0; transform: translateY(8px); transition: opacity .18s, transform .18s; }
  .hmt-toast .t-ico { flex: none; font-weight: 700; }
  .hmt-toast.ok .t-ico { color: #3ecf8e; } .hmt-toast.err .t-ico { color: #ff6b6b; } .hmt-toast.info .t-ico { color: var(--hm-act, #8fa5ff); }
  .hmt-toast .t-msg { flex: 1; min-width: 0; word-break: break-word; }
  .hmt-toast button { all: unset; box-sizing: border-box; cursor: pointer; padding: 4px 10px; border-radius: 7px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 12px; }
  .hmt-toast button:hover { background: #3a3a52; color: #fff; }
  .hmt-toast button.pri { background: var(--hm-ac, #5b7cff); color: #fff; }
  .hmt-toast button.x { padding: 2px 7px; color: #8b8ba6; font-size: 14px; background: transparent; }
  @keyframes hmt-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
  #hmt-modal { position: fixed; inset: 0; z-index: 100020; display: flex; align-items: center; justify-content: center; background: rgba(8,8,14,.6);
    font: 14px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; }
  #hmt-modal .m-box { width: min(380px, calc(100vw - 32px)); padding: 20px; border-radius: 14px; background: #1b1b25; color: #e6e6f0;
    border: 1px solid #33334a; box-shadow: 0 24px 70px rgba(0,0,0,.6); animation: hmt-in .15s ease-out; }
  #hmt-modal .m-title { font-size: 16px; font-weight: 700; color: #fff; }
  #hmt-modal .m-text { margin-top: 6px; color: #a9a9c2; font-size: 13px; word-break: break-word; }
  #hmt-modal .m-act { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; }
  #hmt-modal button { all: unset; box-sizing: border-box; cursor: pointer; padding: 8px 16px; border-radius: 8px; background: #2a2a3b; color: #ddd; font-weight: 600; font-size: 13px; }
  #hmt-modal button:hover { background: #3a3a52; color: #fff; }
  #hmt-modal button.pri { background: var(--hm-ac, #5b7cff); color: #fff; }
  #hmt-modal button.danger { background: #e5484d; color: #fff; }
  /* kolom search diperpanjang */
  html.hm-search .header-table { width: min(var(--hm-sw, 520px), 100%) !important; max-width: none !important; }
  html.hm-search .header-table .search-input, html.hm-search #search { flex: 1 1 auto; }
  html.hm-search #query-input { width: 100% !important; }
  /* pilihan warna aksen */
  #hmt-panel .hmt-acc { flex: none; display: flex; align-items: center; gap: 9px; }
  #hmt-panel .hmt-dot { width: 22px; height: 22px; border-radius: 50%; border: 2px solid transparent; box-shadow: 0 0 0 1px rgba(0,0,0,.5); transition: transform .12s; }
  #hmt-panel .hmt-dot:hover { transform: scale(1.15); }
  #hmt-panel .hmt-dot.on { border-color: #fff; }
  #hmt-panel input.hmt-color { width: 30px; height: 26px; padding: 0; margin: 0; border: 0; background: none; cursor: pointer; }
  /* penanda sudah diunduh */
  .hm-done { position: absolute; left: 6px; top: 27px; padding: 3px 6px; border-radius: 4px; pointer-events: none;
    background: var(--hm-ac, #5b7cff); color: #fff; font: 700 10px/1 sans-serif; box-shadow: 0 1px 4px rgba(0,0,0,.5); }
  .hm-card.is-done img { filter: saturate(.55) brightness(.78); }

  /* ---------- FILTER TAG ---------- */
  #hmt-tf { position: fixed; top: 56px; right: 12px; bottom: 12px; width: min(340px, calc(100vw - 24px)); z-index: 99990; display: none; flex-direction: column; overflow: hidden;
    background: #1a1a24; color: #e6e6f0; border: 1px solid #33334a; border-radius: 16px; box-shadow: 0 20px 60px rgba(0,0,0,.6);
    font: 13px/1.4 system-ui, -apple-system, 'Segoe UI', sans-serif; text-align: left; }
  #hmt-tf.open { display: flex; }
  #hmt-tf *, #hmt-tf *::before, #hmt-tf *::after { box-sizing: border-box; }
  #hmt-tf button { all: unset; box-sizing: border-box; cursor: pointer; }
  #hmt-tf .tf-head { flex: none; display: flex; align-items: center; gap: 10px; padding: 12px 14px 12px 16px; }
  #hmt-tf .tf-title { flex: 1; font-size: 15px; font-weight: 700; color: #fff; }
  #hmt-tf .tf-close { width: 28px; height: 28px; border-radius: 8px; text-align: center; line-height: 28px; font-size: 16px; color: #9a9ab5; }
  #hmt-tf .tf-close:hover { background: #2a2a3b; color: #fff; }
  #hmt-tf .tf-top { flex: none; display: flex; flex-direction: column; gap: 8px; padding: 0 14px 12px; border-bottom: 1px solid #2b2b3b; }
  #hmt-tf .tf-seg { display: flex; gap: 4px; padding: 3px; border-radius: 11px; background: #12121a; }
  #hmt-tf .tf-seg button { flex: 1; display: flex; align-items: center; justify-content: center; gap: 6px; padding: 7px 0; border-radius: 8px; color: #8e8ea8; font-weight: 600; font-size: 12.5px; }
  #hmt-tf .tf-seg button:hover { color: #fff; }
  #hmt-tf .tf-seg button i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; opacity: .85; }
  #hmt-tf .tf-seg button[data-v="inc"].on { background: #1f4033; color: #a8f0cc; }
  #hmt-tf .tf-seg button[data-v="exc"].on { background: #4a2530; color: #ffb3c0; }
  #hmt-tf .tf-search { position: relative; }
  #hmt-tf input.tf-in { width: 100%; height: 34px; padding: 0 30px 0 12px; border-radius: 10px; background: #12121a !important; color: #eee !important; border: 1px solid #2f2f45 !important; outline: none; font: 13px system-ui, sans-serif; }
  #hmt-tf input.tf-in:focus { border-color: #5b5f8a !important; }
  #hmt-tf .tf-x { display: none; position: absolute; right: 4px; top: 5px; width: 24px; height: 24px; border-radius: 6px; text-align: center; line-height: 24px; color: #8e8ea8; font-size: 13px; }
  #hmt-tf .tf-x.on { display: block; }
  #hmt-tf .tf-x:hover { background: #2a2a3b; color: #fff; }
  #hmt-tf .tf-scroll { flex: 1; min-height: 0; overflow-y: auto; padding: 12px 14px 14px; scrollbar-width: thin; scrollbar-color: #3a3a50 transparent; }
  #hmt-tf .tf-hint { padding: 10px 12px; border-radius: 10px; background: #12121a; color: #8e8ea8; font-size: 12px; margin-bottom: 14px; }
  #hmt-tf .tf-active { display: flex; flex-direction: column; gap: 10px; padding: 12px; margin-bottom: 14px; border-radius: 12px; background: #12121a; }
  #hmt-tf .tf-code { padding: 7px 9px; border-radius: 8px; background: #1a1a24; color: #b9c4ff; font: 12px/1.4 ui-monospace, Consolas, monospace; word-break: break-all; user-select: all; }
  #hmt-tf .tf-lab { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 7px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #7f86b8; }
  #hmt-tf .tf-lab em { font-style: normal; font-weight: 500; letter-spacing: 0; text-transform: none; color: #6c6c88; }
  #hmt-tf .tf-chips { display: flex; flex-wrap: wrap; gap: 6px; }
  #hmt-tf .tf-empty { color: #6c6c88; font-size: 12px; padding: 4px 0; }
  #hmt-tf .tf-chip { display: inline-flex; align-items: center; gap: 5px; padding: 4px 9px; border-radius: 8px; background: #262636; color: #d3d5ee; font-size: 12px; font-weight: 600; border: 1px solid transparent; transition: background .12s; }
  #hmt-tf .tf-chip:hover { background: #313147; }
  #hmt-tf .tf-chip small { color: #7d7d99; font-weight: 500; font-size: 11px; }
  #hmt-tf .tf-chip.hide { background: #4a2530; color: #ffb3c0; border-color: #6e3040; }
  #hmt-tf .tf-chip.hide:hover { background: #5a2d3a; }
  #hmt-tf .tf-chip.req { background: #1f4033; color: #a8f0cc; border-color: #2b5f49; }
  #hmt-tf .tf-chip.req:hover { background: #274d3e; }
  #hmt-tf .tf-chip.hide small, #hmt-tf .tf-chip.req small { color: inherit; opacity: .7; }
  #hmt-tf .tf-ns { font-size: 11px; font-weight: 700; opacity: .9; }
  #hmt-tf .tf-ns.f { color: #ff9ec4; }
  #hmt-tf .tf-ns.m { color: #8fc2ff; }
  #hmt-tf .tf-ns.o { color: #c9b27a; font-weight: 600; }
  #hmt-tf .tf-chip.hide .tf-ns, #hmt-tf .tf-chip.req .tf-ns { color: inherit; }
  #hmt-tf .tf-foot { flex: none; display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 14px; border-top: 1px solid #2b2b3b; }
  #hmt-tf .tf-clear { padding: 8px 12px; border-radius: 9px; background: #262636; color: #d3d5ee; font-weight: 600; font-size: 12.5px; white-space: nowrap; }
  #hmt-tf .tf-clear:hover { background: #34344c; color: #fff; }
  #hmt-tf .tf-go { flex: 1; display: block; text-align: center; padding: 9px 12px; border-radius: 9px; background: #3fa77a; color: #fff; font-weight: 700; font-size: 13.5px; }
  #hmt-tf .tf-go:hover { background: #48b988; }
  #hmt-tf .tf-go.dis { background: #2a2a3b; color: #6c6c88; cursor: default; }
  `;
  const style = document.createElement('style');
  style.id = 'hmt-style';
  style.textContent = css;

  // Style dipasang sedini mungkin (document-start) dan dipasang ulang kalau situs membuangnya.
  function ensureStyle() {
    if (!style.isConnected) (document.head || root).appendChild(style);
  }

  /* ====================================================================
   * Apply state (idempoten: tidak mengubah DOM kalau sudah sesuai)
   * ==================================================================== */
  function mixHex(a, b, t) { // campur warna a dengan b sebesar t (0..1)
    const n = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
    const A = n(a), B = n(b);
    return '#' + A.map((v, i) => Math.round(v + (B[i] - v) * t).toString(16).padStart(2, '0')).join('');
  }
  function setVar(name, val) {
    if (root.style.getPropertyValue(name) !== val) root.style.setProperty(name, val);
  }
  function apply() {
    ensureStyle();
    root.classList.toggle('hm-dark', !!state.dark);
    root.setAttribute('data-hmt-tp', state.toastPos || 'br');
    root.classList.toggle('hm-grid', !!(state.grid && !isReader));
    root.classList.toggle('hm-dt', state.cardStyle === 'detail');
    root.classList.toggle('hm-trim', !isReader && !trimmed);
    const tOn = !!(state.thumbs && isGallery && thumbHost && thumbHost.isConnected && thumbOrig && thumbOrig.isConnected);
    root.classList.toggle('hm-thumbs-on', tOn);
    if (thumbOrig) thumbOrig.classList.toggle('hmt-hide', tOn);
    setVar('--hm-tw', state.thumbW + 'px');
    root.classList.toggle('hm-webtoon', !!(state.webtoon && isReader && !wtFailed));
    root.classList.toggle('hm-search', !!state.searchOn);
    root.classList.toggle('hm-sticky', !!state.stickyNav);
    root.classList.toggle('hmt-ready', stkReady || !state.stickyNav || isReader);
    stickyNavSync();
    setVar('--hm-sw', state.searchW + 'px');
    setVar('--hm-card', state.cardW + 'px');
    const ac = /^#[0-9a-f]{6}$/i.test(state.accent) ? state.accent : '#5b7cff';
    setVar('--hm-ac', ac); setVar('--hm-ac2', mixHex(ac, '#ffffff', 0.18));
    setVar('--hm-act', mixHex(ac, '#ffffff', 0.6)); setVar('--hm-acbg', mixHex(ac, '#1b1b25', 0.72));
    setVar('--hm-rw', state.readerW ? state.readerW + 'px' : '100%');
  }
  // Banner judul: selector-nya diingat dari kunjungan sebelumnya, supaya sudah polos/gelap sejak awal (tanpa kedip)
  (function injectBannerPre() {
    if (isReader) return;
    const sel = get('bannerSel', '');
    if (!sel || !/^[#.\w\-\\:\s>]+$/.test(sel)) return;
    const st = document.createElement('style');
    st.id = 'hmt-style-pre';
    st.textContent = 'html.hm-dark ' + sel + ' { background-color: #14141a !important; background-image: none !important; }\n' +
      'html.hm-sticky:not(.hmt-ready) ' + sel + ' { visibility: hidden !important; }';
    root.appendChild(st);
  })();
  setTimeout(() => { stkReady = true; root.classList.add('hmt-ready'); }, 1500); // jaga-jaga: jangan sampai tersembunyi terus
  apply(); // langsung, sebelum halaman sempat tergambar -> tidak ada "kedip" tampilan asli

  // Self-heal: kalau situs menimpa class/style di <html>, pulihkan segera.
  new MutationObserver(apply).observe(root, { attributes: true, attributeFilter: ['class', 'style'] });


  /* ====================================================================
   * Header tetap di atas (fixed + spacer; tidak bergantung struktur induk)
   * ==================================================================== */
  // Navbar + banner judul ("Recently Added") ditumpuk tetap di atas. Tidak ada toggle class saat layout -> tidak kedip.
  function stickyNavOff() {
    if (navRO) { navRO.disconnect(); navRO = null; }
    if (navAttrMO) { navAttrMO.disconnect(); navAttrMO = null; }
    stk.forEach((it) => {
      it.el.classList.remove('hmt-nav-fixed');
      it.el.style.left = it.el.style.width = it.el.style.top = '';
      it.sp.remove();
    });
    stk = [];
  }
  function stickyNavLayout() {
    if (stkRaf) return;
    stkRaf = requestAnimationFrame(() => { stkRaf = 0; stickyNavLayoutNow(); });
  }
  function stickyNavLayoutNow() {
    {
      let top = stk.length ? stk[0].mt : 0;
      for (const it of stk) {
        if (!it.el.isConnected || !it.sp.isConnected) continue;
        const h = it.el.offsetHeight;
        const r = it.sp.getBoundingClientRect();
        const sh = (h + it.mt + it.mb) + 'px';
        if (it.sp.style.height !== sh) it.sp.style.height = sh;
        const L = r.left + 'px', W = r.width + 'px', T = top + 'px';
        if (it.el.style.left !== L) it.el.style.left = L;
        if (it.el.style.width !== W) it.el.style.width = W;
        if (it.el.style.top !== T) it.el.style.top = T;
        top += h;
      }
    }
  }
  function findBanner() {
    const sels = [...document.querySelectorAll('select')].filter((x) => !x.closest('.navbar, #hmt-overlay, #hmt-dl, #hmt-tf, #hmt-panel, .hm-grid-wrap, .gallery-content'));
    for (const sel of sels) {
      for (let e = sel.parentElement; e && e !== document.body && !e.classList.contains('container'); e = e.parentElement) {
        if (e.querySelector('.hm-grid-wrap, .gallery-content, .navbar')) break;
        if (e.querySelector('h1, h2, h3') && e.offsetHeight < 160) return e;
      }
    }
    return null;
  }
  function stickyNavSync() {
    try { stickyNavSyncImpl(); } catch (e) { console.warn('[HMT] sticky header gagal', e); }
  }
  function stickyNavSyncImpl() {
    if (isReader || !document.body) return;
    if (!state.stickyNav) { stickyNavOff(); return; }
    if (stk.length && stk.every((it) => it.el.isConnected && it.sp.isConnected)) {
      if (stk.length > 1) return; // navbar + banner sudah terkunci
      const now = Date.now();
      if (now - stkBanTry < 400) return; // banner belum ada: cek paling sering tiap 0,4 dtk
      stkBanTry = now;
      if (!findBanner()) return;
    }
    stickyNavOff();
    const bannerEl = findBanner();
    if (bannerEl) {
      let sel = '';
      if (bannerEl.id) sel = '#' + bannerEl.id.replace(/[^\w-]/g, '');
      else {
        const cl = [...bannerEl.classList].filter((c) => !/^(hmt-|hm-)/.test(c) && /^[\w-]+$/.test(c)).slice(0, 3);
        if (cl.length) sel = bannerEl.tagName.toLowerCase() + '.' + cl.join('.');
      }
      if (sel && get('bannerSel', '') !== sel) set('bannerSel', sel);
    }
    const els = [document.querySelector('.navbar-static-top, .navbar'), bannerEl].filter(Boolean);
    if (!els.length) return;
    els.forEach((el) => {
      const cs = getComputedStyle(el);
      const sp = document.createElement('div');
      sp.className = 'hmt-nav-spacer';
      if (el !== els[0]) {
        el.classList.add('hmt-banner');
        // tandai elemen yang memuat tulisan judul (apa pun tag-nya), supaya tidak terpotong
        const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
        let n;
        while ((n = tw.nextNode())) {
          if (!n.nodeValue.trim() || n.parentElement.closest('select, option, script, style')) continue;
          n.parentElement.classList.add('hmt-bt');
          break;
        }
      }
      sp.style.cssText = 'width:100%;pointer-events:none;';
      sp.style.height = el.offsetHeight + 'px';
      el.before(sp);
      stk.push({ el, sp, mt: parseFloat(cs.marginTop) || 0, mb: parseFloat(cs.marginBottom) || 0 });
    });
    stickyNavLayoutNow(); // posisi/ukuran dihitung dulu (sinkron), baru di-fixed -> tidak ada frame "loncat"
    stk.forEach((it) => it.el.classList.add('hmt-nav-fixed'));
    stickyNavLayoutNow();
    stkReady = true; root.classList.add('hmt-ready');
    // kalau skrip situs menimpa class/style elemen ini, pulihkan seketika (sebelum paint) -> tidak kedip
    navAttrMO = new MutationObserver(() => {
      let fix = false;
      stk.forEach((it, i) => {
        if (!it.el.classList.contains('hmt-nav-fixed')) { it.el.classList.add('hmt-nav-fixed'); fix = true; }
        if (i > 0 && !it.el.classList.contains('hmt-banner')) it.el.classList.add('hmt-banner');
      });
      if (fix) stickyNavLayoutNow();
    });
    stk.forEach((it) => navAttrMO.observe(it.el, { attributes: true, attributeFilter: ['class', 'style'] }));
    if (window.ResizeObserver) { navRO = new ResizeObserver(stickyNavLayout); stk.forEach((it) => { navRO.observe(it.sp); navRO.observe(it.el); }); }
  }
  window.addEventListener('resize', stickyNavLayout);
  onReady(() => { stickyNavSync(); setTimeout(stickyNavSync, 600); setInterval(stickyNavSync, 2000); });

  // gaya animasi: kf = keyframe, ease = easing, d = pengali durasi
  const INTRO_FX = {
    rise:  { label: 'Naik',            ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateY(28px) scale(.95)' }, { opacity: 1, transform: 'none' }] },
    drop:  { label: 'Turun',           ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateY(-28px) scale(.95)' }, { opacity: 1, transform: 'none' }] },
    slide: { label: 'Geser dari kiri', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateX(-56px)' }, { opacity: 1, transform: 'none' }] },
    pop:   { label: 'Pop (membal)',    ease: 'ease-out', d: 1.1, kf: [{ opacity: 0, transform: 'scale(.55)' }, { opacity: 1, transform: 'scale(1.06)', offset: .6 }, { opacity: 1, transform: 'none' }] },
    flip:  { label: 'Balik 3D',        ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.15,
             kf: [{ opacity: 0, transform: 'perspective(700px) rotateX(-75deg) translateY(24px)', transformOrigin: '50% 0%' }, { opacity: 1, transform: 'perspective(700px) rotateX(0deg) translateY(0)', transformOrigin: '50% 0%' }] },
    blur:  { label: 'Kabur ke jelas',  ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, filter: 'blur(14px)', transform: 'scale(1.08)' }, { opacity: 1, filter: 'blur(0px)', transform: 'none' }] },
    wipe:  { label: 'Tirai (kiri ke kanan)', ease: 'cubic-bezier(.65, 0, .35, 1)', kf: [{ clipPath: 'inset(0 100% 0 0 round 8px)' }, { clipPath: 'inset(0 0 0 0 round 8px)' }] },
    fade:  { label: 'Fade polos',      ease: 'ease-out', kf: [{ opacity: 0 }, { opacity: 1 }] },
    right: { label: 'Geser dari kanan', ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translateX(56px)' }, { opacity: 1, transform: 'none' }] },
    diag:  { label: 'Geser diagonal',  ease: 'cubic-bezier(.22, 1, .36, 1)', kf: [{ opacity: 0, transform: 'translate(-34px, 34px)' }, { opacity: 1, transform: 'none' }] },
    zoomin: { label: 'Zoom mendekat',  ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.15, kf: [{ opacity: 0, transform: 'scale(1.3)' }, { opacity: 1, transform: 'none' }] },
    bounce: { label: 'Memantul dari bawah', ease: 'linear', d: 1.35,
              kf: [{ opacity: 0, transform: 'translateY(64px)', easing: 'cubic-bezier(.3, .9, .4, 1)' },
                   { opacity: 1, transform: 'translateY(-14px)', offset: .55, easing: 'ease-in-out' },
                   { opacity: 1, transform: 'translateY(5px)', offset: .78, easing: 'ease-in-out' },
                   { opacity: 1, transform: 'none' }] },
    swing: { label: 'Miring masuk',    ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'rotate(-9deg) translateY(30px)', transformOrigin: '0% 100%' }, { opacity: 1, transform: 'none', transformOrigin: '0% 100%' }] },
    skew:  { label: 'Geser miring',    ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.05, kf: [{ opacity: 0, transform: 'translateX(-46px) skewX(-14deg)' }, { opacity: 1, transform: 'none' }] },
    spin:  { label: 'Putar masuk',     ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, transform: 'rotate(-120deg) scale(.4)' }, { opacity: 1, transform: 'none' }] },
    flipy: { label: 'Balik horizontal', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, transform: 'perspective(700px) rotateY(-80deg)', transformOrigin: '0% 50%' }, { opacity: 1, transform: 'perspective(700px) rotateY(0deg)', transformOrigin: '0% 50%' }] },
    tilt:  { label: 'Rebah 3D',        ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.15, kf: [{ opacity: 0, transform: 'perspective(700px) rotateX(70deg) translateY(30px)', transformOrigin: '50% 100%' }, { opacity: 1, transform: 'perspective(700px) rotateX(0deg) translateY(0)', transformOrigin: '50% 100%' }] },
    iris:  { label: 'Iris (lingkaran membuka)', ease: 'cubic-bezier(.65, 0, .35, 1)', d: 1.2, kf: [{ clipPath: 'circle(0% at 50% 50%)' }, { clipPath: 'circle(75% at 50% 50%)' }] },
    wipeup: { label: 'Tirai (bawah ke atas)', ease: 'cubic-bezier(.65, 0, .35, 1)', kf: [{ clipPath: 'inset(100% 0 0 0 round 8px)' }, { clipPath: 'inset(0 0 0 0 round 8px)' }] },
  };
  const INTRO_STYLE_OPTS = Object.keys(INTRO_FX).map((k) => [k, INTRO_FX[k].label]).concat([['mix', 'Campur (acak tiap kartu)']]);
  const INTRO = { dur: 750, maxStagger: 1800, settle: 250, maxWait: 3500 };
  // gaya animasi header (navbar / banner judul). 'items' = tiap bagian (logo, menu, search) muncul satu per satu
  const HEAD_FX = {
    none:   { label: 'Tanpa animasi' },
    slide:  { label: 'Geser dari atas',  ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'translateY(-100%)' }, { opacity: 1, transform: 'none' }] },
    fade:   { label: 'Fade polos',       ease: 'ease-out', d: 1.2, kf: [{ opacity: 0 }, { opacity: 1 }] },
    wipe:   { label: 'Tirai (kiri ke kanan)', ease: 'cubic-bezier(.65, 0, .35, 1)', d: 1.3, kf: [{ clipPath: 'inset(0 100% 0 0)' }, { clipPath: 'inset(0 0 0 0)' }] },
    unfold: { label: 'Buka dari atas',   ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'scaleY(0)', transformOrigin: '50% 0%' }, { opacity: 1, transform: 'scaleY(1)', transformOrigin: '50% 0%' }] },
    blur:   { label: 'Kabur ke jelas',   ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, filter: 'blur(12px)' }, { opacity: 1, filter: 'blur(0px)' }] },
    zoom:   { label: 'Zoom masuk',       ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'scale(.9)' }, { opacity: 1, transform: 'none' }] },
    left:   { label: 'Geser dari kiri',  ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'translateX(-60px)' }, { opacity: 1, transform: 'none' }] },
    right:  { label: 'Geser dari kanan', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.1, kf: [{ opacity: 0, transform: 'translateX(60px)' }, { opacity: 1, transform: 'none' }] },
    bounce: { label: 'Turun memantul',   ease: 'linear', d: 1.5,
              kf: [{ opacity: 0, transform: 'translateY(-100%)', easing: 'cubic-bezier(.3, .9, .4, 1)' },
                   { opacity: 1, transform: 'translateY(8px)', offset: .6, easing: 'ease-in-out' },
                   { opacity: 1, transform: 'translateY(-3px)', offset: .82, easing: 'ease-in-out' },
                   { opacity: 1, transform: 'none' }] },
    flipx:  { label: 'Balik 3D (dari atas)', ease: 'cubic-bezier(.22, 1, .36, 1)', d: 1.2, kf: [{ opacity: 0, transform: 'perspective(900px) rotateX(-90deg)', transformOrigin: '50% 0%' }, { opacity: 1, transform: 'perspective(900px) rotateX(0deg)', transformOrigin: '50% 0%' }] },
    iris:   { label: 'Iris (melebar dari tengah)', ease: 'cubic-bezier(.65, 0, .35, 1)', d: 1.3, kf: [{ clipPath: 'inset(0 50% 0 50%)' }, { clipPath: 'inset(0 0 0 0)' }] },
    items:  { label: 'Bagian satu per satu' },
  };
  const BLOCK_STYLE_OPTS = [['none', 'Tanpa animasi']].concat(Object.keys(INTRO_FX).map((k) => [k, INTRO_FX[k].label]));
  const HEAD_STYLE_OPTS = Object.keys(HEAD_FX).map((k) => [k, HEAD_FX[k].label]);


  /* ====================================================================
   * Panel Pengaturan (menu "Pengaturan" di navbar atau Alt+S)
   * ==================================================================== */
  const DEFAULTS = { dark: true, grid: true, webtoon: true, cardW: 190, cardStyle: 'detail', readerW: 800,
    thumbs: true, thumbReader: false, thumbW: 160, pvOn: true, pvSize: 80, pvDelay: 150, pvMode: 'side', dlPanel: true, dlFmt: 'orig', dlQ: 90, dlOut: 'zip', dlDest: 'dl', dlThumb: 150, accent: '#5b7cff', dlMark: true, searchOn: true, stickyNav: true, introOn: true, toastPos: 'br', introScroll: true, introScrollStyle: 'same', introBlock: 'rise', introNav: 'slide', introBanner: 'wipe', introStyle: 'rise', introOrder: 'seq', introSpeed: 1, introStep: 80, searchW: 520, romaji: true, romajiKanji: true, romajiLong: 'simple', tfInc: [], tfExc: [] };
  let buildWebtoonFn = null; // diisi di bagian reader
  const refreshers = [];
  let overlay = null;
  let panelReady = false;

  function afterChange(key) {
    if (key === 'webtoon' && state.webtoon && isReader && !window.__wtReady && buildWebtoonFn) {
      wtFailed = false; apply(); buildWebtoonFn();
    } else apply();
    if (key === 'dark' && state.dark && !isReader) rescanDark();
    if (key === 'thumbs' || key === 'thumbReader') rebuildThumbs();
    if (key === 'pvOn') hidePreview();
    if (key === 'dlMark') markCards();
    if (key === 'romaji' || key === 'romajiKanji' || key === 'romajiLong') {
      rmCache.clear();
      if (kmState === 'error') kmState = 'idle';
      if (state.romaji && state.romajiKanji) kmLoad();
      romajiApply();
    }
    if (key === 'toastPos') toast('Notifikasi akan muncul di sini', { type: 'info', ms: 2500 });
    if (key === 'introScroll') srRefresh();
    else if (key !== 'introScrollStyle' && key.indexOf('intro') === 0) introPreview();
    applyTagFilter();
    if (tfUi && tfEl && tfEl.classList.contains('open')) renderTf();
  }

  function section(panel, title) {
    panel.appendChild(mk('div', 'hmt-sec', T(title)));
    panel._g = null; // baris berikutnya masuk ke kartu baru
  }

  function grpOf(panel) {
    if (!panel._g) { panel._g = mk('div', 'hmt-grp'); panel.appendChild(panel._g); }
    return panel._g;
  }

  function row(panel, label, desc, ctrl, col) {
    const r = mk('div', 'hmt-row' + (col ? ' col' : ''));
    const left = mk('div', 'hmt-txt');
    left.appendChild(mk('div', 'hmt-lbl', T(label)));
    if (desc) left.appendChild(mk('div', 'hmt-desc', T(desc)));
    r.appendChild(left);
    if (ctrl) r.appendChild(ctrl);
    grpOf(panel).appendChild(r);
    return r;
  }

  const refreshAll = () => refreshers.forEach((f) => f());
  // baris diredupkan & dikunci selama saklar induknya mati
  function dep(r, key) {
    const f = () => r.classList.toggle('hmt-dis', !state[key]);
    refreshers.push(f); f();
    return r;
  }

  function addSwitch(panel, label, desc, key) {
    const sw = mk('button', 'hmt-sw');
    sw.type = 'button';
    sw.setAttribute('role', 'switch');
    sw.setAttribute('aria-label', T(label));
    const refresh = () => sw.setAttribute('aria-checked', String(!!state[key]));
    sw.addEventListener('click', () => { state[key] = !state[key]; set(key, state[key]); refreshAll(); afterChange(key); });
    refreshers.push(refresh); refresh();
    const r = row(panel, label, desc, sw);
    r.classList.add('hmt-clk'); // klik di mana saja pada baris = ubah saklar
    r.addEventListener('click', (e) => { if (!e.target.closest('button')) sw.click(); });
    return r;
  }

  function addRange(panel, label, desc, key, min, max, step, unit) {
    const box = mk('div', 'hmt-rangebox');
    const input = document.createElement('input');
    input.type = 'range'; input.className = 'hmt-range';
    input.min = min; input.max = max; input.step = step;
    const val = mk('span', 'hmt-val');
    const refresh = () => { input.value = state[key]; val.textContent = state[key] + unit; };
    input.addEventListener('input', () => { state[key] = +input.value; val.textContent = state[key] + unit; set(key, state[key]); apply(); if (key.indexOf('intro') === 0) introPreview(); });
    refreshers.push(refresh); refresh();
    box.append(input, val);
    return row(panel, label, desc, box);
  }

  function addSeg(panel, label, desc, key, opts) {
    const seg = mk('div', 'hmt-seg');
    const btns = opts.map(([v, t]) => {
      const b = mk('button', '', T(t)); b.type = 'button';
      b.addEventListener('click', () => {
        state[key] = v; set(key, v);
        if (key === 'lang') { langChanged(); return; }
        refresh(); apply(); if (key.indexOf('intro') === 0) introPreview();
      });
      seg.appendChild(b); return [v, b];
    });
    const refresh = () => btns.forEach(([v, b]) => b.classList.toggle('on', state[key] === v));
    refreshers.push(refresh); refresh();
    return row(panel, label, desc, seg);
  }

  function addSelect(panel, label, desc, key, opts) {
    const sel = mk('select', 'hmt-sel');
    opts.forEach(([v, t]) => { const o = document.createElement('option'); o.value = String(v); o.textContent = T(t); sel.appendChild(o); });
    const refresh = () => { sel.value = String(state[key]); };
    sel.addEventListener('change', () => { const o = opts.find(([v]) => String(v) === sel.value); state[key] = o ? o[0] : sel.value; set(key, state[key]); afterChange(key); });
    refreshers.push(refresh); refresh();
    return row(panel, label, desc, sel);
  }

  let curTab = 'tampilan';
  function addAccent(panel) {
    const box = mk('div', 'hmt-acc');
    const presets = ['#5b7cff', '#a259ff', '#ff5d8f', '#ff8a3d', '#2ec4b6', '#3ecf8e'];
    const setAc = (c) => { state.accent = c; set('accent', c); refresh(); apply(); };
    const btns = presets.map((c) => {
      const b = mk('button', 'hmt-dot'); b.type = 'button'; b.title = c; b.style.background = c;
      b.addEventListener('click', () => setAc(c)); box.appendChild(b); return [c, b];
    });
    const inp = document.createElement('input'); inp.type = 'color'; inp.className = 'hmt-color'; inp.title = T('Warna kustom');
    inp.addEventListener('input', () => setAc(inp.value));
    box.appendChild(inp);
    const refresh = () => {
      btns.forEach(([c, b]) => b.classList.toggle('on', c === String(state.accent).toLowerCase()));
      inp.value = /^#[0-9a-f]{6}$/i.test(state.accent) ? state.accent : '#5b7cff';
    };
    refreshers.push(refresh); refresh();
    return row(panel, 'Warna aksen', 'Warna tombol, slider, dan penanda', box);
  }

  // Ganti bahasa: bangun ulang semua UI yang sudah terlanjur dibuat supaya teksnya ikut berganti.
  function langChanged() {
    if (overlay) { overlay.remove(); overlay = null; }
    buildPanel();
    overlay.classList.add('hmt-still', 'open'); // tanpa animasi buka, supaya tidak berkedip
    refreshAll();
    const a = document.querySelector('#hmt-nav a'); if (a) a.textContent = T('⚙ Pengaturan');
    const a2 = document.querySelector('#hmt-tf-nav a'); if (a2) a2.title = T('Filter Tag (Alt+T)');
    const fab = document.getElementById('hmt-fab'); if (fab) fab.title = T('Pengaturan (Alt+S)');
    if (tfEl) { const was = tfEl.classList.contains('open'); tfEl.remove(); tfEl = null; tfUi = null; if (was) openTf(); }
    if (dlEl) closeDl();
    if (dmEl) { dmEl.remove(); dmEl = null; dmBody = null; dmTitle = null; dmPlaced = false; dmRows.clear(); dmTick(); }
    document.querySelectorAll('.hm-done').forEach((b) => b.remove());
    markCards();
  }

  function buildPanel() {
    refreshers.length = 0;
    overlay = mk('div'); overlay.id = 'hmt-overlay';
    const panel = mk('div'); panel.id = 'hmt-panel';
    panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', T('Pengaturan'));
    overlay.appendChild(panel);

    const head = mk('div', 'hmt-head');
    const ttl = mk('div', 'hmt-ttl');
    ttl.appendChild(mk('div', 'hmt-title', T('Pengaturan')));
    const sub = mk('div', 'hmt-sub');
    sub.append(mk('b', '', AUTHOR), document.createTextNode(' · v' + VERSION));
    ttl.appendChild(sub);
    const close = mk('button', 'hmt-close', '✕'); close.type = 'button'; close.title = T('Tutup (Esc)');
    close.addEventListener('click', closePanel);
    head.append(mk('div', 'hmt-logo', '⚙'), ttl, close);

    const body = mk('div', 'hmt-body');
    const nav = mk('div', 'hmt-nav');
    const tabs = [];
    const addTab = (id, icon, label) => {
      const btn = mk('button', 'hmt-tab'); btn.type = 'button';
      btn.append(mk('span', 'hmt-ic', icon), mk('span', 'hmt-tl', T(label)));
      const pane = mk('div', 'hmt-pane');
      btn.addEventListener('click', () => { curTab = id; tabs.forEach((t) => { t.btn.classList.toggle('on', t.id === id); t.pane.classList.toggle('on', t.id === id); t.pane.scrollTop = 0; }); });
      tabs.push({ id, btn, pane });
      nav.appendChild(btn);
      return pane;
    };

    /* ---- Tampilan ---- */
    const pView = addTab('tampilan', '🎨', 'Tampilan');
    section(pView, 'Umum');
    addSeg(pView, 'Bahasa / Language', 'Bahasa antarmuka panel dan notifikasi', 'lang', [['id', 'Indonesia'], ['en', 'English']]);
    addSwitch(pView, 'Mode gelap', 'Tema gelap untuk seluruh situs', 'dark');
    addAccent(pView);
    addSelect(pView, 'Posisi notifikasi', 'Tempat muncul pesan seperti "Kamus kanji siap"', 'toastPos',
      [['br', 'Kanan bawah'], ['bl', 'Kiri bawah'], ['bc', 'Tengah bawah'], ['tr', 'Kanan atas'], ['tl', 'Kiri atas'], ['tc', 'Tengah atas']]);
    section(pView, 'Header');
    addSwitch(pView, 'Header tetap di atas', 'Navbar (menu & pencarian) tidak hilang saat halaman di-scroll', 'stickyNav');
    addSwitch(pView, 'Perpanjang kolom search', 'Kolom pencarian di header dibuat lebih panjang', 'searchOn');
    dep(addRange(pView, 'Lebar kolom search', 'Lebar kolom pencarian (px)', 'searchW', 240, 900, 20, 'px'), 'searchOn');
    if (!isReader) {
      section(pView, 'Daftar galeri');
      addSwitch(pView, 'Poster grid', 'Tampilkan galeri sebagai kartu poster', 'grid');
      dep(addSeg(pView, 'Gaya kartu', 'Detail: cover + tabel info seperti halaman galeri. Poster: cover penuh', 'cardStyle', [['detail', 'Detail'], ['poster', 'Poster']]), 'grid');
      dep(addRange(pView, 'Ukuran kartu', 'Lebar minimum tiap kartu poster', 'cardW', 110, 400, 10, 'px'), 'grid');
    } else {
      section(pView, 'Reader');
      addSwitch(pView, 'Mode webtoon', 'Scroll vertikal, semua halaman berurutan', 'webtoon');
      dep(addSeg(pView, 'Lebar gambar', 'Lebar halaman pada mode webtoon (px)', 'readerW',
        [[600, '600'], [800, '800'], [1000, '1000'], [0, 'Penuh']]), 'webtoon');
    }

    /* ---- Animasi ---- */
    if (!isReader) {
      const pAnim = addTab('animasi', '🎬', 'Animasi');
      section(pAnim, 'Saat halaman dimuat');
      addSwitch(pAnim, 'Animasi saat memuat', 'Header dan kartu muncul dengan animasi saat halaman dimuat', 'introOn');
      section(pAnim, 'Header');
      dep(addSelect(pAnim, 'Navbar (menu atas)', 'Efek masuk untuk logo, menu, dan kolom search', 'introNav', HEAD_STYLE_OPTS), 'introOn');
      dep(addSelect(pAnim, 'Banner judul', 'Efek masuk untuk bar \"Recently Added\" (halaman daftar)', 'introBanner', HEAD_STYLE_OPTS), 'introOn');
      section(pAnim, 'Halaman galeri');
      dep(addSelect(pAnim, 'Cover & info', 'Efek masuk untuk cover, judul, dan tabel info saat membuka sebuah galeri', 'introBlock', BLOCK_STYLE_OPTS), 'introOn');
      section(pAnim, 'Kartu & thumbnail');
      dep(addSelect(pAnim, 'Gaya animasi', 'Pilih efek masuk tiap kartu. Perubahan langsung dipratinjau di halaman', 'introStyle', INTRO_STYLE_OPTS), 'introOn');
      dep(addSelect(pAnim, 'Urutan muncul', 'Berurutan: kiri ke kanan lalu ke bawah. Diagonal: menyapu dari pojok kiri-atas. Acak: urutan dikocok', 'introOrder',
        [['seq', 'Berurutan (kiri-atas)'], ['diag', 'Diagonal'], ['rand', 'Acak']]), 'introOn');
      dep(addSeg(pAnim, 'Kecepatan', 'Durasi animasi tiap kartu', 'introSpeed', [[0.6, 'Cepat'], [1, 'Normal'], [1.7, 'Lambat']]), 'introOn');
      dep(addRange(pAnim, 'Jeda antar kartu', 'Selisih waktu muncul antar kartu (ms)', 'introStep', 0, 300, 10, 'ms'), 'introOn');
      const bPlay = mk('button', 'hmt-reset', T('Putar ulang')); bPlay.type = 'button';
      bPlay.addEventListener('click', () => introPreview());
      dep(row(pAnim, 'Pratinjau', 'Putar ulang animasi pada kartu yang sedang terlihat', bPlay), 'introOn');
      section(pAnim, 'Saat di-scroll');
      addSwitch(pAnim, 'Animasi saat di-scroll', 'Kartu dan thumbnail di bawah layar muncul dengan animasi ketika di-scroll sampai ke situ', 'introScroll');
      dep(addSelect(pAnim, 'Gaya saat di-scroll', 'Efek masuk untuk kartu yang baru terlihat. Coba scroll halaman untuk melihatnya', 'introScrollStyle', [['same', 'Sama dengan gaya kartu']].concat(INTRO_STYLE_OPTS)), 'introScroll');
    }

    if (!isReader) {
      /* ---- Thumbnail ---- */
      const pThumb = addTab('thumb', '🖼', 'Thumbnail');
      section(pThumb, 'Thumbnail galeri');
      addSwitch(pThumb, 'Thumbnail besar', 'Ganti strip thumbnail dengan grid besar', 'thumbs');
      dep(addSwitch(pThumb, 'Resolusi asli', 'Pakai gambar reader di thumbnail (berat). Mati = ringan', 'thumbReader'), 'thumbs');
      dep(addRange(pThumb, 'Ukuran thumbnail', 'Lebar minimum tiap thumbnail', 'thumbW', 120, 420, 10, 'px'), 'thumbs');

      /* ---- Romaji ---- */
      const pRm = addTab('romaji', '🔤', 'Romaji');
      section(pRm, 'Judul');
      addSwitch(pRm, 'Judul ke romaji', 'Ubah tulisan Jepang (hiragana, katakana, kanji) di judul jadi huruf latin. Arahkan kursor ke kartu untuk melihat judul asli', 'romaji');
      dep(addSwitch(pRm, 'Ubah kanji juga', 'Unduh kamus kanji sekali (±17 MB, lalu tersimpan di cache browser). Mati = hanya kana yang diubah', 'romajiKanji'), 'romaji');
      dep(addSeg(pRm, 'Vokal panjang', 'Cara menulis vokal panjang: toukyou atau tokyo', 'romajiLong',
        [['simple', 'Sederhana'], ['keep', 'Apa adanya']]), 'romaji');

      /* ---- Hover preview ---- */
      const pPrev = addTab('preview', '🔍', 'Hover preview');
      section(pPrev, 'Pratinjau saat hover');
      addSwitch(pPrev, 'Hover preview', 'Tampilkan halaman besar saat kursor di thumbnail', 'pvOn');
      dep(addRange(pPrev, 'Ukuran preview', 'Tinggi preview (% tinggi layar)', 'pvSize', 30, 98, 2, '%'), 'pvOn');
      dep(addRange(pPrev, 'Jeda muncul', 'Waktu tunggu sebelum preview muncul', 'pvDelay', 0, 800, 50, 'ms'), 'pvOn');
      dep(addSeg(pPrev, 'Posisi preview', 'Di samping thumbnail atau mengikuti kursor', 'pvMode',
        [['side', 'Samping'], ['cursor', 'Kursor']]), 'pvOn');

      /* ---- Unduhan ---- */
      const pDl = addTab('unduh', '⬇', 'Unduhan');
      section(pDl, 'Panel unduhan');
      addSwitch(pDl, 'Panel pilih unduhan', 'Klik Download → pilih gambar dulu; jalan di latar belakang antar halaman', 'dlPanel');
      dep(addSeg(pDl, 'Hasil default', 'Bentuk file yang dihasilkan', 'dlOut',
        [['zip', 'ZIP'], ['pdf', 'PDF']].concat(HAS_DIR ? [['img', 'Gambar']] : [])), 'dlPanel');
      dep(addRange(pDl, 'Kualitas JPG/PDF', 'Dipakai untuk PDF atau konversi JPG', 'dlQ', 50, 100, 5, '%'), 'dlPanel');
      dep(addRange(pDl, 'Ukuran thumbnail panel', 'Lebar thumbnail di panel pilih gambar', 'dlThumb', 90, 320, 10, 'px'), 'dlPanel');
      section(pDl, 'Riwayat');
      addSwitch(pDl, 'Tandai yang sudah diunduh', 'Badge "✓ Diunduh" dan kartu diredupkan di daftar galeri', 'dlMark');
      const bClear = mk('button', 'hmt-reset'); bClear.type = 'button';
      const cntRefresh = () => { bClear.textContent = T('Hapus daftar ({0})', Object.keys(doneMap()).length); };
      refreshers.push(cntRefresh); cntRefresh();
      bClear.addEventListener('click', async () => {
        const n = Object.keys(doneMap()).length;
        if (!n) { toast('Daftar sudah kosong'); return; }
        const ok = await askConfirm({ title: 'Hapus daftar sudah diunduh?', text: T('{0} galeri akan kehilangan penanda. File hasil unduhan tidak ikut terhapus.', n), ok: 'Hapus', cancel: 'Batal', danger: true });
        if (!ok) return;
        set('dlDone', {}); markCards(); cntRefresh(); toast('Daftar penanda dihapus', { type: 'ok' });
      });
      row(pDl, 'Daftar sudah diunduh', 'Reset semua penanda "diunduh"', bClear);
    }

    /* ---- Lainnya ---- */
    const pMisc = addTab('lainnya', '🛠', 'Lainnya');
    const keys = (t) => { const w = mk('div'); w.appendChild(mk('kbd', '', t)); return w; };
    section(pMisc, 'Pintasan');
    row(pMisc, 'Buka/tutup pengaturan', null, keys('Alt+S'));
    if (!isReader) row(pMisc, 'Buka/tutup Filter Tag', null, keys('Alt+T'));
    section(pMisc, 'Diagnostik');
    const dumpBtn = mk('button', 'hmt-reset', T('Salin dump')); dumpBtn.type = 'button';
    dumpBtn.addEventListener('click', () => copyDump(dumpBtn));
    row(pMisc, 'Dump latar terang', 'Salin daftar elemen berlatar putih (untuk dicek)', dumpBtn);
    section(pMisc, 'Tentang');
    const badge = mk('span', 'hmt-badge');
    badge.append(mk('b', '', AUTHOR), document.createTextNode(' · v' + VERSION));
    row(pMisc, 'Hitomi Tweak', 'Poster grid, mode gelap, reader webtoon, dan filter tag untuk hitomi.la', badge);

    tabs.forEach((t) => body.appendChild(t.pane));
    body.insertBefore(nav, body.firstChild);
    const first = tabs.find((t) => t.id === curTab) || tabs[0];
    curTab = first.id;
    first.btn.classList.add('on'); first.pane.classList.add('on');

    /* ---- kaki: pintasan + reset ---- */
    const foot = mk('div', 'hmt-foot');
    const hint = mk('span'); hint.append(T('Buka/tutup: '));
    hint.appendChild(mk('kbd', '', 'Alt+S'));
    const rst = mk('button', 'hmt-reset warn', T('Reset default')); rst.type = 'button';
    rst.addEventListener('click', async () => {
      const ok = await askConfirm({ title: 'Reset semua pengaturan?', text: 'Semua pengaturan dikembalikan ke nilai awal.', ok: 'Reset', cancel: 'Batal', danger: true });
      if (!ok) return;
      Object.keys(DEFAULTS).forEach((k) => { state[k] = DEFAULTS[k]; set(k, DEFAULTS[k]); }); // bahasa tidak ikut di-reset
      refreshAll();
      afterChange('webtoon');
      toast('Pengaturan dikembalikan ke default', { type: 'ok' });
    });
    foot.append(hint, rst);
    panel.append(head, body, foot);

    overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) closePanel(); });
    document.body.appendChild(overlay);
  }

  function openPanel() {
    if (!overlay) buildPanel();
    else if (!overlay.isConnected) document.body.appendChild(overlay); // situs sempat menghapus body
    refreshers.forEach((f) => f());
    overlay.classList.add('open');
  }
  function closePanel() { if (overlay) overlay.classList.remove('open'); }
  function togglePanel() { if (overlay && overlay.classList.contains('open')) closePanel(); else openPanel(); }

  function addNavEntry() {
    if (document.getElementById('hmt-nav')) return true;
    const ul = document.querySelector('.navbar-nav, .navbar .nav, .navbar ul');
    if (!ul) return false;
    const li = mk('li'); li.id = 'hmt-nav';
    const a = mk('a', '', T('⚙ Pengaturan')); a.href = '#';
    a.addEventListener('click', (e) => { e.preventDefault(); togglePanel(); });
    li.appendChild(a); ul.appendChild(li);
    if (!isReader && !document.getElementById('hmt-tf-nav')) {
      const li2 = mk('li'); li2.id = 'hmt-tf-nav';
      const a2 = mk('a', '', '🏷 Filter'); a2.href = '#'; a2.title = T('Filter Tag (Alt+T)');
      a2.addEventListener('click', (e) => { e.preventDefault(); toggleTf(); });
      li2.appendChild(a2); ul.appendChild(li2);
      updateTfBadge();
    }
    const fab = document.getElementById('hmt-fab');
    if (fab) fab.remove();
    return true;
  }

  // Dipanggil berkala oleh observer: pasang ulang menu kalau navbar dirender ulang situs.
  function ensureNav() {
    if (panelReady && (!document.getElementById('hmt-nav') || (!isReader && !document.getElementById('hmt-tf-nav')))) {
      const old = document.getElementById('hmt-nav'); if (old && !document.getElementById('hmt-tf-nav')) old.remove();
      addNavEntry();
    }
  }

  function initPanel() {
    panelReady = true;
    window.addEventListener('keydown', (e) => {
      if (e.key === 'Escape' && overlay && overlay.classList.contains('open')) { e.stopImmediatePropagation(); closePanel(); return; }
      if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyS') { e.preventDefault(); e.stopImmediatePropagation(); togglePanel(); }
    }, true);

    let tries = 0;
    (function tick() {
      if (addNavEntry()) return;
      if (++tries < 12) return setTimeout(tick, 400);
      // fallback kalau navbar tidak ketemu: satu tombol kecil
      if (document.getElementById('hmt-fab')) return;
      const fab = mk('button', '', '⚙'); fab.id = 'hmt-fab'; fab.title = T('Pengaturan (Alt+S)');
      fab.addEventListener('click', togglePanel);
      document.body.appendChild(fab);
    })();
  }

  /* ====================================================================
   * POSTER GRID (list + related galleries)
   * Kartu asli situs dibaca, lalu dibangun ulang jadi kartu poster bersih.
   * ==================================================================== */
  const LANG = {
    'english': 'EN', '日本語': 'JP', '中文': 'CN', 'bahasa indonesia': 'ID', '한국어': 'KR',
    'español': 'ES', 'français': 'FR', 'deutsch': 'DE', 'русский': 'RU', 'italiano': 'IT',
    'português': 'PT', 'tiếng việt': 'VI', 'ไทย': 'TH', 'polski': 'PL', 'türkçe': 'TR',
  };
  const TYPE = {
    'doujinshi': 'DJ', 'manga': 'MG', 'artist cg': 'ACG', 'game cg': 'GCG', 'imageset': 'IS', 'anime': 'AN',
  };

  function cardSrc(card) {
    const imgEl = card.querySelector('.dj-img1 img') || card.querySelector('img');
    let src = '';
    if (imgEl) src = imgEl.getAttribute('data-src') || imgEl.getAttribute('src') || '';
    if (!src) {
      const s = card.querySelector('.dj-img1 source[data-srcset], source[data-srcset]');
      if (s) src = (s.getAttribute('data-srcset') || '').trim().split(/\s+/)[0];
    }
    if (src.startsWith('//')) src = 'https:' + src;
    return src;
  }

  function parseCard(card) {
    const a = card.querySelector('a.lillie') || card.querySelector('a[href]');
    if (!a) return null;

    const rows = {};
    card.querySelectorAll('table.dj-desc tr').forEach((tr) => {
      const tds = tr.querySelectorAll('td');
      if (tds.length >= 2) rows[txt(tds[0]).toLowerCase()] = tds[1];
    });
    const list = (td) => (td ? [...td.querySelectorAll('a')].map(txt).filter((t) => t && t !== '...') : []);

    const dateM = card.textContent.match(/[A-Z][a-z]{2} \d{1,2}, \d{4}(?:, \d{1,2}:\d{2} [AP]M)?/);

    return {
      href: a.href,
      src: cardSrc(card),
      title: txt(card.querySelector('h1.lillie')) || txt(a),
      artists: [...card.querySelectorAll('.artist-list a')].map(txt),
      series: list(rows.series).join(', '),
      group: list(rows.group || rows.groups).join(', '),
      chars: list(rows.characters || rows.character).join(', '),
      type: txt(rows.type),
      lang: txt(rows.language),
      tags: list(rows.tags),
      tagKeys: rows.tags ? [...rows.tags.querySelectorAll('a')].map(tfKeyFromAnchor).filter(Boolean) : [],
      date: dateM ? dateM[0] : '',
    };
  }

  /* ---------- penanda "sudah diunduh" ---------- */
  const gidOfHref = (h) => { const m = String(h || '').match(/(\d+)\.html(?:[#?].*)?$/); return m ? m[1] : ''; };
  const doneMap = () => { const m = get('dlDone', {}); return m && typeof m === 'object' ? m : {}; };
  function decorateDone(a, m) {
    const id = a.dataset.gid || (a.dataset.gid = gidOfHref(a.getAttribute('href')));
    const on = !!(state.dlMark && id && m[id]);
    a.classList.toggle('is-done', on);
    const b = a.querySelector('.hm-done');
    if (on && !b) a.appendChild(mk('div', 'hm-done', T('✓ Diunduh')));
    else if (!on && b) b.remove();
  }
  function markCards() { const m = doneMap(); document.querySelectorAll('.hm-card').forEach((a) => decorateDone(a, m)); }
  function markDone(id) { if (!id) return; const m = doneMap(); m[id] = Date.now(); set('dlDone', m); markCards(); }
  function unmarkDone(id) { const m = doneMap(); delete m[id]; set('dlDone', m); markCards(); }

  function makeCard(d) {
    const a = mk('a', 'hm-card');
    a.href = d.href;
    a.title = [d.title, d.artists.join(', '), d.series, d.type, d.lang, d.date].filter(Boolean).join('\n');
    a.dataset.tfk = (d.tagKeys || []).join('|');
    if (d.type) a.dataset.type = d.type.toLowerCase().replace(/[^a-z]/g, ''); // 'Artist CG' -> 'artistcg'

    const cover = mk('div', 'hm-cover');
    const img = document.createElement('img');
    img.loading = 'lazy'; img.decoding = 'async'; img.alt = '';
    if (d.src) img.src = d.src;
    img.addEventListener('error', () => { img.style.visibility = 'hidden'; });
    cover.appendChild(img);

    const tags = mk('div', 'hm-tags');
    d.tags.slice(0, 24).forEach((t) => tags.appendChild(mk('span', '', t)));
    cover.appendChild(tags);

    const badges = mk('div', 'hm-badges');
    const langCode = LANG[d.lang.toLowerCase()] || (d.lang && d.lang.toLowerCase() !== 'n/a' ? d.lang.slice(0, 2).toUpperCase() : '');
    badges.appendChild(mk('span', 'hm-badge', langCode));
    badges.appendChild(mk('span', 'hm-badge', TYPE[d.type.toLowerCase()] || d.type.slice(0, 3).toUpperCase()));
    if (!langCode) badges.firstChild.style.visibility = 'hidden';
    cover.appendChild(badges);

    const ov = mk('div', 'hm-ov');
    ov.appendChild(mk('div', 'hm-title', d.title));
    const sub = [d.artists.join(', '), d.series].filter(Boolean).join(' · ');
    if (sub) ov.appendChild(mk('div', 'hm-sub', sub));
    cover.appendChild(ov);
    a.appendChild(cover);

    // panel info (dipakai di gaya "detail")
    const info = mk('div', 'hm-info');
    info.appendChild(mk('div', 'hm-it', d.title));
    if (d.artists.length) info.appendChild(mk('div', 'hm-ia', d.artists.join(', ')));
    const rows = mk('div', 'hm-rows');
    const addRow = (k, v, cls) => { if (!v) return; rows.append(mk('div', 'hm-k', k), mk('div', 'hm-v' + (cls ? ' ' + cls : ''), v)); };
    addRow('Group', d.group);
    addRow('Type', d.type, 'hm-vtype');
    addRow('Language', d.lang);
    addRow('Series', d.series);
    addRow('Characters', d.chars);
    if (d.tags.length) {
      const chips = mk('div', 'hm-chips');
      d.tags.forEach((t) => chips.appendChild(mk('span', '', t)));
      rows.append(mk('div', 'hm-k', 'Tags'), chips);
    }
    info.appendChild(rows);
    a.appendChild(info);
    decorateDone(a, doneMap());

    return a;
  }

  // Kartu di-cache per URL: selama galeri masih ada di daftar, elemen kartunya dipakai ulang
  // (gambar tidak dimuat ulang), jadi tidak ada kedip saat situs merender ulang daftar.
  const cardCache = new Map(); // href -> { el, sig }
  const boxState = new WeakMap(); // .gallery-content -> { wrap, emptySince }
  const EMPTY_GRACE = 1500; // tahan grid lama sebentar saat situs mengosongkan daftar

  const hrefOf = (c) => {
    const a = c.querySelector('a.lillie') || c.querySelector('a[href]');
    return a ? a.href : '';
  };
  const cardSig = (d) => [d.title, d.artists.join(','), d.series, d.group, d.chars, d.type, d.lang, d.tags.join(','), d.date].join('\u0001');

  function stateOf(box) {
    let s = boxState.get(box);
    if (!s) { s = { wrap: mk('div', 'hm-grid-wrap'), emptySince: 0 }; boxState.set(box, s); }
    return s;
  }

  function buildGrid(box) {
    const s = stateOf(box);
    const wrap = s.wrap;
    if (wrap.previousElementSibling !== box) box.after(wrap);

    const cards = [...box.children].filter((c) => c.nodeType === 1);
    if (!cards.length) {
      if (!wrap.childElementCount) return;
      if (!s.emptySince) { s.emptySince = Date.now(); setTimeout(gridify, EMPTY_GRACE + 50); return; }
      if (Date.now() - s.emptySince < EMPTY_GRACE) return;
      wrap.replaceChildren();
      s.emptySince = 0;
      return;
    }
    s.emptySince = 0;

    const wanted = [];
    const seen = new Set();
    for (const c of cards) {
      const href = hrefOf(c);
      if (!href || seen.has(href)) continue;
      seen.add(href);

      let d;
      try { d = parseCard(c); } catch (e) { console.warn('[HMT] gagal parse kartu', e, c); continue; }
      if (!d) continue;

      const sig = cardSig(d);
      let entry = cardCache.get(href);
      if (!entry || entry.sig !== sig) {
        const el = makeCard(d);
        if (entry) { // data berubah: pertahankan <img> lama yang sudah termuat
          const oldImg = entry.el.querySelector('img');
          const newImg = el.querySelector('img');
          if (oldImg && newImg && oldImg.getAttribute('src')) newImg.replaceWith(oldImg);
        }
        entry = { el, sig };
        cardCache.set(href, entry);
      } else {
        const img = entry.el.querySelector('img');
        if (img && !img.getAttribute('src') && d.src) { img.style.visibility = ''; img.src = d.src; }
      }
      wanted.push(entry.el);
    }
    if (!wanted.length) return;

    // reconcile: hanya ubah DOM kalau memang berbeda
    const cur = wrap.children;
    let same = cur.length === wanted.length;
    for (let i = 0; same && i < wanted.length; i++) if (cur[i] !== wanted[i]) same = false;
    if (same) return;

    const keep = new Set(wanted);
    for (const ch of [...cur]) if (!keep.has(ch)) ch.remove();
    wanted.forEach((el, i) => {
      if (wrap.children[i] !== el) wrap.insertBefore(el, wrap.children[i] || null);
    });

    if (cardCache.size > 600) {
      for (const [k, v] of cardCache) if (!keep.has(v.el)) cardCache.delete(k);
    }
  }

  function gridify() {
    document.querySelectorAll('.gallery-content').forEach(buildGrid);
    // buang grid yatim kalau container aslinya diganti oleh situs (kartu sudah dipindah lewat cache)
    document.querySelectorAll('.hm-grid-wrap').forEach((w) => {
      const p = w.previousElementSibling;
      if (!p || !p.classList.contains('gallery-content')) w.remove();
    });
    applyTagFilter();
    romajiApply();
  }


  /* ====================================================================
   * FILTER TAG (panel samping, Alt+T)
   * Pembuat query pencarian: pilih tag (sertakan / kecualikan), lalu tombol Cari mengisi
   * kolom search situs dan menjalankannya, jadi hasilnya dari SELURUH galeri (bukan hanya halaman ini).
   * ==================================================================== */
  const tfNorm = (t) => String(t || '').replace(/[\u2640\u2642]/g, '').replace(/_/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
  const tfSplit = (k) => { const i = k.indexOf(':'); return i < 0 ? ['tag', k] : [k.slice(0, i), k.slice(i + 1)]; };

  // kunci tag lengkap dengan namespace, mis. "female:big breasts"; tag tanpa namespace -> "tag:..."
  function tfKeyFromAnchor(a) {
    const t = txt(a);
    if (!t || t === '...') return '';
    const m = (a.getAttribute('href') || '').match(/\/tag\/([^/?#]+?)-all\.html/);
    if (m) {
      try {
        const k = tfNorm(decodeURIComponent(m[1]));
        if (k) return k.includes(':') ? k : 'tag:' + k;
      } catch {}
    }
    const name = tfNorm(t);
    return name ? (/\u2640/.test(t) ? 'female:' : /\u2642/.test(t) ? 'male:' : 'tag:') + name : '';
  }

  const tfIncL = () => (Array.isArray(state.tfInc) ? state.tfInc : []);
  const tfExcL = () => (Array.isArray(state.tfExc) ? state.tfExc : []);
  const tfSave = (k, arr) => { state[k] = arr; set(k, arr); };
  const tfCount = () => tfIncL().length + tfExcL().length;
  const tfQuery = () => [...tfIncL(), ...tfExcL().map((k) => '-' + k)].map((t) => t.replace(/ /g, '_')).join(' ');

  let tfEl = null, tfUi = null, tfSig = '', tfLiveT = 0, tfMode = 'inc', tfSaveT = 0;

  // Kosakata tag: diingat lintas halaman. tfSeen = id galeri yang sudah dihitung.
  const tfVocab = (() => { const v = get('tfVocab2', {}); return v && typeof v === 'object' && !Array.isArray(v) ? v : {}; })();
  let tfSeenArr = (() => { const v = get('tfSeen2', []); return Array.isArray(v) ? v : []; })();
  const tfSeen = new Set(tfSeenArr);
  function tfLearn(cards) {
    let dirty = false;
    for (const c of cards) {
      if (!c.dataset.tfk) continue;
      const id = c.dataset.gid || gidOfHref(c.getAttribute('href'));
      if (!id || tfSeen.has(id)) continue;
      tfSeen.add(id); tfSeenArr.push(id);
      new Set(c.dataset.tfk.split('|')).forEach((t) => { if (t) tfVocab[t] = (tfVocab[t] || 0) + 1; });
      dirty = true;
    }
    if (dirty && !tfSaveT) tfSaveT = setTimeout(() => {
      tfSaveT = 0;
      if (tfSeenArr.length > 6000) tfSeenArr = tfSeenArr.slice(-5000);
      const keys = Object.keys(tfVocab);
      if (keys.length > 4000) keys.sort((a, b) => tfVocab[a] - tfVocab[b]).slice(0, keys.length - 3000).forEach((k) => delete tfVocab[k]);
      set('tfVocab2', tfVocab); set('tfSeen2', tfSeenArr);
    }, 600);
  }

  function updateTfBadge() {
    const a = document.querySelector('#hmt-tf-nav a');
    if (!a) return;
    const n = tfCount();
    const t = '🏷 Filter' + (n ? ' (' + n + ')' : '');
    if (a.textContent !== t) a.textContent = t;
  }

  // dipanggil tiap grid dibangun ulang: pelajari tag baru + segarkan panel kalau terbuka
  function applyTagFilter() {
    tfLearn(document.querySelectorAll('.hm-card'));
    updateTfBadge();
    if (tfEl && tfEl.classList.contains('open')) { clearTimeout(tfLiveT); tfLiveT = setTimeout(tfLive, 150); }
  }

  function tfPageKeys() {
    const m = new Map();
    document.querySelectorAll('.hm-card').forEach((c) => {
      if (!c.dataset.tfk) return;
      new Set(c.dataset.tfk.split('|')).forEach((t) => { if (t) m.set(t, (m.get(t) || 0) + 1); });
    });
    return m;
  }
  function tfSigNow() {
    const c = document.querySelectorAll('.hm-card');
    return c.length + '|' + (c.length ? c[0].href + '|' + c[c.length - 1].href : '');
  }

  function tfChip(key, cls, extra, onClick) {
    const [ns, name] = tfSplit(key);
    const b = mk('button', 'tf-chip' + (cls ? ' ' + cls : '')); b.type = 'button'; b.title = key;
    if (ns !== 'tag') b.appendChild(mk('span', 'tf-ns ' + (ns === 'female' ? 'f' : ns === 'male' ? 'm' : 'o'), ns === 'female' ? '♀' : ns === 'male' ? '♂' : ns));
    b.appendChild(mk('span', '', name));
    if (extra != null) b.appendChild(mk('small', '', String(extra)));
    b.addEventListener('click', onClick);
    return b;
  }

  function tfChanged() { updateTfBadge(); renderTf(); }
  const tfListKey = (mode) => (mode === 'exc' ? 'tfExc' : 'tfInc');

  function tfPut(key, mode) {
    tfSave('tfInc', tfIncL().filter((x) => x !== key));
    tfSave('tfExc', tfExcL().filter((x) => x !== key));
    tfSave(tfListKey(mode), [...(mode === 'exc' ? tfExcL() : tfIncL()), key]);
  }
  function tfRemove(key) {
    tfSave('tfInc', tfIncL().filter((x) => x !== key));
    tfSave('tfExc', tfExcL().filter((x) => x !== key));
  }
  function tfToggle(key) {
    const cur = tfMode === 'exc' ? tfExcL() : tfIncL();
    if (cur.includes(key)) tfRemove(key); else tfPut(key, tfMode);
    tfChanged();
  }

  // isi dari kolom search situs (mis. saat sedang di halaman tag/pencarian)
  function tfPreload() {
    if (tfCount()) return;
    const box = document.getElementById('query-input');
    const v = box ? box.value.trim() : '';
    if (!v) return;
    const inc = [], exc = [];
    v.split(/\s+/).forEach((term) => {
      const neg = term.startsWith('-');
      const k = tfNorm(neg ? term.slice(1) : term);
      if (!k || !k.includes(':') || k.startsWith(':') || k.endsWith(':')) return;
      (neg ? exc : inc).push(k);
    });
    if (inc.length) tfSave('tfInc', inc);
    if (exc.length) tfSave('tfExc', exc);
  }

  function tfGoSearch() {
    const q = tfQuery();
    if (!q) { toast('Pilih tag dulu'); return; }
    const inp = document.getElementById('query-input');
    const btn = document.getElementById('search-button')
      || [...document.querySelectorAll('#search button, #search input[type=submit], .header-table button, .header-table input[type=submit]')]
        .find((b) => /search/i.test(b.textContent || b.value || ''));
    const before = location.href;
    if (inp && btn) {
      inp.value = q;
      inp.dispatchEvent(new Event('input', { bubbles: true }));
      btn.click();
      // kalau tombol situs tidak memindah halaman, pakai URL pencarian langsung
      setTimeout(() => { if (location.href === before) location.href = '/search.html?' + encodeURIComponent(q); }, 600);
    } else {
      location.href = '/search.html?' + encodeURIComponent(q);
    }
  }

  function renderTfTags() {
    if (!tfUi) return;
    tfSig = tfSigNow();
    const raw = tfUi.input.value;
    const q = tfNorm(raw);
    tfUi.x.classList.toggle('on', !!raw);
    const inc = new Set(tfIncL()), exc = new Set(tfExcL());
    const page = tfPageKeys();
    const pageTags = [...page].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
    const others = Object.entries(tfVocab).filter(([k]) => !page.has(k)).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
    const match = ([k]) => !q || k.includes(q);
    const chip = ([k, n]) => tfChip(k, inc.has(k) ? 'req' : exc.has(k) ? 'hide' : '', n, () => tfToggle(k));

    // saran tag baru (kalau yang diketik belum dikenal)
    const known = (name) => pageTags.some(([k]) => tfSplit(k)[1] === name) || others.some(([k]) => tfSplit(k)[1] === name);
    let sug = [];
    if (q) {
      const i = q.indexOf(':');
      if (i > 0 && i < q.length - 1) sug = [q];
      else if (i < 0 && !known(q)) sug = ['female:' + q, 'male:' + q, 'tag:' + q];
    }
    tfUi.sugWrap.style.display = sug.length ? '' : 'none';
    tfUi.sug.replaceChildren(...sug.map((k) => tfChip(k, '', null, () => { tfPut(k, tfMode); tfUi.input.value = ''; tfChanged(); })));

    const listA = pageTags.filter(match).slice(0, 250).map(chip);
    tfUi.tags.replaceChildren(...(listA.length ? listA : [mk('div', 'tf-empty', pageTags.length ? (q ? T('Tidak ada yang cocok di halaman ini.') : '') : T('Belum ada tag terbaca di halaman ini'))]));
    tfUi.tagsCnt.textContent = pageTags.length ? T('{0} tag', pageTags.length, pageTags.length === 1 ? '' : 's') : '';

    const matchB = others.filter(match);
    const listB = matchB.slice(0, q ? 250 : 80).map(chip);
    tfUi.more.style.display = listB.length ? '' : 'none';
    tfUi.tags2.replaceChildren(...listB);
    tfUi.tags2Cnt.textContent = matchB.length > listB.length ? T('{0} dari {1} (ketik untuk cari)', listB.length, matchB.length) : T('{0} tag', matchB.length, matchB.length === 1 ? '' : 's');

    const names = new Set(pageTags.map((x) => x[0]));
    others.slice(0, 300).forEach(([k]) => names.add(k));
    tfUi.dl.replaceChildren(...[...names].slice(0, 500).map((k) => { const o = document.createElement('option'); o.value = k; return o; }));
  }

  function tfLive() {
    if (!tfUi || !tfEl || !tfEl.classList.contains('open')) return;
    if (tfSigNow() !== tfSig) renderTfTags();
  }

  function renderTf() {
    if (!tfUi) return;
    const inc = tfIncL(), exc = tfExcL();
    const n = inc.length + exc.length;
    tfUi.tgt.forEach((b) => b.classList.toggle('on', b.dataset.v === tfMode));
    tfUi.hint.style.display = n ? 'none' : '';
    tfUi.active.style.display = n ? '' : 'none';
    const rm = (cls) => (k) => tfChip(k, cls, '×', () => { tfRemove(k); tfChanged(); });
    tfUi.chips.replaceChildren(...inc.map(rm('req')), ...exc.map(rm('hide')));
    tfUi.code.textContent = tfQuery();
    tfUi.go.textContent = n ? T('🔍 Cari ({0})', n) : T('🔍 Cari');
    tfUi.go.classList.toggle('dis', !n);
    tfUi.clear.style.visibility = n ? '' : 'hidden';
    renderTfTags();
  }

  function buildTf() {
    tfEl = mk('div'); tfEl.id = 'hmt-tf';
    tfEl.setAttribute('role', 'dialog'); tfEl.setAttribute('aria-label', T('Filter Tag'));

    const head = mk('div', 'tf-head');
    const close = mk('button', 'tf-close', '✕'); close.type = 'button'; close.title = T('Tutup (Esc)');
    close.addEventListener('click', closeTf);
    head.append(mk('div', 'tf-title', T('Filter Tag')), close);

    const top = mk('div', 'tf-top');
    const seg = mk('div', 'tf-seg');
    const tgt = [['inc', 'Sertakan'], ['exc', 'Kecualikan']].map(([v, l]) => {
      const b = mk('button'); b.type = 'button'; b.dataset.v = v;
      b.append(mk('i'), document.createTextNode(T(l)));
      b.addEventListener('click', () => { tfMode = v; tfUi.tgt.forEach((x) => x.classList.toggle('on', x.dataset.v === v)); });
      seg.appendChild(b); return b;
    });
    const sbox = mk('div', 'tf-search');
    const input = document.createElement('input');
    input.type = 'text'; input.className = 'tf-in'; input.placeholder = T('Cari tag… (mis. female:insect)');
    input.setAttribute('list', 'hmt-tf-dl'); input.autocomplete = 'off'; input.spellcheck = false;
    const dl = document.createElement('datalist'); dl.id = 'hmt-tf-dl';
    const x = mk('button', 'tf-x', '✕'); x.type = 'button'; x.title = T('Bersihkan');
    x.addEventListener('click', () => { input.value = ''; renderTfTags(); input.focus(); });
    input.addEventListener('input', renderTfTags);
    input.addEventListener('keydown', (e) => {
      if (e.key !== 'Enter') return;
      e.preventDefault();
      const q = tfNorm(input.value);
      if (!q) { tfGoSearch(); return; }
      let key = '';
      const i = q.indexOf(':');
      if (i > 0 && i < q.length - 1) key = q;
      else {
        const cands = [...new Set([...tfPageKeys().keys(), ...Object.keys(tfVocab)])].filter((k) => tfSplit(k)[1] === q);
        cands.sort((a, b) => (tfVocab[b] || 0) - (tfVocab[a] || 0));
        key = cands[0] || '';
      }
      if (!key) { toast('Tag belum dikenal. Pilih namespace di bawah (♀ / ♂ / tag).'); return; }
      tfPut(key, tfMode); input.value = ''; tfChanged();
    });
    sbox.append(input, x, dl);
    top.append(seg, sbox);

    const scroll = mk('div', 'tf-scroll');
    const hint = mk('div', 'tf-hint', T('Pilih mode di atas, klik tag di bawah, lalu tekan Cari. Hasilnya dari seluruh galeri, bukan hanya halaman ini.'));

    const active = mk('div', 'tf-active');
    const chips = mk('div', 'tf-chips');
    const code = mk('div', 'tf-code');
    active.append(chips, code);

    const sugWrap = mk('div'); sugWrap.style.marginBottom = '14px';
    const sl = mk('div', 'tf-lab'); sl.append(mk('span', '', T('Tambah tag baru')), mk('em', '', T('pilih jenisnya')));
    const sug = mk('div', 'tf-chips');
    sugWrap.append(sl, sug);

    const tl = mk('div', 'tf-lab'); const tagsCnt = mk('em');
    tl.append(mk('span', '', T('Tag di halaman ini')), tagsCnt);
    const tagsBox = mk('div', 'tf-chips');
    const more = mk('div'); more.style.marginTop = '16px';
    const tl2 = mk('div', 'tf-lab'); const tags2Cnt = mk('em');
    tl2.append(mk('span', '', T('Dari halaman lain')), tags2Cnt);
    const tagsBox2 = mk('div', 'tf-chips');
    more.append(tl2, tagsBox2);
    scroll.append(hint, active, sugWrap, tl, tagsBox, more);

    const foot = mk('div', 'tf-foot');
    const clear = mk('button', 'tf-clear', T('Kosongkan')); clear.type = 'button';
    clear.addEventListener('click', () => { tfSave('tfInc', []); tfSave('tfExc', []); tfChanged(); });
    const go = mk('button', 'tf-go', T('🔍 Cari')); go.type = 'button';
    go.addEventListener('click', tfGoSearch);
    foot.append(clear, go);

    tfEl.append(head, top, scroll, foot);
    document.body.appendChild(tfEl);
    tfUi = { input, x, dl, hint, active, chips, code, sugWrap, sug, tags: tagsBox, tagsCnt, more, tags2: tagsBox2, tags2Cnt, tgt, clear, go };
  }

  function openTf() {
    if (isReader) return;
    if (!tfEl) buildTf();
    else if (!tfEl.isConnected) document.body.appendChild(tfEl);
    tfEl.classList.add('open');
    tfPreload();
    applyTagFilter();
    renderTf();
    updateTfBadge();
  }
  function closeTf() { if (tfEl) tfEl.classList.remove('open'); }
  function toggleTf() { if (tfEl && tfEl.classList.contains('open')) closeTf(); else openTf(); }

  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && tfEl && tfEl.classList.contains('open') && !(overlay && overlay.classList.contains('open'))) {
      if (document.getElementById('hmt-dl')) return;
      closeTf(); return;
    }
    if (e.altKey && !e.ctrlKey && !e.metaKey && e.code === 'KeyT' && !isReader) { e.preventDefault(); e.stopImmediatePropagation(); toggleTf(); }
  }, true);

  /* ====================================================================
   * ROMAJI: tulisan Jepang di judul -> huruf latin
   * Kana (hiragana/katakana) diubah offline. Kanji memakai kamus kuromoji yang dimuat dari CDN
   * (hanya kalau "Ubah kanji juga" aktif); sebelum kamus siap, kanji dibiarkan apa adanya.
   * ==================================================================== */
  const RM_B = {};
  ('あa いi うu えe おo かka きki くku けke こko さsa しshi すsu せse そso たta ちchi つtsu てte とto ' +
   'なna にni ぬnu ねne のno はha ひhi ふfu へhe ほho まma みmi むmu めme もmo やya ゆyu よyo ' +
   'らra りri るru れre ろro わwa ゐi ゑe をo がga ぎgi ぐgu げge ごgo ざza じji ずzu ぜze ぞzo ' +
   'だda ぢji づzu でde どdo ばba びbi ぶbu べbe ぼbo ぱpa ぴpi ぷpu ぺpe ぽpo ゔvu ' +
   'ぁa ぃi ぅu ぇe ぉo ゃya ゅyu ょyo ゎwa ゕka ゖke').split(' ').forEach((t) => { RM_B[t[0]] = t.slice(1); });
  const RM_SY = { 'ゃ': 'a', 'ゅ': 'u', 'ょ': 'o' };
  const RM_SV = { 'ぁ': 'a', 'ぃ': 'i', 'ぅ': 'u', 'ぇ': 'e', 'ぉ': 'o' };
  const RM_PUNCT = { '【': '[', '】': ']', '「': '"', '」': '"', '『': '"', '』': '"', '、': ', ', '。': '. ', '・': ' ', '〜': '~', ' ': ' ' };
  const RM_JP = /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\uff66-\uff9f々〆]/;
  const RM_KANARUN = /[\u3041-\u3096\u30a1-\u30fa\u30fc]+/g;
  const RM_PART = { 'は': 'wa', 'へ': 'e', 'を': 'o' };
  const rmCache = new Map();
  let kmWorker = null, kmState = 'idle'; // idle | loading | ready | error
  const kmPending = new Map(), kmInflight = new Map(); // teks asli -> teks NFKC
  let kmFlushT = 0, kmApplyT = 0;

  const rmKata2Hira = (t) => t.replace(/[\u30a1-\u30f6]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0x60));
  const rmCap = (w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : w);

  function rmCombine(base, n) {
    const y = RM_SY[n];
    if (y) {
      if (/^(shi|chi|ji)$/.test(base)) return base.slice(0, -1) + y;
      if (/^(ki|gi|ni|hi|bi|pi|mi|ri|fu|te|de|vu)$/.test(base)) return base.slice(0, -1) + 'y' + y;
      return null;
    }
    const v = RM_SV[n];
    if (!v) return null;
    if (base === 'u') return v === 'u' ? null : 'w' + v;
    if (base === 'i') return v === 'e' ? 'ye' : null;
    if (/^(fu|vu|tsu|te|de|to|do|shi|ji|chi|su|zu|ku|gu)$/.test(base)) return base.replace(/[aiueo]$/, '') + v;
    return null;
  }

  // satu rangkaian kana -> romaji (Hepburn, tanpa makron)
  function rmKana(src) {
    const s = rmKata2Hira(src);
    let out = '', i = 0, gem = false;
    const push = (r) => { if (gem) { r = r.startsWith('ch') ? 't' + r : r.charAt(0) + r; gem = false; } out += r; };
    while (i < s.length) {
      const c = s[i];
      if (c === 'っ') { gem = true; i++; continue; }
      if (c === 'ー') { const m = out.match(/[aiueo]$/); if (m) out += m[0]; i++; continue; }
      if (c === 'ん') { const nx = s[i + 1]; push(nx && 'あいうえおやゆよ'.includes(nx) ? "n'" : 'n'); i++; continue; }
      const base = RM_B[c];
      if (base !== undefined) {
        const n = s[i + 1];
        const comb = n ? rmCombine(base, n) : null;
        if (comb) { push(comb); i += 2; } else { push(base); i++; }
        continue;
      }
      gem = false; out += c; i++;
    }
    return out;
  }
  const rmSimple = (r) => (state.romajiLong === 'keep' ? r : r.replace(/ou/g, 'o').replace(/oo/g, 'o').replace(/uu/g, 'u'));
  const rmChunk = (str) => str.replace(RM_KANARUN, (run) => rmSimple(rmKana(run)));
  const rmPunct = (t) => t.replace(/[【】「」『』、。・〜 ]/g, (c) => RM_PUNCT[c]);
  const rmFinish = (r) => r.replace(/\s{2,}/g, ' ').trim();

  function rmKanaOnly(text) {
    const t = text.normalize('NFKC');
    return rmFinish(rmPunct(t.replace(RM_KANARUN, (run) => rmCap(rmSimple(rmKana(run))))));
  }

  // token dari worker: { s: surface, r: reading, p: pos, d: pos_detail_1, w: word_position }
  function rmFromTokens(t, toks) {
    let out = '', pos = 0, prevJp = false;
    for (const k of toks) {
      const surf = k.s;
      const start = k.w - 1;
      const gap = t.slice(pos, start);
      pos = start + surf.length;
      const jp = RM_JP.test(surf);
      const particle = k.p === '助詞';
      const attach = k.p === '助動詞' || k.d === '接尾';
      let piece;
      if (jp) {
        if (particle && RM_PART[surf]) piece = RM_PART[surf];
        else piece = rmChunk(k.r && k.r !== '*' ? k.r : surf);
      } else piece = rmPunct(surf);
      const sep = gap || (jp && prevJp && !attach ? ' ' : '');
      const newWord = !out || sep !== '' || !prevJp;
      if (jp && newWord && !particle) piece = rmCap(piece);
      out += sep + piece;
      prevJp = jp;
    }
    return rmFinish(out);
  }

  // Hasil lengkap (dengan kanji) datang async dari worker. Selama belum ada, tampilkan versi kana saja.
  function rmConv(text) {
    if (!RM_JP.test(text)) return text;
    const hit = rmCache.get(text);
    if (hit !== undefined) return hit;
    const useDict = state.romajiKanji && kmState === 'ready' && kmWorker && !/[\ud800-\udfff]/.test(text);
    let r;
    try { r = rmKanaOnly(text); } catch (e) { console.warn('[HMT] romaji gagal', e); r = text; }
    if (useDict) { rmQueue(text); return r; }
    // kamus belum siap tapi diharapkan -> jangan di-cache, hitung ulang nanti
    if (!(state.romajiKanji && (kmState === 'idle' || kmState === 'loading'))) {
      rmCache.set(text, r);
      if (rmCache.size > 3000) rmCache.clear();
    }
    return r;
  }

  function rmQueue(text) {
    if (kmPending.has(text) || kmInflight.has(text)) return;
    kmPending.set(text, text.normalize('NFKC'));
    if (!kmFlushT) kmFlushT = setTimeout(() => {
      kmFlushT = 0;
      if (!kmWorker || !kmPending.size) return;
      const batch = [...kmPending];
      kmPending.clear();
      batch.forEach(([o, n]) => kmInflight.set(o, n));
      kmWorker.postMessage({ type: 'tok', texts: [...new Set(batch.map((x) => x[1]))] });
    }, 0);
  }

  function kmOnTok(out) {
    for (const [orig, norm] of [...kmInflight]) {
      if (!(norm in out)) continue;
      kmInflight.delete(orig);
      let r;
      try { r = out[norm] ? rmFromTokens(norm, out[norm]) : rmKanaOnly(orig); }
      catch (e) { r = rmKanaOnly(orig); }
      rmCache.set(orig, r);
    }
    if (rmCache.size > 3000) rmCache.clear();
    clearTimeout(kmApplyT);
    kmApplyT = setTimeout(romajiApply, 30);
  }

  // Kode worker: kuromoji dijalankan di thread terpisah supaya halaman tidak membeku saat kamus dibangun.
  const KM_GLUE = [
    ';(function () {',
    '  var tk = null;',
    '  self.onmessage = function (e) {',
    '    var m = e.data;',
    "    if (m.type === 'init') {",
    '      try {',
    '        kuromoji.builder({ dicPath: m.dicPath }).build(function (err, t) {',
    "          if (err || !t) { self.postMessage({ type: 'error', msg: 'kamus gagal diunduh (' + String((err && err.message) || err || 'jaringan/CORS') + ')' }); return; }",
    "          tk = t; self.postMessage({ type: 'ready' });",
    '        });',
    "      } catch (x) { self.postMessage({ type: 'error', msg: 'build: ' + String((x && x.message) || x) }); }",
    "    } else if (m.type === 'tok' && tk) {",
    '      var out = {};',
    '      for (var i = 0; i < m.texts.length; i++) {',
    '        var txt = m.texts[i];',
    '        try {',
    '          out[txt] = tk.tokenize(txt).map(function (k) { return { s: k.surface_form, r: k.reading, p: k.pos, d: k.pos_detail_1, w: k.word_position }; });',
    '        } catch (x) { out[txt] = null; }',
    '      }',
    "      self.postMessage({ type: 'tok', out: out });",
    '    }',
    '  };',
    '})();',
  ].join('\n');

  // Muat kuromoji + kamus (di worker). Beberapa mirror dicoba berurutan; kegagalan dicatat di Console ([HMT]).
  function kmLoad() {
    if (kmState !== 'idle') return;
    kmState = 'loading';
    toast('Memuat kamus kanji… (pertama kali agak lama, halaman tetap bisa dipakai)', { ms: 3500 });
    const bases = [
      'https://cdn.jsdelivr.net/npm/[email protected]/',
      'https://fastly.jsdelivr.net/npm/[email protected]/',
      'https://gcore.jsdelivr.net/npm/[email protected]/',
      'https://unpkg.com/[email protected]/',
    ];
    const errs = [];
    const fail = () => {
      kmState = 'error';
      console.warn('[HMT] kamus kanji gagal dimuat:', errs);
      rmCache.clear(); romajiApply();
      toast(T('Gagal memuat kamus kanji ({0}). Kalau diblokir ekstensi pemblokir iklan, izinkan cdn.jsdelivr.net. Detail di Console, cari [HMT].', tErr(errs[errs.length - 1] || 'tidak diketahui')), { type: 'err', ms: 15000 });
    };
    const tryBase = (n) => {
      if (n >= bases.length) return fail();
      const host = bases[n].split('/')[2];
      let settled = false, w = null, url = '', timer = 0;
      const next = (why) => {
        if (settled) return;
        settled = true; clearTimeout(timer);
        if (w) { try { w.terminate(); } catch {} }
        if (url) URL.revokeObjectURL(url);
        console.warn('[HMT] ' + host + ': ' + why);
        errs.push(host + ': ' + why);
        tryBase(n + 1);
      };
      timer = setTimeout(() => next('timeout (90 detik)'), 90000);
      fetch(bases[n] + 'build/kuromoji.js')
        .then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
        .then((code) => {
          if (settled) return;
          url = URL.createObjectURL(new Blob([code, '\n', KM_GLUE], { type: 'text/javascript' }));
          w = new Worker(url);
          w.onmessage = (e) => {
            const m = e.data || {};
            if (m.type === 'ready') {
              if (settled) return;
              settled = true; clearTimeout(timer);
              URL.revokeObjectURL(url);
              kmWorker = w; kmState = 'ready';
              rmCache.clear(); romajiApply();
              toast('Kamus kanji siap', { type: 'ok', ms: 2500 });
            } else if (m.type === 'error') next(m.msg || 'error');
            else if (m.type === 'tok') kmOnTok(m.out || {});
          };
          w.onerror = (e) => next('worker: ' + ((e && e.message) || 'error'));
          w.postMessage({ type: 'init', dicPath: bases[n] + 'dict/' });
        })
        .catch((e) => next('kuromoji.js: ' + ((e && e.message) || e)));
    };
    tryBase(0);
  }

  // Terapkan ke judul di halaman. Idempotent: teks asli disimpan di data-rm-orig.
  const RM_SEL = '.hm-title, .hm-sub, .hm-it, .hm-ia, #gallery-brand a';
  function romajiApply() {
    if (isReader) return;
    const on = !!state.romaji;
    document.querySelectorAll(RM_SEL).forEach((el) => {
      if (el.children.length) return;
      const cur = el.textContent;
      let o = el.dataset.rmOrig;
      if (o === undefined || (cur !== o && cur !== el.dataset.rmOut)) { o = cur; el.dataset.rmOrig = o; }
      const want = on ? rmConv(o) : o;
      el.dataset.rmOut = want;
      if (cur !== want) el.textContent = want;
    });
  }

  onReady(() => {
    if (isReader) return;
    romajiApply();
    if (state.romaji && state.romajiKanji) setTimeout(kmLoad, 800);
  });

  /* ====================================================================
   * Deteksi latar terang -> gelapkan (tidak bergantung nama class situs)
   * ==================================================================== */
  const SKIP_DARK = new Set(['IMG', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'SCRIPT', 'STYLE', 'LINK', 'META', 'VIDEO', 'CANVAS', 'PICTURE', 'SOURCE', 'IFRAME']);

  // Kembalikan warna latar (string) kalau elemen berlatar terang & tidak transparan, selain itu null.
  function lightBg(el) {
    const c = getComputedStyle(el).backgroundColor;
    const m = c && c.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:[,\s/]+([\d.]+%?))?\s*\)/);
    if (!m) return null;
    const a = m[4] === undefined ? 1 : (m[4].endsWith('%') ? parseFloat(m[4]) / 100 : +m[4]);
    if (a < 0.5) return null;
    const lum = (0.2126 * m[1] + 0.7152 * m[2] + 0.0722 * m[3]) / 255;
    return lum > 0.72 ? c : null;
  }

  function darkenTree(rootEl) {
    if (!state.dark || !rootEl || rootEl.nodeType !== 1 || !rootEl.isConnected) return;
    const skipSel = state.grid ? '#hmt-overlay, #hmt-dl, #hmt-preview, .hm-thumbs, .hm-grid-wrap, .gallery-content' : '#hmt-overlay, #hmt-dl, #hmt-preview, .hm-thumbs, .hm-grid-wrap';
    const all = [rootEl, ...rootEl.querySelectorAll('*')];
    for (const el of all) {
      if (!(el instanceof HTMLElement) || SKIP_DARK.has(el.tagName) || el.classList.contains('hmt-dk') || el.closest(skipSel)) continue;
      const bg = lightBg(el);
      if (bg) { el.dataset.hmtBg = bg; el.classList.add('hmt-dk'); }
    }
  }

  let rescanQueued = false;
  function rescanDark() {
    if (rescanQueued) return;
    rescanQueued = true;
    requestAnimationFrame(() => { rescanQueued = false; if (document.body) darkenTree(document.body); });
  }

  /* ---------- Dump diagnostik (tombol di panel Pengaturan) ---------- */
  function elPath(el) {
    const parts = [];
    for (let e = el, i = 0; e && e !== document.body && i < 6; e = e.parentElement, i++) {
      const cls = [...e.classList].filter((c) => !/^(hmt-|hm-)/.test(c)).slice(0, 3).map((c) => '.' + c).join('');
      parts.unshift(e.tagName.toLowerCase() + (e.id ? '#' + e.id : '') + cls);
    }
    return parts.join(' > ');
  }

  function openTag(el) {
    const h = el.cloneNode(false).outerHTML;
    return h.length > 200 ? h.slice(0, 200) + '…' : h;
  }

  function buildDump() {
    const lines = ['URL: ' + location.href, 'Dark: ' + state.dark + ' | Grid: ' + state.grid + ' | v2.5', ''];
    lines.push('== Struktur atas body ==');
    [...document.body.children].slice(0, 25).forEach((e) => {
      const r = e.getBoundingClientRect();
      lines.push(`${elPath(e) || e.tagName.toLowerCase()} | ${Math.round(r.width)}x${Math.round(r.height)}`);
    });
    lines.push('', '== Elemen berlatar terang (warna asli | ukuran | path | tag pembuka) ==');
    let n = 0;
    for (const el of document.body.querySelectorAll('*')) {
      if (!(el instanceof HTMLElement) || SKIP_DARK.has(el.tagName) || el.closest('#hmt-overlay, #hmt-dl, .hm-grid-wrap')) continue;
      const bg = el.dataset.hmtBg || lightBg(el);
      if (!bg) continue;
      const r = el.getBoundingClientRect();
      lines.push(`${bg} | ${Math.round(r.width)}x${Math.round(r.height)} | ${elPath(el)} | ${openTag(el)}`);
      if (++n >= 120) { lines.push('... (dipotong)'); break; }
    }
    if (!n) lines.push('(tidak ada)');
    return lines.join('\n');
  }

  function copyDump(btn) {
    const text = buildDump();
    console.log('[HMT dump]\n' + text);
    const done = (ok) => {
      btn.textContent = T(ok ? 'Tersalin ✓' : 'Lihat console (F12)');
      setTimeout(() => { btn.textContent = T('Salin dump'); }, 2500);
    };
    const fallback = () => {
      try {
        const ta = document.createElement('textarea');
        ta.value = text; ta.style.cssText = 'position:fixed;left:-9999px;top:0';
        document.body.appendChild(ta); ta.select();
        const ok = document.execCommand('copy');
        ta.remove(); done(ok);
      } catch (e) { done(false); }
    };
    if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(() => done(true), fallback);
    else fallback();
  }

  /* ====================================================================
   * URL gambar reader (dipakai reader webtoon, thumbnail besar, dan hover preview)
   * ==================================================================== */
  // Daftar URL kandidat untuk satu gambar. Dicoba berurutan kalau ada yang gagal.
  // Catatan: subdomain gambar penuh = w{n} (webp) / a{n} (avif), BUKAN "aa".
  function candidates(f) {
    const gg = window.gg;
    const id = window.galleryinfo && window.galleryinfo.id;
    const exts = [];
    if (f.haswebp !== 0) exts.push('webp');
    if (f.hasavif) exts.push('avif');
    if (!exts.length) exts.push('webp');

    const out = [];
    const push = (u) => { if (u && !out.includes(u)) out.push(u); };

    exts.forEach((ext) => {
      if (typeof window.url_from_url_from_hash === 'function') {
        try { push(window.url_from_url_from_hash(id, f, ext)); } catch (e) { /* lanjut */ }
      }
      try {
        const path = gg.b + gg.s(f.hash) + '/' + f.hash + '.' + ext;
        const n = parseInt(gg.s(f.hash), 10);
        const prefix = ext === 'webp' ? 'w' : 'a';
        const first = 1 + gg.m(n);
        [first, 1, 2, 3].forEach((k) => push(`https://${prefix}${k}.gold-usergeneratedcontent.net/${path}`));
      } catch (e) { /* gg tidak lengkap */ }
    });
    return out;
  }

  /* ====================================================================
   * THUMBNAIL BESAR (halaman galeri): ganti strip thumbnail kecil dengan grid
   * besar yang memakai gambar reader.
   * ==================================================================== */
  // Cari kumpulan thumbnail asli: tautan ke /reader/ yang berisi <img>, dikelompokkan per induk.
  function findThumbGroup() {
    const as = [...document.querySelectorAll('a[href*="/reader/"]')]
      .filter((a) => a.querySelector('img') && !a.closest('.hm-thumbs, .hm-grid-wrap, .gallery-content, #hmt-overlay'));
    if (as.length < 2) return null;

    const groups = new Map();
    for (const a of as) {
      const p = a.parentElement;
      if (!p) continue;
      const k = p.tagName + '|' + p.className;
      if (!groups.has(k)) groups.set(k, []);
      groups.get(k).push(a);
    }
    let best = null;
    for (const g of groups.values()) if (!best || g.length > best.length) best = g;
    if (!best || best.length < 2) return null;

    let lca = best[0].parentElement;
    while (lca && !best.every((a) => lca.contains(a))) lca = lca.parentElement;
    if (!lca || lca === document.body || lca === root || lca.matches('.container, .content, .top-content, .bottom-content')) return null;
    return { anchors: best, lca };
  }

  let infoReqTimer = 0;
  function requestInfo() { // kalau situs belum memuat galleryinfo/gg, muat sendiri setelah jeda
    if (infoReqTimer) return;
    infoReqTimer = setTimeout(() => {
      const idm = location.pathname.match(/(\d+)\.html$/);
      const inject = (src) => {
        const s = document.createElement('script');
        s.src = src; s.onload = () => buildThumbs();
        (document.head || root).appendChild(s);
      };
      if (!window.galleryinfo && idm) {
        const ex = [...document.scripts].find((s) => /\/galleries\/\d+\.js/.test(s.src));
        inject(ex ? ex.src : `https://ltn.gold-usergeneratedcontent.net/galleries/${idm[1]}.js`);
      }
      if (!window.gg) {
        const ex = [...document.scripts].find((s) => /\/gg\.js/.test(s.src));
        inject(ex ? ex.src : 'https://ltn.gold-usergeneratedcontent.net/gg.js');
      }
    }, 2500);
  }

  function buildThumbs() {
    if (!isGallery || !state.thumbs) return;
    const info = window.galleryinfo;
    const gg = window.gg;
    if (!(info && info.files && info.files.length && gg && typeof gg.m === 'function')) { requestInfo(); return; }
    const g = findThumbGroup();
    if (!g) return;

    const idm = location.pathname.match(/(\d+)\.html$/);
    const gid = info.id || (idm && idm[1]) || '';
    const siteSrc = g.anchors.map((a) => {
      const im = a.querySelector('img');
      let s = im ? (im.getAttribute('data-src') || im.getAttribute('src') || '') : '';
      if (!s || s.startsWith('data:')) {
        const so = a.querySelector('source[data-srcset], source[srcset]');
        s = so ? ((so.getAttribute('data-srcset') || so.getAttribute('srcset') || '').trim().split(/\s+/)[0]) : '';
      }
      if (s.startsWith('data:')) s = '';
      if (s.startsWith('//')) s = 'https:' + s;
      return s;
    });

    const host = thumbHost || mk('div', 'hm-thumbs');
    const frag = document.createDocumentFragment();
    info.files.forEach((f, i) => {
      const a = mk('a', 'hm-th');
      a.href = `/reader/${gid}.html#${i + 1}`;
      a.dataset.i = String(i + 1);
      const urls = candidates(f);
      a._urls = urls;

      const img = document.createElement('img');
      img.loading = 'lazy'; img.decoding = 'async'; img.alt = '';
      if (f.width && f.height) {
        a.dataset.r = (f.width / f.height).toFixed(4);
        img.width = f.width; img.height = f.height;
        img.style.aspectRatio = `${f.width} / ${f.height}`;
      }
      const list = state.thumbReader ? urls : [siteSrc[i], ...urls].filter(Boolean);
      let k = 0;
      img.addEventListener('error', () => { if (++k < list.length) img.src = list[k]; });
      if (list.length) img.src = list[0];

      a.appendChild(img);
      a.appendChild(mk('span', 'hm-thn', String(i + 1)));
      frag.appendChild(a);
    });
    host.dataset.n = String(info.files.length);
    host.replaceChildren(frag);
    attachPreview(host);

    thumbHost = host;
    thumbOrig = g.lca;
    if (host.previousElementSibling !== g.lca) g.lca.after(host);
    apply();
  }

  function thumbsMaybe() {
    if (!isGallery || !state.thumbs) return;
    if (thumbHost && thumbHost.isConnected && thumbOrig && thumbOrig.isConnected) return;
    buildThumbs();
  }

  function rebuildThumbs() {
    hidePreview();
    if (thumbOrig) thumbOrig.classList.remove('hmt-hide');
    if (thumbHost) thumbHost.remove();
    thumbHost = null; thumbOrig = null;
    root.classList.remove('hm-thumbs-on');
    if (state.thumbs) buildThumbs();
    apply();
  }

  /* ====================================================================
   * HOVER PREVIEW (di atas thumbnail halaman galeri)
   * ==================================================================== */
  let pv = null, pvImg = null, pvLbl = null;
  let pvTimer = 0, pvCur = null, pvUrls = null, pvIdx = 0;
  const pvPt = { x: 0, y: 0 };

  function ensurePreview() {
    if (pv && pv.isConnected) return;
    pv = mk('div'); pv.id = 'hmt-preview';
    pvImg = document.createElement('img'); pvImg.alt = '';
    pvLbl = mk('div', 'hmt-pv-n');
    pv.append(pvImg, pvLbl);
    pvImg.addEventListener('error', () => { if (pvUrls && ++pvIdx < pvUrls.length) pvImg.src = pvUrls[pvIdx]; });
    pvImg.addEventListener('load', placePreview);
    document.body.appendChild(pv);
  }

  function placePreview() {
    if (!pvCur || !pv || pv.style.display !== 'block') return;
    const vw = window.innerWidth, vh = window.innerHeight, gap = 14;
    let r = +pvCur.dataset.r;
    if (!r) r = pvImg.naturalWidth && pvImg.naturalHeight ? pvImg.naturalWidth / pvImg.naturalHeight : 0.7;

    const rect = state.pvMode === 'cursor'
      ? { left: pvPt.x + 6, right: pvPt.x + 6, top: pvPt.y, bottom: pvPt.y }
      : pvCur.getBoundingClientRect();

    const spaceR = vw - rect.right - gap - 8;
    const spaceL = rect.left - gap - 8;
    const right = spaceR >= spaceL; // sisi yang lebih lega
    const availW = Math.max(120, (right ? spaceR : spaceL) - 2); // -2: border
    const maxH = Math.min(vh - 16, vh * state.pvSize / 100) - 2;
    const h = Math.min(maxH, availW / r);
    const w = h * r;

    pvImg.style.width = Math.round(w) + 'px';
    pvImg.style.height = Math.round(h) + 'px';
    const x = right ? rect.right + gap : rect.left - gap - w;
    const y = Math.min(Math.max(8, (rect.top + rect.bottom) / 2 - h / 2), vh - h - 8);
    pv.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;
  }

  function showPreview(cell) {
    if (!state.pvOn || !cell || !cell.isConnected) return;
    ensurePreview();
    pvCur = cell;
    pvUrls = cell._urls || [];
    pvIdx = 0;
    const src = pvUrls[0] || '';
    if (src && pvImg.getAttribute('src') !== src) pvImg.src = src;
    pvLbl.textContent = cell.dataset.i + ' / ' + (cell.parentElement ? cell.parentElement.dataset.n || '' : '');
    pv.style.display = 'block';
    placePreview();
  }

  function hidePreview() {
    clearTimeout(pvTimer);
    pvCur = null;
    if (pv) pv.style.display = 'none';
  }

  function attachPreview(host) {
    if (host.dataset.pv) return;
    host.dataset.pv = '1';
    host.addEventListener('mouseover', (e) => {
      const c = e.target.closest && e.target.closest('.hm-th');
      clearTimeout(pvTimer);
      if (!c) { hidePreview(); return; }
      if (!state.pvOn) return;
      const shown = pv && pv.style.display === 'block';
      if (c === pvCur && shown) return;
      pvTimer = setTimeout(() => showPreview(c), shown ? 0 : state.pvDelay);
    });
    host.addEventListener('mouseleave', hidePreview);
    host.addEventListener('mousemove', (e) => {
      pvPt.x = e.clientX; pvPt.y = e.clientY;
      if (state.pvMode === 'cursor' && pvCur) placePreview();
    }, { passive: true });
  }
  window.addEventListener('scroll', () => { if (pvCur) hidePreview(); }, { passive: true });
  window.addEventListener('keydown', (e) => { if (e.key === 'Escape') hidePreview(); }, true);

  // Sembunyikan pagination di atas daftar (yang di bawah dibiarkan) dan footer "Donate BTC".
  let footerEl = null;
  let lastFooterScan = 0;
  function trimChrome(force) {
    const gc = document.querySelector('.gallery-content');
    if (gc) {
      document.querySelectorAll('.page-container').forEach((pc) => {
        // pagination yang posisinya sebelum daftar galeri = yang di atas
        if (!pc.classList.contains('hmt-hide') && (pc.compareDocumentPosition(gc) & Node.DOCUMENT_POSITION_FOLLOWING)) {
          pc.classList.add('hmt-hide');
        }
      });
    }

    if (footerEl && footerEl.isConnected) return;
    const now = Date.now();
    if (!force && now - lastFooterScan < 500) return;
    lastFooterScan = now;
    const body = document.body;
    if (!body) return;
    const tw = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = tw.nextNode())) {
      if (!/^\s*Donate\s*BTC/i.test(n.nodeValue)) continue;
      let t = n.parentElement;
      if (!t || t.closest('#hmt-overlay')) continue;
      while (t.parentElement && t.parentElement !== body && t.parentElement.textContent.trim().length < 100) t = t.parentElement;
      t.classList.add('hmt-hide');
      footerEl = t;
      break;
    }
  }

  if (!isReader) {
    // Dijalankan langsung di callback observer (microtask) -> selesai sebelum browser sempat menggambar.
    // stickyNavSync ikut dijalankan di sini (microtask, sebelum paint) supaya banner baru hasil render ulang situs langsung dikunci
    const run = () => { ensureStyle(); gridify(); trimChrome(); ensureNav(); thumbsMaybe(); stickyNavSync(); };

    new MutationObserver((muts) => {
      let need = false;
      for (const m of muts) {
        const t = m.target.nodeType === 1 ? m.target : m.target.parentElement;
        if (t && t.closest && t.closest('.hm-grid-wrap, .hm-thumbs, #hmt-preview, #hmt-overlay, #hmt-dl')) continue; // abaikan perubahan buatan sendiri
        need = true;
        m.addedNodes.forEach((n) => { if (n.nodeType === 1) darkenTree(n); });
      }
      if (need) run();
    }).observe(root, { childList: true, subtree: true });

    // stylesheet situs yang datang belakangan mengubah warna latar -> cek ulang
    document.addEventListener('load', (e) => { if (e.target && e.target.tagName === 'LINK') rescanDark(); }, true);

    onReady(() => { run(); trimChrome(true); darkenTree(document.body); trimmed = true; apply(); });

    // Situs mengganti class/style elemen (mis. banner judul) -> class gelap hilang -> latar terang sempat kedip.
    // Gelapkan lagi di microtask yang sama, sebelum browser menggambar.
    onReady(() => {
      new MutationObserver((muts) => {
        if (!state.dark) return;
        for (const m of muts) {
          const t = m.target;
          if (!(t instanceof HTMLElement) || t === root || t === document.body) continue;
          if (m.attributeName === 'class' && t.classList.contains('hmt-dk') && t.dataset.hmtBg === undefined) continue;
          if (t.classList.contains('hmt-dk') || SKIP_DARK.has(t.tagName)) continue;
          if (t.closest('#hmt-overlay, #hmt-dl, #hmt-preview, .hm-thumbs, .hm-grid-wrap, .gallery-content')) continue;
          const bg = lightBg(t);
          if (bg) { t.dataset.hmtBg = bg; t.classList.add('hmt-dk'); }
        }
      }).observe(document.body, { attributes: true, attributeFilter: ['class', 'style'], subtree: true });
    });
    window.addEventListener('load', () => { trimChrome(true); darkenTree(document.body); setTimeout(rescanDark, 1000); }, { once: true });
  }


  /* ====================================================================
   * INTRO KARTU (animasi muncul satu per satu dari kiri-atas)
   * Cara kerja:
   *  1) kartu langsung disembunyikan (class hmt-pre) supaya tidak sempat tampil dulu
   *  2) tunggu sampai daftar kartu stabil + gambar di layar termuat
   *  3) tiap kartu yang terlihat diputar animasinya dengan jeda bertingkat (urut DOM = kiri -> kanan, atas -> bawah)
   * Pakai Web Animations API, jadi tidak tergantung class/CSS lain.
   * ==================================================================== */
  const introInView = (c) => { const r = c.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.top < innerHeight && r.bottom > 0; };
  // kelompokkan nilai posisi yang berdekatan jadi nomor baris/kolom (0, 1, 2, ...)
  function introCluster(vals) {
    const uniq = [...new Set(vals.map((v) => Math.round(v)))].sort((a, b) => a - b);
    const idx = new Map(); let g = -1, last = -1e9;
    uniq.forEach((v) => { if (v - last > 8) g++; idx.set(v, g); last = v; });
    return vals.map((v) => idx.get(Math.round(v)));
  }
  // putar animasi pada kartu yang terlihat; mengembalikan total durasi (ms)
  function introPlay(cards, offset) {
    cards.forEach((c) => { try { c.getAnimations().forEach((a) => a.cancel()); } catch (e) { /* abaikan */ } });
    const vis = cards.filter(introInView);
    if (!vis.length) return 0;
    const step = Math.max(0, +state.introStep || 0);
    const order = state.introOrder;
    let raw;
    if (order === 'diag') {
      const rects = vis.map((c) => c.getBoundingClientRect());
      const rows = introCluster(rects.map((r) => r.top)), cols = introCluster(rects.map((r) => r.left));
      raw = vis.map((_, k) => (rows[k] + cols[k]) * step * 1.4);
    } else if (order === 'rand') {
      const perm = vis.map((_, k) => k).sort(() => Math.random() - .5);
      raw = perm.map((rank) => rank * step);
    } else raw = vis.map((_, k) => k * step);
    const mx = Math.max(0, ...raw), sc = mx > INTRO.maxStagger ? INTRO.maxStagger / mx : 1;
    const keys = Object.keys(INTRO_FX);
    const dur0 = INTRO.dur * (+state.introSpeed || 1);
    let total = 0;
    vis.forEach((c, k) => {
      const fx = INTRO_FX[state.introStyle === 'mix' ? keys[Math.floor(Math.random() * keys.length)] : state.introStyle] || INTRO_FX.rise;
      const dur = dur0 * (fx.d || 1), delay = Math.round(raw[k] * sc) + (offset || 0);
      total = Math.max(total, delay + dur);
      try { c.animate(fx.kf, { duration: dur, delay, easing: fx.ease, fill: 'backwards' }); } catch (e) { /* browser lama: kartu tampil biasa */ }
    });
    return total;
  }
  // animasi header: navbar dulu, banner menyusul; mengembalikan { total, offset } (offset = kapan kartu mulai)
  function introHeaders() {
    const nav = document.querySelector('.navbar');
    const bn = isGallery ? null : (document.querySelector('.hmt-banner:not(.navbar)') || document.querySelector('.list-title'));
    const dur0 = 650 * (+state.introSpeed || 1);
    let total = 0, any = false;
    const items = (el) => {
      const out = [];
      [...el.children].forEach((ch) => {
        const t = ch.tagName;
        if (t === 'SCRIPT' || t === 'STYLE' || t === 'NOSCRIPT') return;
        if (t === 'NAV') { const li = ch.querySelectorAll('li'); out.push(...(li.length ? li : ch.children)); } else out.push(ch);
      });
      return out;
    };
    const run = (el, key, base) => {
      const fx = HEAD_FX[key];
      if (!el || !fx || key === 'none') return;
      try { el.getAnimations({ subtree: true }).forEach((a) => a.cancel()); } catch (e) { /* abaikan */ }
      any = true;
      try {
        if (key === 'items') {
          const list = items(el);
          if (!list.length) { el.animate(HEAD_FX.fade.kf, { duration: dur0, delay: base, easing: 'ease-out', fill: 'backwards' }); total = Math.max(total, base + dur0); return; }
          const dur = dur0 * .9;
          list.forEach((it, k) => {
            const delay = base + k * 70;
            it.animate([{ opacity: 0, transform: 'translateY(-14px)' }, { opacity: 1, transform: 'none' }], { duration: dur, delay, easing: 'cubic-bezier(.22, 1, .36, 1)', fill: 'backwards' });
            total = Math.max(total, delay + dur);
          });
        } else {
          const dur = dur0 * (fx.d || 1);
          el.animate(fx.kf, { duration: dur, delay: base, easing: fx.ease, fill: 'backwards' });
          total = Math.max(total, base + dur);
        }
      } catch (e) { /* browser lama: header tampil biasa */ }
    };
    run(nav, state.introNav, 0);
    const navOn = !!state.introNav && state.introNav !== 'none';
    run(bn, state.introBanner, navOn ? 160 : 0);
    // halaman galeri: cover + judul/info muncul berurutan setelah navbar
    let nBlk = 0;
    const bfx = isGallery ? INTRO_FX[state.introBlock] : null;
    if (bfx) {
      const big = '.hm-thumbs, .hm-grid-wrap';
      const picks = [];
      ['.cover-column', '.dj-gallery', '#gallery-brand', '.gallery-info'].forEach((sel) => document.querySelectorAll(sel).forEach((el) => {
        if (!picks.includes(el) && !el.querySelector(big)) picks.push(el); // wadah yang memuat thumbnail/kartu dilewati
      }));
      const tops = picks.filter((el) => !picks.some((o) => o !== el && o.contains(el)))
        .sort((a, b) => (a.compareDocumentPosition(b) & 4 ? -1 : 1));
      tops.forEach((el, k) => {
        try {
          el.getAnimations({ subtree: true }).forEach((a) => a.cancel());
          const dur = 650 * (+state.introSpeed || 1) * (bfx.d || 1), delay = (navOn ? 140 : 0) + k * 120;
          el.animate(bfx.kf, { duration: dur, delay, easing: bfx.ease, fill: 'backwards' });
          total = Math.max(total, delay + dur); any = true; nBlk++;
        } catch (e) { /* abaikan */ }
      });
    }
    return { total, offset: any ? (nBlk ? 140 + nBlk * 120 + 160 : 300) : 0 };
  }
  // jalankan semua: header dulu, lalu kartu satu per satu; mengembalikan total durasi (ms)
  function introRun(cards) {
    const h = introHeaders();
    const t = introPlay(cards, h.offset);
    return Math.max(h.total, t);
  }
  const ITEM_SEL = '.hm-card, .hm-th'; // kartu daftar/related + thumbnail galeri
  const itemSig = () => {
    const it = document.querySelectorAll(ITEM_SEL), n = it.length;
    return n + ':' + (n ? it[0].getAttribute('href') + '|' + it[n - 1].getAttribute('href') : '');
  };
  // pratinjau dari panel pengaturan: putar ulang + latar panel dibuat transparan sebentar supaya kartu terlihat
  let introPvT = 0, introPvR = 0;
  function introPreview() {
    if (isReader || !state.introOn) return;
    clearTimeout(introPvT);
    introPvT = setTimeout(() => {
      const total = introRun([...document.querySelectorAll(ITEM_SEL)]);
      if (!overlay || !total) return;
      overlay.classList.add('hmt-pv');
      clearTimeout(introPvR);
      introPvR = setTimeout(() => overlay.classList.remove('hmt-pv'), total + 400);
    }, 300);
  }

  /* mode 'load': sembunyikan sejak awal, putar setelah daftar stabil.
   * mode 'nav' : dipasang saat tombol Back/Forward (popstate); hanya bereaksi kalau situs merender ulang daftarnya. */
  function introWatch(mode) {
    const nav = mode === 'nav';
    let t0 = Date.now(), finished = false, hidden = false, timer = 0, guard = 0, hdr = null, hdrAt = 0, sig = nav ? itemSig() : '';
    const BLK = ['hmt-pre-nav', 'hmt-pre-navi', 'hmt-pre-bn', 'hmt-pre-bni', 'hmt-pre-blk'];
    const dropBlk = () => BLK.forEach((c) => root.classList.remove(c));
    const release = () => { finished = true; mo.disconnect(); clearTimeout(timer); clearTimeout(guard); root.classList.remove('hmt-pre'); dropBlk(); srQueue(); };
    const hide = () => { if (hidden) return; hidden = true; root.classList.add('hmt-pre'); clearTimeout(guard); guard = setTimeout(() => { if (!finished) release(); }, INTRO.maxWait + 1500); };
    const runHdr = () => { if (hdr || nav) return; hdr = introHeaders(); hdrAt = Date.now(); dropBlk(); }; // header + blok galeri
    const imgsReady = (els) => els.every((c) => { const im = c.querySelector('img'); return !im || im.complete; });
    const check = () => {
      if (finished) return;
      const elapsed = Date.now() - t0, timeout = elapsed > INTRO.maxWait;
      const stkOk = stkReady || !state.stickyNav;
      const items = [...document.querySelectorAll(ITEM_SEL)];
      if (isGallery && !nav && !hdr && stkOk && elapsed > 500) runHdr(); // cover & info tidak menunggu thumbnail
      // halaman galeri: tunggu thumbnail besar terbentuk dulu (kartu related bisa muncul lebih awal)
      if (isGallery && !nav && state.thumbs && !timeout && !document.querySelector('.hm-th')) { timer = setTimeout(check, 150); return; }
      if (!items.length) { if (timeout) { runHdr(); release(); } else timer = setTimeout(check, 150); return; }
      if (!timeout && !(imgsReady(items.filter(introInView)) && stkOk)) { timer = setTimeout(check, 100); return; }
      runHdr();
      introPlay(items, hdr ? Math.max(0, hdr.offset - (Date.now() - hdrAt)) : 0);
      release(); // animasi sudah terdaftar (fill backwards menahan opacity 0 selama jeda), aman membuka sembunyi
    };
    const mo = new MutationObserver(() => {
      if (finished) return;
      const s2 = itemSig();
      if (s2 === sig) return;
      sig = s2;
      if (nav && !hidden) { hide(); t0 = Date.now(); } // daftar diganti situs -> sembunyikan seketika (sebelum paint)
      clearTimeout(timer); timer = setTimeout(check, INTRO.settle); // daftar berubah -> tunggu stabil lagi
    });
    mo.observe(root, { childList: true, subtree: true });
    if (nav) { guard = setTimeout(() => { if (!hidden) release(); }, 2500); return; } // tidak ada perubahan daftar -> tidak melakukan apa-apa
    hide();
    if (state.introNav && state.introNav !== 'none') root.classList.add(state.introNav === 'items' ? 'hmt-pre-navi' : 'hmt-pre-nav');
    if (!isGallery && state.introBanner && state.introBanner !== 'none') root.classList.add(state.introBanner === 'items' ? 'hmt-pre-bni' : 'hmt-pre-bn');
    if (isGallery && INTRO_FX[state.introBlock]) root.classList.add('hmt-pre-blk');
    timer = setTimeout(check, INTRO.settle);
  }
  /* ====================================================================
   * ANIMASI SAAT DI-SCROLL: kartu/thumbnail di bawah layar disembunyikan (data-hmt-sr),
   * lalu dianimasikan sekali ketika masuk layar. Yang sudah terlihat saat halaman dibuka tidak disentuh.
   * ==================================================================== */
  let srSeen = new WeakSet(), srIO = null, srRaf = 0;
  const srClear = () => document.querySelectorAll('[data-hmt-sr]').forEach((e) => e.removeAttribute('data-hmt-sr'));
  function srReveal(entries) {
    const hit = entries.filter((e) => e.isIntersecting)
      .sort((a, b) => (a.boundingClientRect.top - b.boundingClientRect.top) || (a.boundingClientRect.left - b.boundingClientRect.left));
    if (!hit.length) return;
    const step = Math.min(+state.introStep || 0, 90), keys = Object.keys(INTRO_FX);
    const st = state.introScrollStyle === 'same' ? state.introStyle : state.introScrollStyle;
    hit.forEach((en, k) => {
      const el = en.target;
      if (srIO) srIO.unobserve(el);
      const fx = INTRO_FX[st === 'mix' ? keys[Math.floor(Math.random() * keys.length)] : st] || INTRO_FX.rise;
      try { el.animate(fx.kf, { duration: INTRO.dur * (+state.introSpeed || 1) * (fx.d || 1) * .85, delay: k * step, easing: fx.ease, fill: 'backwards' }); } catch (e) { /* tampil biasa */ }
      el.removeAttribute('data-hmt-sr'); // animasi sudah terdaftar (fill backwards menahan opacity 0), aman dibuka
    });
  }
  function srScan() {
    srRaf = 0;
    if (isReader || !state.introScroll || root.classList.contains('hmt-pre')) return; // intro awal masih jalan: dipanggil lagi saat selesai
    try {
      if (!srIO) srIO = new IntersectionObserver(srReveal, { rootMargin: '0px 0px -6% 0px', threshold: 0.01 });
      document.querySelectorAll(ITEM_SEL).forEach((el) => {
        if (srSeen.has(el)) return;
        const r = el.getBoundingClientRect();
        if (!r.width || !r.height) return; // sedang tersembunyi: cek lagi nanti
        srSeen.add(el);
        if (r.top >= innerHeight * .94) { el.setAttribute('data-hmt-sr', ''); srIO.observe(el); } // di bawah layar
      });
    } catch (e) { srClear(); } // apa pun yang gagal: jangan biarkan kartu tersembunyi
  }
  function srQueue() { if (!srRaf) srRaf = requestAnimationFrame(srScan); }
  function srRefresh() {
    if (state.introScroll) { srQueue(); return; }
    if (srIO) { srIO.disconnect(); srIO = null; }
    srClear(); srSeen = new WeakSet();
  }
  if (!isReader) {
    new MutationObserver(srQueue).observe(root, { childList: true, subtree: true });
    window.addEventListener('scroll', srQueue, { passive: true });
    window.addEventListener('resize', srQueue);
    srQueue();
  }

  if (!isReader && state.introOn) introWatch('load');
  // tombol Back/Forward: halaman dipulihkan dari cache browser (bfcache) -> putar ulang animasinya
  window.addEventListener('pageshow', (e) => {
    if (!e.persisted || isReader || !state.introOn) return;
    try { introRun([...document.querySelectorAll(ITEM_SEL)]); } catch (err) { /* abaikan */ }
  });
  // Back/Forward pada situs yang merender ulang daftar tanpa reload
  window.addEventListener('popstate', () => { if (!isReader && state.introOn) introWatch('nav'); });

  /* ====================================================================
   * READER: WEBTOON
   * ==================================================================== */
  if (isReader) {
    const waitFor = (cond, ms = 12000) => new Promise((res) => {
      const t0 = Date.now();
      (function tick() {
        if (cond()) return res(true);
        if (Date.now() - t0 > ms) return res(false);
        setTimeout(tick, 150);
      })();
    });

    function attach(img, f) {
      const list = candidates(f);
      let i = 0;
      img.addEventListener('error', () => {
        if (++i < list.length) {
          img.src = list[i];
        } else {
          img.classList.add('wt-fail');
          img.title = T('Gagal dimuat, klik untuk coba lagi');
          console.warn('[HMT] semua URL gagal untuk gambar', img.dataset.idx, list);
        }
      });
      img.addEventListener('click', () => {
        if (!img.classList.contains('wt-fail')) return;
        img.classList.remove('wt-fail');
        i = 0;
        img.removeAttribute('src');
        requestAnimationFrame(() => { img.src = list[0]; });
      });
      if (list.length) img.src = list[0];
      else img.classList.add('wt-fail');
    }

    let building = false;
    async function buildWebtoon() {
      if (building) return;
      building = true;
      try {
        const ok = await waitFor(() => document.body && window.galleryinfo && window.galleryinfo.files &&
          window.gg && typeof window.gg.m === 'function');
        if (!ok) {
          console.warn('[HMT] galleryinfo/gg tidak ditemukan, webtoon dibatalkan');
          wtFailed = true; apply(); // kembali ke reader asli
          return;
        }

        const files = window.galleryinfo.files;
        const old = document.getElementById('wt-container');
        if (old) old.remove();

        const wrap = mk('div');
        wrap.id = 'wt-container';

        files.forEach((f, i) => {
          const img = document.createElement('img');
          img.loading = 'lazy';
          img.decoding = 'async';
          img.alt = '';
          img.dataset.idx = i;
          if (f.width && f.height) {
            img.width = f.width; img.height = f.height;
            img.style.aspectRatio = `${f.width} / ${f.height}`;
          }
          attach(img, f);
          wrap.appendChild(img);
        });

        const anchor = document.getElementById('comicImages');
        if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(wrap, anchor);
        else document.body.appendChild(wrap);

        // counter halaman + sinkron dengan dropdown halaman
        let counter = document.getElementById('wt-counter');
        if (!counter) { counter = mk('div'); counter.id = 'wt-counter'; document.body.appendChild(counter); }
        const sel = document.getElementById('single-page-select');
        const imgs = [...wrap.children];
        const io = new IntersectionObserver((entries) => {
          entries.forEach((e) => {
            if (!e.isIntersecting) return;
            const idx = +e.target.dataset.idx;
            counter.textContent = `${idx + 1} / ${imgs.length}`;
            if (sel && sel.options.length === imgs.length) sel.selectedIndex = idx;
          });
        }, { rootMargin: '-45% 0px -45% 0px' });
        imgs.forEach((im) => io.observe(im));
        counter.textContent = `1 / ${imgs.length}`;

        // dropdown halaman -> lompat ke gambar
        if (sel && !sel.dataset.hmt) {
          sel.dataset.hmt = '1';
          sel.addEventListener('change', (e) => {
            if (!window.__wtReady || !state.webtoon) return;
            e.stopImmediatePropagation();
            const target = imgs[sel.selectedIndex];
            if (target) target.scrollIntoView({ block: 'start' });
          }, true);
        }

        window.__wtReady = true;
        wtFailed = false;
        apply();
        const hm = location.hash.match(/^#(\d+)/);
        const target = hm && imgs[+hm[1] - 1];
        if (target) target.scrollIntoView({ block: 'start' });
        else window.scrollTo(0, 0);
      } finally {
        building = false;
      }
    }

    // Keyboard: j / panah kiri = gambar berikutnya, k / panah kanan = sebelumnya
    window.addEventListener('keydown', (e) => {
      if (!window.__wtReady || !state.webtoon) return;
      if (/INPUT|SELECT|TEXTAREA/.test(e.target.tagName)) return;
      const k = e.key;
      if (!['ArrowLeft', 'ArrowRight', 'j', 'k', 'v', 'h'].includes(k)) return;
      e.stopImmediatePropagation();
      e.preventDefault();
      const imgs = [...document.querySelectorAll('#wt-container img')];
      const mid = window.innerHeight / 2;
      let cur = imgs.findIndex((i) => i.getBoundingClientRect().bottom > mid);
      if (cur < 0) cur = imgs.length - 1;
      const next = (k === 'ArrowLeft' || k === 'j') ? cur + 1 : (k === 'ArrowRight' || k === 'k') ? cur - 1 : cur;
      if (imgs[next]) imgs[next].scrollIntoView({ block: 'start' });
    }, true);

    buildWebtoonFn = buildWebtoon;
    if (state.webtoon) buildWebtoon();
  }


  /* ====================================================================
   * PANEL PILIH UNDUHAN: klik "Download" -> pilih gambar dulu -> ZIP
   * (ZIP dibuat sendiri tanpa library; gambar webp/avif sudah terkompres
   * jadi disimpan apa adanya / "store")
   * ==================================================================== */
  const CRC_T = (() => {
    const t = new Uint32Array(256);
    for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; }
    return t;
  })();
  function crc32(u8) {
    let c = 0xFFFFFFFF;
    for (let i = 0; i < u8.length; i++) c = CRC_T[(c ^ u8[i]) & 255] ^ (c >>> 8);
    return (c ^ 0xFFFFFFFF) >>> 0;
  }

  function makeZip(entries) { // entries: [{ name, blob, size, crc }]
    const enc = new TextEncoder();
    const d = new Date();
    const dt = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF;
    const dd = (((Math.max(d.getFullYear(), 1980) - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF;
    const parts = [], central = [];
    let off = 0;
    for (const e of entries) {
      const nm = enc.encode(e.name);
      const lh = new DataView(new ArrayBuffer(30));
      lh.setUint32(0, 0x04034b50, true); lh.setUint16(4, 20, true); lh.setUint16(6, 0x0800, true); lh.setUint16(8, 0, true);
      lh.setUint16(10, dt, true); lh.setUint16(12, dd, true);
      lh.setUint32(14, e.crc, true); lh.setUint32(18, e.size, true); lh.setUint32(22, e.size, true);
      lh.setUint16(26, nm.length, true); lh.setUint16(28, 0, true);
      parts.push(lh.buffer, nm, e.blob);

      const ch = new DataView(new ArrayBuffer(46));
      ch.setUint32(0, 0x02014b50, true); ch.setUint16(4, 20, true); ch.setUint16(6, 20, true);
      ch.setUint16(8, 0x0800, true); ch.setUint16(10, 0, true);
      ch.setUint16(12, dt, true); ch.setUint16(14, dd, true);
      ch.setUint32(16, e.crc, true); ch.setUint32(20, e.size, true); ch.setUint32(24, e.size, true);
      ch.setUint16(28, nm.length, true); ch.setUint32(42, off, true);
      central.push(ch.buffer, nm);
      off += 30 + nm.length + e.size;
    }
    const cdSize = central.reduce((s, p) => s + p.byteLength, 0);
    const end = new DataView(new ArrayBuffer(22));
    end.setUint32(0, 0x06054b50, true);
    end.setUint16(8, entries.length, true); end.setUint16(10, entries.length, true);
    end.setUint32(12, cdSize, true); end.setUint32(16, off, true);
    return new Blob([...parts, ...central, end.buffer], { type: 'application/zip' });
  }

  // Konversi gambar (webp/avif) -> JPG lewat canvas. Latar putih untuk transparansi.
  // Mengembalikan { blob, w, h }.
  async function toJpg(buf, q) {
    const bmp = await createImageBitmap(new Blob([buf]));
    const w = bmp.width, h = bmp.height;
    const cv = document.createElement('canvas');
    cv.width = w; cv.height = h;
    const ctx = cv.getContext('2d');
    ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, w, h);
    ctx.drawImage(bmp, 0, 0);
    if (bmp.close) bmp.close();
    const blob = await new Promise((res, rej) => cv.toBlob((x) => (x ? res(x) : rej(new Error('toBlob gagal'))), 'image/jpeg', q / 100));
    cv.width = cv.height = 0;
    return { blob, w, h };
  }

  // PDF sederhana: satu gambar JPEG (DCTDecode) per halaman, ukuran halaman = ukuran gambar.
  function makePdf(pages) { // pages: [{ blob, size, w, h }]
    const enc = new TextEncoder();
    const parts = [];
    let off = 0;
    const push = (x) => { parts.push(x); off += x.byteLength !== undefined ? x.byteLength : x.size; };
    const str = (t) => push(enc.encode(t));
    const n = pages.length;
    const xref = new Array(3 * n + 3).fill(0);
    const obj = (id, fn) => { xref[id] = off; str(id + ' 0 obj\n'); fn(); str('endobj\n'); };

    str('%PDF-1.4\n%\u00e2\u00e3\u00cf\u00d3\n');
    obj(1, () => str('<< /Type /Catalog /Pages 2 0 R >>\n'));
    obj(2, () => str('<< /Type /Pages /Count ' + n + ' /Kids [' + pages.map((_, i) => (5 + 3 * i) + ' 0 R').join(' ') + '] >>\n'));
    pages.forEach((pg, i) => {
      const img = 3 + 3 * i, con = 4 + 3 * i, page = 5 + 3 * i;
      const pw = Math.round(pg.w * 0.75 * 100) / 100, ph = Math.round(pg.h * 0.75 * 100) / 100;
      obj(img, () => {
        str('<< /Type /XObject /Subtype /Image /Width ' + pg.w + ' /Height ' + pg.h +
          ' /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ' + pg.size + ' >>\nstream\n');
        push(pg.blob);
        str('\nendstream\n');
      });
      const content = 'q ' + pw + ' 0 0 ' + ph + ' 0 0 cm /Im0 Do Q';
      obj(con, () => str('<< /Length ' + content.length + ' >>\nstream\n' + content + '\nendstream\n'));
      obj(page, () => str('<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ' + pw + ' ' + ph + '] /Resources << /XObject << /Im0 ' + img +
        ' 0 R >> >> /Contents ' + con + ' 0 R >>\n'));
    });
    const xrefOff = off;
    let x = 'xref\n0 ' + (3 * n + 3) + '\n0000000000 65535 f \n';
    for (let id = 1; id < 3 * n + 3; id++) x += String(xref[id]).padStart(10, '0') + ' 00000 n \n';
    x += 'trailer\n<< /Size ' + (3 * n + 3) + ' /Root 1 0 R >>\nstartxref\n' + xrefOff + '\n%%EOF\n';
    str(x);
    return new Blob(parts, { type: 'application/pdf' });
  }

  /* ====================================================================
   * Notifikasi (toast) & dialog konfirmasi buatan sendiri
   * (pengganti alert/confirm bawaan browser)
   * ==================================================================== */
  let modalOpen = false;
  function toast(text, opt) {
    opt = opt || {};
    const type = opt.type || 'info';
    let host = document.getElementById('hmt-toasts');
    if (!host) { host = mk('div'); host.id = 'hmt-toasts'; }
    if (!host.isConnected) document.body.appendChild(host);
    const t = mk('div', 'hmt-toast ' + type);
    t.appendChild(mk('span', 't-ico', type === 'ok' ? '✓' : type === 'err' ? '✕' : 'ℹ'));
    t.appendChild(mk('span', 't-msg', T(text)));
    const kill = () => {
      if (!t.isConnected) return;
      t.classList.add('out');
      setTimeout(() => t.remove(), 200);
    };
    if (opt.action) {
      const b = mk('button', 'pri', T(opt.action.label)); b.type = 'button';
      b.addEventListener('click', () => { kill(); try { opt.action.fn(); } catch (e) { console.warn('[HMT]', e); } });
      t.appendChild(b);
    }
    const x = mk('button', 'x', '✕'); x.type = 'button'; x.addEventListener('click', kill);
    t.appendChild(x);
    host.appendChild(t);
    while (host.children.length > 4) host.firstChild.remove();
    const ms = opt.ms != null ? opt.ms : (opt.action ? 12000 : type === 'err' ? 8000 : 4500);
    if (ms > 0) setTimeout(kill, ms);
    return kill;
  }

  function askConfirm(o) {
    return new Promise((resolve) => {
      modalOpen = true;
      const ov = mk('div'); ov.id = 'hmt-modal';
      const box = mk('div', 'm-box');
      box.appendChild(mk('div', 'm-title', T(o.title || 'Konfirmasi')));
      if (o.text) box.appendChild(mk('div', 'm-text', T(o.text)));
      const act = mk('div', 'm-act');
      const bNo = mk('button', '', T(o.cancel || 'Batal')); bNo.type = 'button';
      const bOk = mk('button', o.danger ? 'danger' : 'pri', T(o.ok || 'OK')); bOk.type = 'button';
      act.append(bNo, bOk); box.appendChild(act); ov.appendChild(box);
      const done = (v) => {
        window.removeEventListener('keydown', onKey, true);
        ov.remove(); setTimeout(() => { modalOpen = false; }, 0); resolve(v);
      };
      const onKey = (e) => {
        if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); done(false); }
        else if (e.key === 'Enter') { e.preventDefault(); e.stopImmediatePropagation(); done(true); }
      };
      window.addEventListener('keydown', onKey, true);
      bNo.addEventListener('click', () => done(false));
      bOk.addEventListener('click', () => done(true));
      ov.addEventListener('mousedown', (e) => { if (e.target === ov) done(false); });
      document.body.appendChild(ov);
      bOk.focus && setTimeout(() => { try { bOk.focus(); } catch (e) { /* abaikan */ } }, 0);
    });
  }

  /* ====================================================================
   * Jendela: geser (drag) & ubah ukuran (resize), posisi diingat
   * ==================================================================== */
  function fixPos(box) {
    const b = box.getBoundingClientRect();
    box.style.left = b.left + 'px'; box.style.top = b.top + 'px';
    box.style.right = 'auto'; box.style.bottom = 'auto';
    return b;
  }
  function keepInView(box) {
    const b = box.getBoundingClientRect();
    if (!b.width) return;
    const x = Math.min(Math.max(b.left, 80 - b.width), innerWidth - 80);
    const y = Math.min(Math.max(b.top, 0), innerHeight - 40);
    if (x !== b.left || y !== b.top) {
      box.style.left = x + 'px'; box.style.top = y + 'px'; box.style.right = 'auto'; box.style.bottom = 'auto';
    }
  }
  function saveRect(box, key) {
    const b = box.getBoundingClientRect();
    set(key, { x: Math.round(b.left), y: Math.round(b.top), w: Math.round(b.width), h: Math.round(b.height) });
  }
  function attachDrag(box, handle, key) {
    handle.addEventListener('pointerdown', (e) => {
      if (e.button !== 0 || e.target.closest('button, input')) return;
      e.preventDefault();
      const b = fixPos(box);
      const sx = e.clientX, sy = e.clientY, ox = b.left, oy = b.top, w = b.width;
      try { handle.setPointerCapture(e.pointerId); } catch (err) { /* abaikan */ }
      const mv = (ev) => {
        box.style.left = Math.min(Math.max(ox + ev.clientX - sx, 80 - w), innerWidth - 80) + 'px';
        box.style.top = Math.min(Math.max(oy + ev.clientY - sy, 0), innerHeight - 40) + 'px';
      };
      const up = () => {
        handle.removeEventListener('pointermove', mv);
        handle.removeEventListener('pointerup', up);
        handle.removeEventListener('pointercancel', up);
        saveRect(box, key);
      };
      handle.addEventListener('pointermove', mv);
      handle.addEventListener('pointerup', up);
      handle.addEventListener('pointercancel', up);
    });
  }
  function attachResize(box, key, minW, minH) {
    ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw'].forEach((d) => {
      const h = mk('div', 'dl-rs dl-rs-' + d);
      box.appendChild(h);
      h.addEventListener('pointerdown', (e) => {
        if (e.button !== 0) return;
        e.preventDefault(); e.stopPropagation();
        const b = fixPos(box);
        const sx = e.clientX, sy = e.clientY;
        try { h.setPointerCapture(e.pointerId); } catch (err) { /* abaikan */ }
        const mv = (ev) => {
          const dx = ev.clientX - sx, dy = ev.clientY - sy;
          let w = b.width, hh = b.height, x = b.left, y = b.top;
          if (d.includes('e')) w = b.width + dx;
          if (d.includes('w')) w = b.width - dx;
          if (d.includes('s')) hh = b.height + dy;
          if (d.includes('n')) hh = b.height - dy;
          w = Math.min(Math.max(w, minW), innerWidth);
          hh = Math.min(Math.max(hh, minH), innerHeight);
          if (d.includes('w')) x = b.left + b.width - w;
          if (d.includes('n')) {
            y = b.top + b.height - hh;
            if (y < 0) { hh += y; y = 0; }
          }
          box.style.left = x + 'px'; box.style.top = y + 'px';
          box.style.width = w + 'px'; box.style.height = hh + 'px';
        };
        const up = () => {
          h.removeEventListener('pointermove', mv);
          h.removeEventListener('pointerup', up);
          h.removeEventListener('pointercancel', up);
          saveRect(box, key);
        };
        h.addEventListener('pointermove', mv);
        h.addEventListener('pointerup', up);
        h.addEventListener('pointercancel', up);
      });
    });
  }

  /* ====================================================================
   * Simpan ke folder (File System Access API, Chrome/Edge) + penyimpanan
   * antrian di IndexedDB (dibagi antar tab & antar halaman hitomi.la)
   * ==================================================================== */
  const HAS_DIR = typeof window.showDirectoryPicker === 'function';
  let dirHandle = null; // folder tujuan terakhir (disimpan di IndexedDB supaya tidak perlu dipilih ulang)
  let dbp = null;
  const idbOpen = () => dbp || (dbp = new Promise((res, rej) => {
    const r = indexedDB.open('hmt', 2);
    r.onupgradeneeded = () => {
      const db = r.result;
      if (!db.objectStoreNames.contains('h')) db.createObjectStore('h');
      if (!db.objectStoreNames.contains('jobs')) db.createObjectStore('jobs', { keyPath: 'id' });
      if (!db.objectStoreNames.contains('data')) db.createObjectStore('data');
    };
    r.onsuccess = () => {
      const db = r.result;
      db.onversionchange = () => { db.close(); dbp = null; };
      res(db);
    };
    r.onerror = () => { dbp = null; rej(r.error); };
  }));
  async function idb(store, mode, fn) {
    const db = await idbOpen();
    return new Promise((res, rej) => {
      const t = db.transaction(store, mode);
      let out;
      const r = fn(t.objectStore(store));
      if (r) r.onsuccess = () => { out = r.result; };
      t.oncomplete = () => res(out);
      t.onerror = () => rej(t.error);
      t.onabort = () => rej(t.error);
    });
  }
  const idbGet = (k) => idb('h', 'readonly', (s) => s.get(k));
  const idbSet = (k, v) => idb('h', 'readwrite', (s) => s.put(v, k));

  const jobPut = (j) => idb('jobs', 'readwrite', (s) => s.put(j));
  const jobGet = (id) => idb('jobs', 'readonly', (s) => s.get(id));
  const jobAll = () => idb('jobs', 'readonly', (s) => s.getAll());
  const jobDel = (id) => idb('jobs', 'readwrite', (s) => s.delete(id));
  const dKey = (id, k) => id + ':' + k;
  const dataPut = (id, k, v) => idb('data', 'readwrite', (s) => s.put(v, dKey(id, k)));
  const dataGet = (id, k) => idb('data', 'readonly', (s) => s.get(dKey(id, k)));
  const dataKeys = (id) => idb('data', 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(id + ':', id + ';')));
  const dataClear = (id) => idb('data', 'readwrite', (s) => s.delete(IDBKeyRange.bound(id + ':', id + ';')));

  // Ubah satu job secara atomik. onlyIfRunning: tidak menulis kalau job sudah dibatalkan/dihapus.
  async function jobPatch(id, patch, onlyIfRunning) {
    const db = await idbOpen();
    return new Promise((res, rej) => {
      const t = db.transaction('jobs', 'readwrite');
      const s = t.objectStore('jobs');
      let result = null;
      const g = s.get(id);
      g.onsuccess = () => {
        const cur = g.result;
        if (!cur) return;
        if (onlyIfRunning && cur.status !== 'running') { result = cur; return; }
        const nx = Object.assign(cur, typeof patch === 'function' ? patch(cur) : patch);
        s.put(nx); result = nx;
      };
      t.oncomplete = () => res(result);
      t.onerror = () => rej(t.error);
      t.onabort = () => rej(t.error);
    });
  }

  // Chrome menolak folder sistem (Downloads/Documents/Desktop langsung, drive root, dll) dengan dialog bawaan
  // yang tidak bisa diganti. Jadi sebelum picker dibuka, kita beri panduan dengan dialog buatan sendiri.
  async function pickDir(why) {
    const ok = await askConfirm({
      title: 'Pilih folder tujuan',
      text: (why ? T(why) + ' ' : '') + T('Pilih atau buat SUBFOLDER sendiri (mis. Documents\\Hitomi). Browser menolak folder sistem seperti Downloads, Documents, atau Desktop langsung.'),
      ok: 'Pilih folder', cancel: 'Batal',
    });
    if (!ok) { const e = new Error('dibatalkan'); e.name = 'AbortError'; throw e; }
    const h = await window.showDirectoryPicker({ id: 'hmt-dl', mode: 'readwrite' });
    dirHandle = h;
    try { await idbSet('dir', h); } catch (e) { /* tidak fatal */ }
    return h;
  }
  async function ensureDir() { // panggil langsung dari klik pengguna (perlu user gesture)
    if (!dirHandle) { try { dirHandle = await idbGet('dir'); } catch (e) { /* abaikan */ } }
    if (dirHandle) {
      try {
        let perm = await dirHandle.queryPermission({ mode: 'readwrite' });
        if (perm !== 'granted') perm = await dirHandle.requestPermission({ mode: 'readwrite' });
        if (perm === 'granted') return dirHandle;
      } catch (e) { /* handle lama tidak valid -> pilih ulang */ }
    }
    return pickDir(dirHandle ? 'Izin ke folder sebelumnya tidak tersedia.' : 'Belum ada folder tujuan.');
  }
  async function dirIfGranted() { // tanpa gesture: hanya kalau izin sudah ada
    if (!dirHandle) { try { dirHandle = await idbGet('dir'); } catch (e) { /* abaikan */ } }
    if (!dirHandle) return null;
    try { if ((await dirHandle.queryPermission({ mode: 'readwrite' })) === 'granted') return dirHandle; } catch (e) { /* abaikan */ }
    return null;
  }
  async function writeTo(dir, name, blob) {
    const fh = await dir.getFileHandle(name, { create: true });
    const w = await fh.createWritable();
    try { await w.write(blob); } finally { await w.close(); }
  }

  async function fetchUrls(urls) {
    let last = null;
    for (const u of urls) {
      try {
        const r = await fetch(u, { credentials: 'omit', signal: window.AbortSignal && AbortSignal.timeout ? AbortSignal.timeout(60000) : undefined });
        if (r.ok) return { buf: await r.arrayBuffer(), url: u };
        last = new Error('HTTP ' + r.status);
      } catch (e) { last = e; }
    }
    throw last || new Error('tidak ada URL');
  }

  /* ====================================================================
   * ANTRIAN UNDUHAN: berjalan di latar belakang, dilanjutkan otomatis
   * di halaman/tab mana pun. Satu "runner" aktif (Web Locks). Kalau halaman
   * yang menjalankannya ditinggalkan, halaman berikutnya meneruskan dari
   * gambar yang belum selesai (gambar yang sudah jadi tersimpan di IndexedDB).
   * ==================================================================== */
  let leaving = false;
  window.addEventListener('beforeunload', () => { leaving = true; setTimeout(() => { leaving = false; }, 2500); });
  window.addEventListener('pagehide', () => { leaving = true; });
  window.addEventListener('pageshow', () => { leaving = false; });

  const isActive = (j) => j.status === 'queued' || j.status === 'running';
  let runnerOn = false;

  async function runJob(j) {
    await jobPatch(j.id, { status: 'running' });
    const have = new Set((await dataKeys(j.id)).map((k) => +String(k).split(':')[1]));
    const failedSet = new Set(j.failed || []);
    const pending = [];
    j.items.forEach((it, k) => { if (!have.has(k) && !failedSet.has(k)) pending.push(k); });
    await jobPatch(j.id, { done: have.size + failedSet.size });

    let next = 0, alive = true;
    const worker = async () => {
      while (alive && !leaving && next < pending.length) {
        const k = pending[next++], it = j.items[k];
        try {
          const { buf, url } = await fetchUrls(it.urls);
          let ext = (url.match(/\.(\w+)(?:\?|$)/) || [])[1] || 'webp';
          let rec, kept = false;
          if (j.out === 'pdf') {
            const r = await toJpg(buf, j.q);
            rec = { blob: r.blob, size: r.blob.size, w: r.w, h: r.h };
          } else {
            let data = buf;
            if (j.asJpg && !/^jpe?g$/i.test(ext)) {
              try { data = await (await toJpg(buf, j.q)).blob.arrayBuffer(); ext = 'jpg'; }
              catch (e) { kept = true; console.warn('[HMT] konversi JPG gagal, pakai file asli: hal.', it.i + 1, e); }
            }
            rec = { name: String(it.i + 1).padStart(j.pad, '0') + '.' + ext, blob: new Blob([data]), size: data.byteLength, crc: crc32(new Uint8Array(data)) };
          }
          await dataPut(j.id, k, rec);
          const cur = await jobPatch(j.id, (c) => ({ done: c.done + 1, kept: (c.kept || 0) + (kept ? 1 : 0) }), true);
          if (!cur || cur.status !== 'running') alive = false;
        } catch (e) {
          if (leaving) return; // halaman ditinggalkan: jangan dianggap gagal, halaman berikut melanjutkan
          console.warn('[HMT] gagal memproses gambar', it.i + 1, e);
          const cur = await jobPatch(j.id, (c) => ({ done: c.done + 1, failed: (c.failed || []).concat(k) }), true);
          if (!cur || cur.status !== 'running') alive = false;
        }
      }
    };
    await Promise.all(Array.from({ length: Math.min(4, Math.max(1, pending.length)) }, worker));
    if (leaving) return;

    const cur = await jobGet(j.id);
    if (!cur || cur.status !== 'running') { // dibatalkan / dihapus
      await dataClear(j.id);
      if (cur && cur.status === 'canceled') await jobDel(j.id);
      return;
    }
    await finalizeJob(j.id, null);
  }

  // Susun ZIP/PDF/gambar dari data di IndexedDB. gestureDir = folder dari klik pengguna (opsional).
  async function finalizeJob(id, gestureDir) {
    try {
      const j = await jobGet(id);
      if (!j) return;
      const keys = (await dataKeys(id)).map((k) => +String(k).split(':')[1]).sort((a, b) => a - b);
      if (!keys.length) { await jobPatch(id, { status: 'error', ended: Date.now(), err: 'Semua gambar gagal diproses (lihat console F12)' }); return; }
      let dir = null;
      if (HAS_DIR && (j.dest === 'dir' || j.out === 'img')) {
        dir = gestureDir || await dirIfGranted();
        if (!dir) { await jobPatch(id, { status: 'ready' }); return; } // menunggu klik "Simpan" (butuh izin folder)
      }
      const recs = [];
      for (const k of keys) recs.push(await dataGet(id, k));
      if (j.out === 'img') {
        const sub = await dir.getDirectoryHandle(j.subName, { create: true });
        for (const r of recs) await writeTo(sub, r.name, r.blob);
      } else {
        const blob = j.out === 'pdf' ? makePdf(recs) : makeZip(recs);
        if (dir) await writeTo(dir, j.fname, blob);
        else {
          const a = document.createElement('a');
          a.href = URL.createObjectURL(blob); a.download = j.fname;
          document.body.appendChild(a); a.click(); a.remove();
          setTimeout(() => URL.revokeObjectURL(a.href), 120000);
        }
      }
      await dataClear(id);
      await jobPatch(id, { status: 'done', ended: Date.now(), dirName: dir ? dir.name : '' });
      markDone(j.gid);
    } catch (e) {
      console.warn('[HMT] finalisasi gagal', e);
      await jobPatch(id, { status: 'error', ended: Date.now(), err: 'Gagal menyimpan: ' + ((e && e.message) || e) });
    }
  }

  async function runLoop() {
    for (;;) {
      if (leaving) return;
      const jobs = (await jobAll()).sort((a, b) => a.created - b.created);
      const j = jobs.find((x) => x.status === 'running') || jobs.find((x) => x.status === 'queued');
      if (!j) return;
      try { await runJob(j); }
      catch (e) {
        console.warn('[HMT] job error', e);
        await jobPatch(j.id, { status: 'error', ended: Date.now(), err: 'Error: ' + ((e && e.message) || e) });
      }
    }
  }

  function kick() {
    if (runnerOn) return;
    const go = async () => { runnerOn = true; try { await runLoop(); } finally { runnerOn = false; } };
    if (navigator.locks && navigator.locks.request) {
      navigator.locks.request('hmt-dl-runner', { ifAvailable: true }, async (lock) => { if (lock) await go(); }).catch(() => {});
    } else go();
  }

  async function cancelJob(id) {
    await jobPatch(id, { status: 'canceled' });
    await dataClear(id);
    setTimeout(() => { jobDel(id).catch(() => {}); }, 1500);
    dmTick();
  }
  async function removeJob(id) {
    await dataClear(id); await jobDel(id); dmTick();
  }
  async function saveReady(id) { // dipanggil dari klik (gesture) -> minta izin folder
    try {
      const dir = await ensureDir();
      await finalizeJob(id, dir);
      dmTick();
    } catch (e) {
      if (e && e.name === 'AbortError') toast('Pemilihan folder dibatalkan');
      else toast(T('Tidak bisa mengakses folder: {0}', (e && e.message) || e), { type: 'err' });
    }
  }

  /* ====================================================================
   * Widget antrian (kanan bawah, bisa digeser/ciut) + notifikasi status
   * ==================================================================== */
  let dmEl = null, dmBody = null, dmTitle = null, dmPlaced = false, dmFirst = true;
  const dmRows = new Map();
  const dmSeen = new Map();

  function jobText(j) {
    const n = j.items.length;
    const nf = j.failed && j.failed.length ? T(' (gagal: {0})', j.failed.length) : '';
    switch (j.status) {
      case 'queued': return T('Menunggu antrian · {0} gambar', n);
      case 'running':
        return j.done >= n ? T('Menyusun file…')
          : T('Mengunduh {0} / {1}', j.done, n) + nf + (j.out === 'pdf' || j.asJpg ? T(' · konversi JPG') : '');
      case 'ready': return T('Siap — klik Simpan untuk menulis ke folder') + nf;
      case 'done': return T('Selesai ✓ → {0}', outName(j.out)) + (j.dirName ? T(' di folder "{0}"', j.dirName) : '') + nf;
      case 'error': return tErr(j.err || 'Gagal');
      default: return j.status;
    }
  }

  function ensureDm() {
    if (dmEl) { if (!dmEl.isConnected) document.body.appendChild(dmEl); return dmEl; }
    dmEl = mk('div', 'hide'); dmEl.id = 'hmt-dm';
    const head = mk('div', 'dm-head');
    dmTitle = mk('span', '', T('Unduhan'));
    const hb = mk('div', 'dm-hb');
    const bClr = mk('button', '', T('Bersihkan')); bClr.type = 'button'; bClr.title = T('Hapus yang sudah selesai / gagal');
    const bMin = mk('button', '', '—'); bMin.type = 'button'; bMin.title = T('Ciutkan / buka');
    bClr.addEventListener('click', async () => {
      const js = await jobAll();
      for (const j of js) if (j.status === 'done' || j.status === 'error') await removeJob(j.id);
    });
    const applyMin = () => dmEl.classList.toggle('min', !!get('dmMin', false));
    bMin.addEventListener('click', () => { set('dmMin', !get('dmMin', false)); applyMin(); });
    hb.append(bClr, bMin);
    head.append(dmTitle, hb);
    dmBody = mk('div', 'dm-body');
    dmEl.append(head, dmBody);
    applyMin();
    document.body.appendChild(dmEl);
    attachDrag(dmEl, head, 'dmRect');
    window.addEventListener('resize', () => keepInView(dmEl));
    return dmEl;
  }

  function buildRow(j) {
    const el = mk('div', 'dm-job');
    const t = mk('div', 'dm-t', j.title); t.title = j.title;
    const s = mk('div', 'dm-s');
    const track = mk('div', 'dm-track'); const fill = mk('div', 'dm-fill'); track.appendChild(fill);
    const a = mk('div', 'dm-a');
    el.append(t, s, track, a);
    return { el, s, fill, a, status: null };
  }

  function setActions(row, j) {
    row.a.textContent = '';
    const btn = (label, cls, fn) => { const b = mk('button', cls || '', T(label)); b.type = 'button'; b.addEventListener('click', fn); row.a.appendChild(b); return b; };
    if (isActive(j)) {
      btn('Batal', '', async () => {
        const ok = await askConfirm({ title: 'Batalkan unduhan?', text: j.title, ok: 'Batalkan', cancel: 'Lanjutkan', danger: true });
        if (ok) cancelJob(j.id);
      });
    } else if (j.status === 'ready') {
      btn('Simpan', 'pri', () => saveReady(j.id));
      btn('Hapus', '', () => removeJob(j.id));
    } else {
      btn('Hapus', '', () => removeJob(j.id));
    }
  }

  function renderDm(jobs) {
    const vis = jobs.filter((j) => j.status !== 'canceled');
    if (!vis.length && !dmEl) return;
    ensureDm();
    dmEl.classList.toggle('hide', !vis.length);
    if (vis.length && !dmPlaced) {
      dmPlaced = true;
      const r = get('dmRect', null);
      if (r && typeof r.x === 'number') {
        dmEl.style.left = r.x + 'px'; dmEl.style.top = r.y + 'px'; dmEl.style.right = 'auto'; dmEl.style.bottom = 'auto';
      }
      keepInView(dmEl);
    }
    const act = vis.filter(isActive).length;
    dmTitle.textContent = T('Unduhan') + (act ? ' · ' + T('{0} berjalan', act) : '');
    for (const [id, row] of dmRows) if (!vis.some((j) => j.id === id)) { row.el.remove(); dmRows.delete(id); }
    vis.forEach((j) => {
      let row = dmRows.get(j.id);
      if (!row) { row = buildRow(j); dmRows.set(j.id, row); dmBody.appendChild(row.el); }
      if (row.status !== j.status) { row.status = j.status; row.el.className = 'dm-job ' + j.status; setActions(row, j); }
      row.s.textContent = jobText(j);
      const n = j.items.length || 1;
      row.fill.style.width = (j.status === 'done' || j.status === 'ready' ? 100 : Math.round(Math.min(j.done, n) / n * 100)) + '%';
    });
  }

  function notifyChanges(jobs) {
    jobs.forEach((j) => {
      const prev = dmSeen.get(j.id);
      dmSeen.set(j.id, j.status);
      if (j.status === 'done' && prev !== j.status) markCards();
      if (dmFirst || prev === j.status || document.visibilityState !== 'visible') return;
      if (j.status === 'done') toast(T('Selesai: {0} → {1}', j.title, outName(j.out)), { type: 'ok' });
      else if (j.status === 'ready') toast(T('Unduhan selesai, tinggal disimpan ke folder: {0}', j.title), { action: { label: 'Simpan', fn: () => saveReady(j.id) } });
      else if (j.status === 'error') toast(j.title + ' — ' + tErr(j.err || 'gagal'), { type: 'err' });
    });
  }

  let ticking = false;
  async function dmTick() {
    if (ticking) return;
    ticking = true;
    try {
      const jobs = (await jobAll()).sort((a, b) => a.created - b.created);
      for (const j of jobs) { // bersihkan yang sudah selesai > 60 detik
        if (j.status === 'done' && Date.now() - (j.ended || 0) > 60000) { await removeJob(j.id); }
      }
      const live = jobs.filter((j) => !(j.status === 'done' && Date.now() - (j.ended || 0) > 60000));
      renderDm(live);
      notifyChanges(live);
      dmFirst = false;
      if (live.some(isActive) && !runnerOn) kick();
    } catch (e) { /* IndexedDB tidak tersedia: abaikan */ }
    ticking = false;
  }
  function dmStart() { dmTick(); setInterval(dmTick, 1200); }

  /* ====================================================================
   * Panel pilih gambar (bisa digeser & di-resize). Tombol Unduh hanya
   * memasukkan pekerjaan ke antrian; panel langsung ditutup.
   * ==================================================================== */
  let dlEl = null, dlCleanup = null;
  function closeDl() {
    if (dlCleanup) { dlCleanup(); dlCleanup = null; }
    if (dlEl) { dlEl.remove(); dlEl = null; }
  }

  function openDownloadPanel() {
    if (dlEl) return;
    const info = window.galleryinfo;
    const files = info.files;
    const total = files.length;
    const sel = new Set(files.map((_, i) => i));
    const rawTitle = String(info.title || document.title || 'hitomi').replace(/\s+/g, ' ').trim();
    const safeTitle = rawTitle.replace(/[\\/:*?"<>|\u0000-\u001f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120) || 'hitomi';
    const subName = safeTitle + (info.id ? ' [' + info.id + ']' : '');
    const zipName = subName + '.zip';

    const doneAt = info.id ? doneMap()[String(info.id)] : 0;
    if (state.dlMark && doneAt) {
      toast(T('Galeri ini sudah pernah diunduh ({0}).', new Date(doneAt).toLocaleDateString(state.lang === 'en' ? 'en-US' : 'id-ID')),
        { action: { label: 'Hapus tanda', fn: () => unmarkDone(String(info.id)) } });
    }

    dlEl = mk('div'); dlEl.id = 'hmt-dl';
    const box = mk('div', 'dl-box');
    box.style.setProperty('--dl-th', state.dlThumb + 'px');
    dlEl.appendChild(box);

    const head = mk('div', 'dl-head');
    const hl = mk('div'); hl.style.minWidth = '0';
    hl.appendChild(mk('div', 'dl-title', T('Pilih gambar untuk diunduh')));
    hl.appendChild(mk('div', 'dl-sub', rawTitle));
    const hb = mk('div', 'dl-hb');
    const bMin = mk('button', 'dl-x', '—'); bMin.type = 'button'; bMin.title = T('Ciutkan / buka');
    bMin.addEventListener('click', () => box.classList.toggle('min'));
    const x = mk('button', 'dl-x', '✕'); x.type = 'button'; x.title = T('Tutup (Esc)');
    x.addEventListener('click', closeDl);
    hb.append(bMin, x);
    head.append(hl, hb);

    const bar = mk('div', 'dl-bar');
    const bAll = mk('button', 'dl-btn', T('Pilih semua'));
    const bNone = mk('button', 'dl-btn', T('Kosongkan'));
    const bInv = mk('button', 'dl-btn', T('Balik pilihan'));
    [bAll, bNone, bInv].forEach((b) => { b.type = 'button'; });
    const hint = mk('span', 'dl-hint', T('Klik = pilih/batal · Shift+klik = rentang'));
    const zBox = mk('div', 'dl-q');
    const zIn = document.createElement('input');
    zIn.type = 'range'; zIn.min = 90; zIn.max = 320; zIn.step = 10; zIn.value = state.dlThumb; zIn.title = T('Ukuran thumbnail');
    zBox.append(mk('span', 'dl-lbl', T('Ukuran')), zIn);
    zIn.addEventListener('input', () => { state.dlThumb = +zIn.value; set('dlThumb', state.dlThumb); box.style.setProperty('--dl-th', state.dlThumb + 'px'); });
    const count = mk('span', 'dl-count');
    bar.append(bAll, bNone, bInv, hint, zBox, count);

    // ---- baris opsi: Hasil (ZIP/PDF/Gambar), Format (Asli/JPG), Simpan ke (Unduhan/Folder) ----
    if (!HAS_DIR) {
      if (state.dlDest === 'dir') state.dlDest = 'dl';
      if (state.dlOut === 'img') state.dlOut = 'zip';
    }
    const bar2 = mk('div', 'dl-bar');
    const mkSeg = (items, key, onSet) => {
      const wrap = mk('div', 'dl-seg');
      const btns = items.map(([v, t]) => {
        const b = mk('button', '', T(t)); b.type = 'button'; b.dataset.v = v;
        b.addEventListener('click', () => { state[key] = v; set(key, v); if (onSet) onSet(v); syncOpts(); });
        wrap.appendChild(b); return b;
      });
      return { wrap, btns };
    };
    const outSeg = mkSeg([['zip', 'ZIP'], ['pdf', 'PDF']].concat(HAS_DIR ? [['img', 'Gambar']] : []), 'dlOut',
      (v) => { if (v === 'img') { state.dlDest = 'dir'; set('dlDest', 'dir'); } });
    const fmtLbl = mk('span', 'dl-lbl', T('Format'));
    const fmtSeg = mkSeg([['orig', 'Asli'], ['jpg', 'JPG']], 'dlFmt');
    const qBox = mk('div', 'dl-q');
    const qIn = document.createElement('input');
    qIn.type = 'range'; qIn.min = 50; qIn.max = 100; qIn.step = 5; qIn.title = T('Kualitas JPG');
    const qVal = mk('span');
    qBox.append(qIn, qVal);
    qIn.addEventListener('input', () => { state.dlQ = +qIn.value; set('dlQ', state.dlQ); qVal.textContent = state.dlQ + '%'; });
    const destLbl = mk('span', 'dl-lbl', T('Simpan ke'));
    const destSeg = mkSeg([['dl', 'Unduhan']].concat(HAS_DIR ? [['dir', 'Folder']] : []), 'dlDest',
      (v) => { if (v === 'dl' && state.dlOut === 'img') { state.dlOut = 'zip'; set('dlOut', 'zip'); } });
    const bFolder = mk('button', 'dl-btn dl-folder'); bFolder.type = 'button';
    bFolder.addEventListener('click', async () => {
      try { await pickDir(); } catch (e) { if (e && e.name !== 'AbortError') console.warn('[HMT] pilih folder gagal', e); }
      syncOpts();
    });
    bar2.append(mk('span', 'dl-lbl', T('Hasil')), outSeg.wrap, fmtLbl, fmtSeg.wrap, qBox, destLbl, destSeg.wrap, bFolder);
    const syncOpts = () => {
      const pdf = state.dlOut === 'pdf';
      outSeg.btns.forEach((b) => b.classList.toggle('on', b.dataset.v === state.dlOut));
      fmtSeg.btns.forEach((b) => b.classList.toggle('on', b.dataset.v === state.dlFmt));
      destSeg.btns.forEach((b) => b.classList.toggle('on', b.dataset.v === state.dlDest));
      fmtLbl.classList.toggle('dl-hide', pdf); fmtSeg.wrap.classList.toggle('dl-hide', pdf); // PDF selalu JPEG
      qBox.classList.toggle('dl-hide', !(pdf || state.dlFmt === 'jpg'));
      destLbl.classList.toggle('dl-hide', !HAS_DIR); destSeg.wrap.classList.toggle('dl-hide', !HAS_DIR);
      bFolder.classList.toggle('dl-hide', state.dlDest !== 'dir');
      bFolder.textContent = dirHandle ? '📁 ' + dirHandle.name : T('Pilih folder…');
      qIn.value = state.dlQ; qVal.textContent = state.dlQ + '%';
      refresh();
    };

    const grid = mk('div', 'dl-grid');
    const cells = [];
    const th = thumbHost && thumbHost.isConnected ? thumbHost.querySelectorAll('.hm-th img') : null;
    files.forEach((f, i) => {
      const item = mk('div', 'dl-item');
      const c = mk('div', 'dl-cell on');
      const img = document.createElement('img');
      img.loading = 'lazy'; img.decoding = 'async'; img.alt = '';
      const first = th && th[i] ? (th[i].currentSrc || th[i].getAttribute('src') || '') : '';
      const list = [first, ...candidates(f)].filter(Boolean);
      let k = 0;
      img.addEventListener('error', () => { if (++k < list.length) img.src = list[k]; });
      if (list.length) img.src = list[0];
      c.append(img, mk('span', 'dl-n', String(i + 1)), mk('span', 'dl-ck', '✓'));
      item.dataset.i = String(i);
      item.appendChild(c);
      grid.appendChild(item);
      cells.push(c);
    });

    const foot = mk('div', 'dl-foot');
    const stat = mk('div', 'dl-stat');
    const bCancel = mk('button', 'dl-btn', T('Tutup')); bCancel.type = 'button';
    const bGo = mk('button', 'dl-btn pri'); bGo.type = 'button';
    foot.append(stat, bCancel, bGo);

    box.append(head, bar, bar2, grid, foot);

    let last = null;
    const paint = (i) => {
      const on = sel.has(i);
      cells[i].classList.toggle('on', on);
      cells[i].classList.toggle('off', !on);
      cells[i].querySelector('.dl-ck').textContent = on ? '✓' : '';
    };
    const refresh = () => {
      count.textContent = T('{0} / {1} dipilih', sel.size, total);
      bGo.textContent = T(state.dlDest === 'dir' ? 'Simpan {0} ({1})' : 'Unduh {0} ({1})', outName(state.dlOut), sel.size);
      bGo.classList.toggle('off', !sel.size);
      stat.textContent = sel.size ? T('Siap mengunduh {0} gambar · berjalan di latar belakang, boleh pindah halaman', sel.size) : T('Belum ada gambar dipilih');
    };
    const setAll = (fn) => { for (let i = 0; i < total; i++) { if (fn(i)) sel.add(i); else sel.delete(i); paint(i); } refresh(); };
    bAll.addEventListener('click', () => setAll(() => true));
    bNone.addEventListener('click', () => setAll(() => false));
    bInv.addEventListener('click', () => setAll((i) => !sel.has(i)));

    grid.addEventListener('click', (e) => {
      const it = e.target.closest('.dl-item');
      if (!it) return;
      const i = +it.dataset.i;
      const on = !sel.has(i);
      if (e.shiftKey && last !== null) {
        const [a, b] = last < i ? [last, i] : [i, last];
        for (let j = a; j <= b; j++) { if (on) sel.add(j); else sel.delete(j); paint(j); }
      } else {
        if (on) sel.add(i); else sel.delete(i);
        paint(i);
      }
      last = i;
      refresh();
    });

    bCancel.addEventListener('click', closeDl);

    bGo.addEventListener('click', async () => {
      if (!sel.size) return;
      const out = state.dlOut;
      const toDir = HAS_DIR && (out === 'img' || state.dlDest === 'dir');
      if (toDir) { // harus dipanggil langsung dari klik (user gesture)
        try { await ensureDir(); }
        catch (e) {
          syncOpts();
          if (e && e.name === 'AbortError') toast('Pemilihan folder dibatalkan');
          else toast(T('Tidak bisa mengakses folder: {0}', (e && e.message) || e), { type: 'err' });
          return;
        }
        syncOpts();
      }
      const idxs = [...sel].sort((a, b) => a - b);
      const job = {
        id: Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
        title: rawTitle, subName, gid: String(info.id || ''),
        fname: out === 'pdf' ? subName + '.pdf' : zipName,
        out, asJpg: out !== 'pdf' && state.dlFmt === 'jpg', q: state.dlQ,
        dest: toDir ? 'dir' : 'dl',
        pad: Math.max(3, String(total).length),
        items: idxs.map((i) => ({ i, urls: candidates(files[i]) })),
        failed: [], done: 0, kept: 0, status: 'queued', created: Date.now(),
      };
      try { await jobPut(job); }
      catch (e) { toast(T('Gagal menambahkan ke antrian: {0}', (e && e.message) || e), { type: 'err' }); return; }
      closeDl();
      toast(T('Ditambahkan ke antrian: {0} gambar. Boleh pindah halaman, unduhan tetap jalan.', idxs.length), { type: 'ok' });
      kick();
      dmTick();
    });

    // muat folder tujuan terakhir (kalau pernah dipilih)
    if (HAS_DIR && !dirHandle) idbGet('dir').then((h) => { if (h) { dirHandle = h; syncOpts(); } }).catch(() => {});
    syncOpts();
    refresh();
    document.body.appendChild(dlEl);

    // posisi & ukuran awal (diingat), lalu aktifkan geser + resize
    const r = get('dlRect', null);
    const w = Math.min((r && r.w) || 980, innerWidth - 8), h = Math.min((r && r.h) || 760, innerHeight - 8);
    box.style.width = w + 'px'; box.style.height = h + 'px';
    box.style.left = (r && typeof r.x === 'number' ? r.x : Math.round((innerWidth - w) / 2)) + 'px';
    box.style.top = (r && typeof r.y === 'number' ? r.y : Math.round((innerHeight - h) / 2)) + 'px';
    keepInView(box);
    attachDrag(box, head, 'dlRect');
    attachResize(box, 'dlRect', 460, 320);
    head.addEventListener('dblclick', (e) => { if (!e.target.closest('button')) box.classList.toggle('min'); });
    const onWin = () => keepInView(box);
    window.addEventListener('resize', onWin);
    dlCleanup = () => window.removeEventListener('resize', onWin);
  }

  // Intercept klik tombol Download bawaan situs (fase capture, sebelum handler situs).
  window.addEventListener('click', (e) => {
    if (!state.dlPanel || e.button !== 0) return;
    const t = e.target && e.target.closest && e.target.closest('#dl-button');
    if (!t) return;
    const info = window.galleryinfo;
    if (!(info && info.files && info.files.length && window.gg)) return; // data belum siap: pakai download bawaan
    e.preventDefault(); e.stopImmediatePropagation();
    openDownloadPanel();
  }, true);
  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && dlEl && !modalOpen) { e.stopImmediatePropagation(); closeDl(); }
  }, true);

  onReady(() => { initPanel(); dmStart(); });
})();