☰

Hitomi Tweak

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴、Greasemonkey 油猴子 或 Violentmonkey 暴力猴,才能安装此脚本。

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴 或 Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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