4chan Archives Enhancer

Enhancements for the main 4Chan Archive sites (archived.moe, thebarchive.com, archiveofsins.com)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         4chan Archives Enhancer
// @version      0.92
// @namespace    4chan-archives-enhancer
// @description  Enhancements for the main 4Chan Archive sites (archived.moe, thebarchive.com, archiveofsins.com)
// @license      MIT
// @match        https://archived.moe/*
// @match        https://thebarchive.com/*
// @match        https://archiveofsins.com/*
// @match        https://www.archived.moe/*
// @match        https://www.thebarchive.com/*
// @match        https://www.archiveofsins.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_xmlhttpRequest
// @connect      thebarchive.com
// @connect      archiveofsins.com
// @connect      archived.moe
// @connect      *
// @grant        window.onurlchange
// @run-at       document-start
// ==/UserScript==

(function () {
  'use strict';
  console.log('[4AE] Script injected at', document.readyState);

  // ═══════════════════════════════════════════════════════════════════════
  //  SETTINGS & STORAGE
  // ═══════════════════════════════════════════════════════════════════════

  const SETTINGS_KEY = 'ffe_settings';
  const MD5_TRACK_KEY = 'ffe_tracked_md5s';
  const SAVED_THREADS_KEY = 'ffe_saved_threads';
  const VERSION = '0.9';

  const DEFAULTS = {
    imageExpansion: true, fitWidth: true, fitHeight: true,
    imageHoverZoom: false, advanceOnContract: false,

    galleryEnabled: true, galFitWidth: true, galFitHeight: true,
    galStretchToFit: false, galHideThumbnails: false, galScrollToPost: true, galSlideDelay: 6,
    quoteBacklinks: true, quoteThreading: true, threadQuotes: true,
    quotePreview: true, backlinkPosition: 'header', backlinkFormat: '>>%id', quoteHighlight: true,

    goonMode: true,
    md5Tracking: true,
    savedThreads: true,
    boardNavEnabled: true, customBoardNav: '',

    batchDownload: true, batchDownloadDelay: 300,
    keyboardNav: true, settingsKey: 'o', galleryKey: 'g', goonKey: 'f',
    md5FilterKey: 'm',

    textSize: 'default',
    indexThumbSize: 'default',
    threadThumbSize: 'default',
    headerPinned: true,
  };

  const SETTING_META = {
    imageExpansion:    { section: 'Images',  label: 'Image Expansion',       desc: 'Click thumbnails to expand inline' },
    fitWidth:          { section: 'Images',  label: 'Fit Width',             desc: 'Limit expanded images to page width' },
    fitHeight:         { section: 'Images',  label: 'Fit Height',            desc: 'Limit expanded images to viewport height' },
    imageHoverZoom:    { section: 'Images',  label: 'Image Hover Zoom',      desc: 'Show full image on hover' },
    advanceOnContract: { section: 'Images',  label: 'Advance on Contract',   desc: 'Scroll to next post when contracting' },

    galleryEnabled:    { section: 'Gallery', label: 'Gallery Mode',          desc: 'Fullscreen gallery with thumbnails' },
    galFitWidth:       { section: 'Gallery', label: 'Fit Width',             desc: 'Limit gallery image to viewport width' },
    galFitHeight:      { section: 'Gallery', label: 'Fit Height',            desc: 'Limit gallery image to viewport height' },
    galStretchToFit:   { section: 'Gallery', label: 'Stretch to Fit',        desc: 'Upscale small images to fill viewport' },
    galHideThumbnails: { section: 'Gallery', label: 'Hide Thumbnails',       desc: 'Hide the thumbnail sidebar' },
    galScrollToPost:   { section: 'Gallery', label: 'Scroll to Post',        desc: 'Scroll page to post when navigating' },
    galSlideDelay:     { section: 'Gallery', label: 'Slide Delay (seconds)', desc: 'Seconds between slides in slideshow', type: 'number' },

    quoteBacklinks:    { section: 'Quoting', label: 'Quote Backlinks',       desc: 'Show >>reply links on quoted posts' },
    quoteThreading:    { section: 'Quoting', label: 'Quote Threading',       desc: 'Nest replies under their parent post' },
    threadQuotes:      { section: 'Quoting', label: 'Thread Quotes Active',  desc: 'Threading on by default' },
    quotePreview:      { section: 'Quoting', label: 'Quote Preview',         desc: 'Hover preview for >>links' },
    backlinkPosition:  { section: 'Quoting', label: 'Backlink Position',     desc: '"header" or "bottom"', type: 'select', options: ['header', 'bottom'] },
    backlinkFormat:    { section: 'Quoting', label: 'Backlink Format',       desc: '%id = post number', type: 'text' },
    quoteHighlight:    { section: 'Quoting', label: 'Quote Highlighting',    desc: 'Highlight original post on hover' },

    goonMode:         { section: 'General', label: 'Goon Mode',             desc: 'Toggle to hide non-image posts' },
    md5Tracking:       { section: 'General', label: 'MD5 Tracking',          desc: 'Track and highlight posts by image MD5' },
    savedThreads:      { section: 'General', label: 'Saved Threads',         desc: 'Bookmark threads for quick access' },
    boardNavEnabled:   { section: 'General', label: 'Custom Board Nav',      desc: 'Custom board list at top of page' },
    customBoardNav:    { section: 'General', label: 'Board List',            desc: 'Comma-separated board codes (e.g. a,b,g,v,pol)', type: 'text-wide' },
    textSize:          { section: 'General', label: 'Text Size',             desc: 'Page text size', type: 'select', options: ['default', 'larger'] },
    indexThumbSize:    { section: 'General', label: 'Index Thumbnail Size',  desc: 'Thumbnail size on board index/gallery pages', type: 'select', options: ['default', 'medium', 'large', 'xlarge'] },
    threadThumbSize:   { section: 'General', label: 'Thread Thumbnail Size', desc: 'Thumbnail size in threads', type: 'select', options: ['default', 'large', 'xlarge'] },
    headerPinned:      { section: 'General', label: 'Header Bar Pinned',     desc: 'Keep header bar visible (unpin to auto-hide)', type: 'checkbox' },

    batchDownload:     { section: 'Downloads', label: 'Batch Download',      desc: '"Download Thread Media" button on thread pages' },
    batchDownloadDelay:{ section: 'Downloads', label: 'Batch Delay (ms)',    desc: 'Delay between downloads', type: 'number' },

    keyboardNav:       { section: 'General', label: 'Keyboard Shortcuts',    desc: 'Enable keyboard shortcuts' },
    settingsKey:       { section: 'General', label: 'Settings Key',          desc: 'Key to open settings', type: 'text' },
    galleryKey:        { section: 'General', label: 'Gallery Key',           desc: 'Key to open gallery', type: 'text' },
    goonKey:          { section: 'General', label: 'Goon Mode Key',          desc: 'Key to toggle Goon Mode', type: 'text' },
    md5FilterKey:        { section: 'General', label: 'MD5 Filter Key',        desc: 'Key to toggle MD5 Filter Mode', type: 'text' },
  };

  function loadSettings() {
    try { const r = GM_getValue(SETTINGS_KEY, '{}'); return { ...DEFAULTS, ...(typeof r === 'string' ? JSON.parse(r) : r) }; }
    catch { return { ...DEFAULTS }; }
  }
  function saveSettings(s) { GM_setValue(SETTINGS_KEY, JSON.stringify(s)); }
  let cfg = loadSettings();

  // MD5 storage
  function loadTrackedMD5s() {
    try { const r = GM_getValue(MD5_TRACK_KEY, '[]'); return new Set(typeof r === 'string' ? JSON.parse(r) : r); }
    catch { return new Set(); }
  }
  function saveTrackedMD5s() { GM_setValue(MD5_TRACK_KEY, JSON.stringify([...trackedMD5s])); }
  let trackedMD5s = loadTrackedMD5s();

  // Saved threads storage
  function loadSavedThreads() {
    try { const r = GM_getValue(SAVED_THREADS_KEY, '[]'); return typeof r === 'string' ? JSON.parse(r) : r; }
    catch { return []; }
  }
  function saveSavedThreads() { GM_setValue(SAVED_THREADS_KEY, JSON.stringify(savedThreadsList)); }
  let savedThreadsList = loadSavedThreads();

  // ═══════════════════════════════════════════════════════════════════════
  //  STYLES
  // ═══════════════════════════════════════════════════════════════════════

  GM_addStyle(`
    /* Text Size */
    :root.ffe-text-larger { font-size: 16px !important; }
    :root.ffe-text-larger .text, :root.ffe-text-larger .post_wrapper { font-size: 16px !important; }

    /* Image Expansion */
    .ffe-expanded { width: auto !important; height: auto !important; }
    :root.ffe-fit-width .ffe-expanded  { max-width: 100% !important; }
    :root.ffe-fit-height .ffe-expanded { max-height: calc(100vh - 25px) !important; }
    :root:not(.ffe-fit-width) .ffe-expanded  { max-width: none !important; }
    :root:not(.ffe-fit-height) .ffe-expanded { max-height: none !important; }
    .thread_image_link.ffe-expanding { cursor: wait; }

    /* Image Hover Zoom */
    .ffe-hover-zoom {
      position: fixed; z-index: 10001; max-width: 80vw; max-height: 80vh;
      pointer-events: none; box-shadow: 0 2px 12px rgba(0,0,0,0.5);
    }

    /* Quote Highlight */
    .ffe-highlight { outline: 2px solid rgba(100,150,255,0.5) !important; }

    /* Quote Preview — THEME-AWARE: no hardcoded colors */
    /* Hide FoolFuuka's native hover preview — we provide our own */
    .post_hover { display: none !important; }

    .ffe-quote-preview {
      position: absolute; z-index: 10000; max-width: 600px;
      pointer-events: none; font-size: 13px;
      box-shadow: 2px 2px 8px rgba(0,0,0,0.6);
    }
    .ffe-quote-preview > .post_wrapper { margin: 0 !important; }
    .ffe-quote-preview .post_image { max-width: 125px !important; max-height: 125px !important; }

    /* Backlinks */
    .ffe-backlinks { font-size: 0.85em; display: inline; }
    .ffe-backlinks::before { content: " "; }
    .ffe-backlinks .ffe-backlink { text-decoration: none; margin-right: 3px; }
    .ffe-backlinks .ffe-backlink:hover { color: red; }
    .ffe-backlinks-bottom { display: block; clear: both; margin: 4px 0 0; font-size: 0.85em; }
    .ffe-backlinks-bottom .ffe-backlink { text-decoration: none; margin-right: 4px; }
    .ffe-backlinks-bottom .ffe-backlink:hover { color: red; }

    /* Quote Threading */
    .ffe-thread-container { margin-left: 20px; border-left: 1px solid rgba(128,128,128,0.3); }
    .ffe-thread-container.ffe-collapsed { display: none; }
    .ffe-threadOP { clear: both; }
    .ffe-thread-toggle {
      cursor: pointer; font-family: monospace; font-size: 12px;
      margin-right: 4px; user-select: none; color: #89a;
      vertical-align: middle; display: inline-block; min-width: 16px; text-align: center;
    }
    .ffe-thread-toggle:hover { color: red; }
    .ffe-threading-toggle { cursor: pointer; font-size: 12px; margin-left: 8px; user-select: none; }
    .ffe-threading-toggle input { vertical-align: middle; margin-right: 2px; }

    /* Goon Mode */
    :root.ffe-goonMode article.post.ffe-noimage { display: none; }
    :root.ffe-goonMode .post_controls { display: none; }
    .ffe-goon-indicator {
      display: none; background: rgba(255,0,0,0.8); font-weight: bold;
      min-width: 9px; padding: 0 3px; margin: 0 4px; text-align: center;
      color: #fff; border-radius: 2px; cursor: pointer; font-size: 12px; line-height: 1.5;
    }
    :root.ffe-goonMode .ffe-goon-indicator { display: inline-block; }

    /* MD5 Filter Mode */
    :root.ffe-md5Filter article.post.ffe-md5-hidden { display: none; }
    .ffe-md5filter-indicator {
      display: none; background: rgba(0,120,255,0.8); font-weight: bold;
      min-width: 9px; padding: 0 3px; margin: 0 4px; text-align: center;
      color: #fff; border-radius: 2px; cursor: pointer; font-size: 12px; line-height: 1.5;
    }
    :root.ffe-md5Filter .ffe-md5filter-indicator { display: inline-block; }

    /* MD5 Tracking — highlight the whole visible post area */
    .ffe-md5-tracked { border-left: 3px solid #e74c3c !important; background: rgba(231,76,60,0.08) !important; }
    .ffe-md5-menu-btn { cursor: pointer; font-size: 10px; margin-left: 4px; opacity: 0.6; text-decoration: none; }
    .ffe-md5-menu-btn:hover { opacity: 1; }
    .ffe-md5-dropdown {
      position: fixed; z-index: 9999; min-width: 180px; font-size: 12px;
      background: #282a2e; border: 1px solid #555; box-shadow: 2px 2px 6px rgba(0,0,0,0.4);
    }
    .ffe-md5-dropdown a { display: block; padding: 5px 12px; cursor: pointer; text-decoration: none; color: #c5c8c6; }
    .ffe-md5-dropdown a:hover { background: #3a3d42; }

    /* View on Original Board link */
    .ffe-view-original {
      font-size: 11px; margin-left: 6px; color: #81a2be; text-decoration: none; opacity: 0.8;
      display: inline-flex; align-items: center; gap: 3px; vertical-align: middle;
    }
    .ffe-view-original:hover { opacity: 1; text-decoration: underline; }
    .ffe-view-original svg { width: 12px; height: 12px; }
    .ffe-view-original-corner {
      position: absolute; bottom: 4px; right: 6px;
      font-size: 10px; color: #81a2be; text-decoration: none; opacity: 0.5;
      display: inline-flex; align-items: center; gap: 2px;
    }
    .ffe-view-original-corner:hover { opacity: 1; text-decoration: underline; }
    .ffe-view-original-corner svg { width: 11px; height: 11px; }
    article.post .post_wrapper { position: relative; }

    /* ═══ UNIFIED TOP BAR (board nav LEFT + icons RIGHT) ═══ */
    .ffe-topbar {
      position: fixed; top: 0; left: 0; right: 0; z-index: 9998;
      display: flex; align-items: center; justify-content: space-between;
      height: 32px; padding: 0 10px;
      background: rgba(29,31,33,0.95); border-bottom: 1px solid #444;
      font-size: 12px; color: #c5c8c6;
      box-shadow: 0 1px 4px rgba(0,0,0,0.3);
    }
    .ffe-topbar.ffe-header-fading { opacity: 0.15; transition: opacity 0.3s; }
    .ffe-topbar.ffe-header-fading:hover { opacity: 1; }
    .ffe-topbar-left { display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; overflow: hidden; }
    .ffe-topbar-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
    .ffe-topbar-right a {
      cursor: pointer; text-decoration: none; opacity: 0.7;
      display: inline-flex; align-items: center; color: #c5c8c6;
    }
    .ffe-topbar-right a:hover { opacity: 1; }
    .ffe-topbar-right svg { width: 20px; height: 20px; }
    body { padding-top: 34px !important; }
    html { scroll-padding-top: 38px; }

    /* Custom Board Nav (inside topbar-left) */
    .ffe-board-nav { font-size: 12px; display: flex; align-items: center; gap: 0; white-space: nowrap; }
    .ffe-board-nav a { text-decoration: none; color: #81a2be; }
    .ffe-board-nav a:hover { text-decoration: underline; color: #c5c8c6; }
    .ffe-board-nav a.ffe-board-current { font-weight: bold; color: #c5c8c6; }
    .ffe-board-nav-toggle { cursor: pointer; user-select: none; margin-right: 4px; font-family: monospace; color: #707880; }
    .ffe-board-nav-sep { margin: 0 3px; color: #555; }

    /* Saved Threads */
    .ffe-saved-dropdown {
      position: fixed; z-index: 100001; min-width: 320px; max-height: 400px; overflow-y: auto;
      background: #282a2e; border: 1px solid #555; box-shadow: 2px 2px 8px rgba(0,0,0,0.5);
      font-size: 12px; color: #c5c8c6;
    }
    .ffe-saved-dropdown .ffe-saved-item {
      display: flex; align-items: center; padding: 6px 10px; border-bottom: 1px solid #333;
    }
    .ffe-saved-dropdown .ffe-saved-item:hover { background: #3a3d42; }
    .ffe-saved-dropdown .ffe-saved-item a { flex: 1; color: #81a2be; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ffe-saved-dropdown .ffe-saved-item a:hover { text-decoration: underline; }
    .ffe-saved-dropdown .ffe-saved-item .ffe-saved-board { color: #b294bb; margin-right: 6px; min-width: 30px; }
    .ffe-saved-dropdown .ffe-saved-item .ffe-saved-remove { cursor: pointer; color: #888; margin-left: 8px; }
    .ffe-saved-dropdown .ffe-saved-item .ffe-saved-remove:hover { color: #e74c3c; }
    .ffe-saved-footer { padding: 6px 10px; text-align: center; border-top: 1px solid #444; }
    .ffe-saved-footer a { cursor: pointer; color: #888; text-decoration: underline; font-size: 11px; }
    .ffe-saved-footer a:hover { color: #e74c3c; }
    .ffe-saved-empty { padding: 16px; text-align: center; color: #666; font-style: italic; }

    /* ═══ FLOATING PANEL (bottom-right) ═══ */
    .ffe-float-panel {
      position: fixed; bottom: 12px; right: 12px; z-index: 9999;
      display: flex; align-items: center; gap: 6px;
      background: #282a2e; border: 1px solid #555; border-radius: 4px;
      padding: 6px 10px; font-family: arial, helvetica, sans-serif; font-size: 13px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.4);
    }
    .ffe-float-panel button {
      background: #373b41; border: 1px solid #555; color: #c5c8c6;
      padding: 5px 8px; cursor: pointer; border-radius: 3px; font-size: 13px;
      display: inline-flex; align-items: center; justify-content: center;
      transition: background 0.15s;
    }
    .ffe-float-panel button:hover { background: #4a4e54; }
    .ffe-float-panel .ffe-float-dl {
      background: #282a2e; color: #c5c8c6; border-color: #555;
      padding: 5px 12px; gap: 6px; font-size: 12px;
    }
    .ffe-float-panel .ffe-float-dl:hover { background: #3a3d42; }
    .ffe-float-panel .ffe-float-hidden { display: none !important; }
    .ffe-float-panel svg { width: 14px; height: 14px; }

    /* ═══ INDEX PAGE CUSTOMIZATION ═══ */
    :root.ffe-index-thumb-medium .post_image,
    :root.ffe-index-thumb-medium .thread_image,
    :root.ffe-index-thumb-medium a.thread_image_link img {
      max-width: 250px !important; max-height: 250px !important;
    }
    :root.ffe-index-thumb-large .post_image,
    :root.ffe-index-thumb-large .thread_image,
    :root.ffe-index-thumb-large a.thread_image_link img {
      max-width: 400px !important; max-height: 400px !important;
    }
    :root.ffe-index-thumb-xlarge .post_image,
    :root.ffe-index-thumb-xlarge .thread_image,
    :root.ffe-index-thumb-xlarge a.thread_image_link img {
      max-width: none !important; max-height: none !important;
    }

    /* ═══ THREAD THUMBNAIL SIZES ═══ */
    /* FoolFuuka sets width/height attributes on <img>, so we must override them */
    /* Exclude .ffe-expanded so fit-width/fit-height still work on expanded images */
    :root.ffe-thread-thumb-large img.post_image:not(.ffe-expanded),
    :root.ffe-thread-thumb-large img.thread_image:not(.ffe-expanded) {
      width: auto !important; height: auto !important;
      max-width: none !important; max-height: none !important;
    }
    :root.ffe-thread-thumb-xlarge img.post_image:not(.ffe-expanded),
    :root.ffe-thread-thumb-xlarge img.thread_image:not(.ffe-expanded) {
      width: auto !important; height: auto !important;
      min-width: 300px !important; max-width: none !important; max-height: none !important;
    }

    /* Promoted threads on index */
    article.post.ffe-promoted { border-left: 3px solid #b294bb !important; }
    article.post.ffe-promoted-md5 { border-left: 3px solid #e74c3c !important; }

    /* ═══ GALLERY ═══ */
    #ffe-gallery {
      position: fixed; inset: 0; z-index: 99999; display: flex; flex-direction: row;
      background: rgba(0,0,0,0.85); font-family: arial, helvetica, sans-serif; color: #ddd; font-size: 13px;
    }
    .gal-viewport { display: flex; flex: 1 1 auto; flex-direction: row; align-items: stretch; overflow: hidden; position: relative; }
    .gal-image { flex: 1 0 auto; display: flex; align-items: flex-start; justify-content: space-around; overflow: hidden; width: 1%; }
    #ffe-gallery:not(.gal-fit-height) .gal-image { overflow-y: scroll !important; }
    #ffe-gallery:not(.gal-fit-width)  .gal-image { overflow-x: scroll !important; }
    .gal-image img, .gal-image video { display: block; margin: auto; transition: transform 0.15s ease; }
    #ffe-gallery.gal-fit-width .gal-image img, #ffe-gallery.gal-fit-width .gal-image video { max-width: 100%; }
    #ffe-gallery.gal-fit-height .gal-image img, #ffe-gallery.gal-fit-height .gal-image video { max-height: calc(100vh - 25px); }
    #ffe-gallery.gal-stretch .gal-image img { min-width: 100%; min-height: calc(100vh - 25px); object-fit: contain; }
    .gal-prev, .gal-next { flex: 0 0 28px; position: relative; cursor: pointer; opacity: 0.5; background: rgba(0,0,0,0.3); transition: opacity 0.15s; }
    .gal-prev:hover, .gal-next:hover { opacity: 1; }
    .gal-prev::after, .gal-next::after { position: absolute; top: 50%; transform: translateY(-50%); content: ""; display: block; border-top: 12px solid transparent; border-bottom: 12px solid transparent; }
    .gal-prev::after { border-right: 14px solid #fff; right: 7px; }
    .gal-next::after { border-left: 14px solid #fff; left: 7px; }
    .gal-topbar { position: absolute; top: 0; left: 28px; right: 28px; display: flex; justify-content: space-between; align-items: center; padding: 4px 10px; background: rgba(0,0,0,0.6); z-index: 1; min-height: 22px; }
    .gal-labels { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; }
    .gal-count { white-space: nowrap; }
    .gal-name { color: #9bf; text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .gal-name:hover { text-decoration: underline; }
    .gal-buttons { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
    .gal-buttons a { cursor: pointer; color: #ddd; text-decoration: none; padding: 2px 5px; font-size: 14px; }
    .gal-buttons a:hover { color: #fff; }
    .gal-start i { display: inline-block; width: 0; height: 0; border-left: 8px solid #ddd; border-top: 5px solid transparent; border-bottom: 5px solid transparent; vertical-align: middle; }
    .gal-stop i { display: inline-block; width: 8px; height: 8px; border: 0; background: #ddd; vertical-align: middle; }
    .gal-buttons.gal-playing .gal-start { display: none; }
    .gal-buttons:not(.gal-playing) .gal-stop { display: none; }
    .gal-close { font-size: 20px !important; }
    .gal-menu { position: absolute; top: 28px; right: 28px; background: rgba(30,30,30,0.95); border: 1px solid #555; padding: 8px 12px; z-index: 2; min-width: 160px; display: none; }
    .gal-menu.gal-menu-open { display: block; }
    .gal-menu label { display: block; padding: 3px 0; cursor: pointer; font-size: 12px; color: #ccc; white-space: nowrap; }
    .gal-menu label:hover { color: #fff; }
    .gal-menu input[type="checkbox"] { vertical-align: middle; margin-right: 5px; }
    .gal-menu label.gal-delay-label { margin-top: 6px; }
    .gal-menu input[type="number"] { width: 50px; background: #333; color: #ddd; border: 1px solid #555; padding: 1px 3px; font-size: 12px; }
    .gal-thumbnails { flex: 0 0 150px; overflow-y: auto; display: flex; flex-direction: column; align-items: stretch; text-align: center; background: rgba(0,0,0,0.5); border-left: 1px solid #333; }
    #ffe-gallery.gal-hide-thumbnails .gal-thumbnails { display: none; }
    .gal-thumbnails a { display: block; padding: 4px; cursor: pointer; border-bottom: 1px solid rgba(255,255,255,0.05); transition: background 0.1s; }
    .gal-thumbnails a:hover { background: rgba(255,255,255,0.1); }
    .gal-thumbnails a.gal-thumb-active { background: rgba(100,150,255,0.25); outline: 2px solid #69f; }
    .gal-thumbnails img { max-width: 100%; max-height: 120px; object-fit: contain; display: block; margin: auto; }
    .gal-dl-btn {
      position: absolute; bottom: 12px; right: 12px; z-index: 2;
      display: inline-flex; align-items: center; gap: 6px;
      background: #282a2e; color: #c5c8c6; border: 1px solid #555; border-radius: 4px;
      padding: 5px 12px; font-size: 12px; cursor: pointer; font-family: inherit;
    }
    .gal-dl-btn:hover { background: #3a3d42; }
    .gal-dl-btn svg { width: 14px; height: 14px; }

    /* ═══ SETTINGS PANEL ═══ */
    #ffe-overlay { position: fixed; inset: 0; z-index: 100000; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; }
    #ffe-settings {
      box-sizing: border-box; width: 750px; max-width: 95vw; height: 550px; max-height: 90vh;
      background: #282a2e; border: 1px solid #555; box-shadow: 0 0 15px rgba(0,0,0,0.4);
      display: flex; flex-direction: column; font-family: arial, helvetica, sans-serif; font-size: 13px; color: #c5c8c6;
    }
    #ffe-settings nav { padding: 6px 10px; display: flex; align-items: center; border-bottom: 1px solid #555; background: #1d1f21; flex-shrink: 0; }
    #ffe-settings nav .ffe-tabs { flex: 1; display: flex; gap: 4px; flex-wrap: wrap; }
    #ffe-settings nav .ffe-tab { padding: 4px 10px; cursor: pointer; border: 1px solid transparent; border-radius: 3px 3px 0 0; font-size: 13px; color: #81a2be; text-decoration: underline; background: transparent; }
    #ffe-settings nav .ffe-tab:hover { background: #282a2e; }
    #ffe-settings nav .ffe-tab.ffe-tab-selected { font-weight: 700; text-decoration: none; background: #282a2e; border-color: #555; border-bottom-color: #282a2e; color: #c5c8c6; }
    #ffe-settings .ffe-close-btn { cursor: pointer; font-size: 18px; color: #c5c8c6; padding: 0 4px; line-height: 1; text-decoration: none; margin-left: 8px; }
    #ffe-settings .ffe-close-btn:hover { color: #e74c3c; }
    #ffe-settings .ffe-section-container { flex: 1; overflow: auto; padding: 10px 14px; overscroll-behavior: contain; }
    #ffe-settings .ffe-section { display: none; }
    #ffe-settings .ffe-section.ffe-section-active { display: block; }
    #ffe-settings .ffe-option { padding: 4px 0; display: flex; align-items: baseline; gap: 6px; }
    #ffe-settings .ffe-option label { cursor: pointer; white-space: nowrap; font-weight: 600; color: #c5c8c6; }
    #ffe-settings .ffe-option .ffe-desc { color: #888; font-size: 12px; }
    #ffe-settings .ffe-option input[type="checkbox"] { margin-right: 4px; vertical-align: middle; }
    #ffe-settings .ffe-option input[type="text"],
    #ffe-settings .ffe-option input[type="number"],
    #ffe-settings .ffe-option select { border: 1px solid #555; background: #1d1f21; padding: 2px 4px; font-size: 12px; color: #c5c8c6; width: 80px; }
    #ffe-settings .ffe-option input.ffe-text-wide { width: 300px; }
    #ffe-settings .ffe-footer { padding: 6px 10px; border-top: 1px solid #555; background: #1d1f21; font-size: 12px; display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
    #ffe-settings .ffe-footer a { cursor: pointer; text-decoration: underline; color: #81a2be; }
    #ffe-settings .ffe-footer a:hover { color: #e74c3c; }
    #ffe-settings .ffe-footer .ffe-spacer { flex: 1; }
    #ffe-settings .ffe-footer .ffe-status { color: #b5bd68; font-style: italic; }
    #ffe-settings .ffe-footer input[type="file"] { display: none; }

    /* MD5 Manager Tab */
    .ffe-md5-manager { padding: 4px 0; }
    .ffe-md5-manager .ffe-md5-list { max-height: 280px; overflow-y: auto; border: 1px solid #555; background: #1d1f21; margin: 6px 0; }
    .ffe-md5-manager .ffe-md5-entry { display: flex; align-items: center; padding: 3px 8px; border-bottom: 1px solid #333; font-family: monospace; font-size: 11px; }
    .ffe-md5-manager .ffe-md5-entry:hover { background: #3a3d42; }
    .ffe-md5-manager .ffe-md5-entry span { flex: 1; overflow: hidden; text-overflow: ellipsis; }
    .ffe-md5-manager .ffe-md5-entry a { cursor: pointer; color: #888; margin-left: 8px; text-decoration: none; }
    .ffe-md5-manager .ffe-md5-entry a:hover { color: #e74c3c; }
    .ffe-md5-manager .ffe-md5-add { display: flex; gap: 4px; margin-top: 6px; }
    .ffe-md5-manager .ffe-md5-add input { flex: 1; background: #1d1f21; border: 1px solid #555; color: #c5c8c6; padding: 3px 6px; font-size: 12px; font-family: monospace; }
    .ffe-md5-manager .ffe-md5-add button { background: #373b41; border: 1px solid #555; color: #c5c8c6; padding: 3px 10px; cursor: pointer; font-size: 12px; }
    .ffe-md5-manager .ffe-md5-add button:hover { background: #4a4e54; }
    .ffe-md5-manager .ffe-md5-actions { margin-top: 6px; display: flex; gap: 8px; align-items: center; }
    .ffe-md5-manager .ffe-md5-actions a { cursor: pointer; color: #81a2be; text-decoration: underline; font-size: 12px; }
  `);

  // ═══════════════════════════════════════════════════════════════════════
  //  SVG ICONS
  // ═══════════════════════════════════════════════════════════════════════

  function svgIcon(pathD, titleText, size) {
    const s = size || 16;
    const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
    svg.setAttribute('viewBox', '0 0 24 24');
    svg.setAttribute('width', s);
    svg.setAttribute('height', s);
    svg.setAttribute('fill', 'none');
    svg.setAttribute('stroke', 'currentColor');
    svg.setAttribute('stroke-width', '2');
    svg.setAttribute('stroke-linecap', 'round');
    svg.setAttribute('stroke-linejoin', 'round');
    svg.style.verticalAlign = 'middle';
    if (Array.isArray(pathD)) {
      pathD.forEach(d => {
        if (typeof d === 'string') {
          const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
          p.setAttribute('d', d);
          svg.appendChild(p);
        } else {
          const el = document.createElementNS('http://www.w3.org/2000/svg', d.tag);
          for (const [k, v] of Object.entries(d.attrs)) el.setAttribute(k, v);
          svg.appendChild(el);
        }
      });
    } else {
      const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      p.setAttribute('d', pathD);
      svg.appendChild(p);
    }
    if (titleText) {
      const t = document.createElementNS('http://www.w3.org/2000/svg', 'title');
      t.textContent = titleText;
      svg.appendChild(t);
    }
    return svg;
  }

  const icons = {
    cog: (t) => svgIcon([
      {tag:'circle', attrs:{cx:'12',cy:'12',r:'3'}},
      'M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z'
    ], t || "Archive Enhancer Settings"),
    download: (t) => svgIcon([
      'M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4',
      {tag:'polyline', attrs:{points:'7 10 12 15 17 10'}},
      {tag:'line', attrs:{x1:'12',y1:'15',x2:'12',y2:'3'}}
    ], t || 'Download'),
    bookmark: (t) => svgIcon('M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z', t || 'Saved Threads'),
    // Heart icons for save/unsave (clearer than pin)
    heartFilled: (t) => {
      const s = svgIcon('M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z', t || 'Unsave Thread');
      s.setAttribute('fill', 'currentColor');
      return s;
    },
    heartOutline: (t) => svgIcon('M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z', t || 'Save Thread'),
    arrowUp: (t) => svgIcon([
      {tag:'polyline', attrs:{points:'18 15 12 9 6 15'}}
    ], t || 'Go to top'),
    arrowDown: (t) => svgIcon([
      {tag:'polyline', attrs:{points:'6 9 12 15 18 9'}}
    ], t || 'Go to bottom'),
    externalLink: (t) => svgIcon([
      'M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6',
      {tag:'polyline', attrs:{points:'15 3 21 3 21 9'}},
      {tag:'line', attrs:{x1:'10',y1:'14',x2:'21',y2:'3'}}
    ], t || 'View on original board'),
    skipPrev: (t) => svgIcon([
      {tag:'polygon', attrs:{points:'19 20 9 12 19 4 19 20'}},
      {tag:'line', attrs:{x1:'5',y1:'19',x2:'5',y2:'5'}}
    ], t || 'Previous tracked MD5 post'),
    skipNext: (t) => svgIcon([
      {tag:'polygon', attrs:{points:'5 4 15 12 5 20 5 4'}},
      {tag:'line', attrs:{x1:'19',y1:'5',x2:'19',y2:'19'}}
    ], t || 'Next tracked MD5 post'),
    lock: (t) => svgIcon([
      {tag:'rect', attrs:{x:'3',y:'11',width:'18',height:'11',rx:'2',ry:'2'}},
      'M7 11V7a5 5 0 0 1 10 0v4'
    ], t || 'Header pinned'),
    unlock: (t) => svgIcon([
      {tag:'rect', attrs:{x:'3',y:'11',width:'18',height:'11',rx:'2',ry:'2'}},
      'M7 11V7a5 5 0 0 1 9.9-1'
    ], t || 'Header unpinned'),
  };

  // ═══════════════════════════════════════════════════════════════════════
  //  UTILITY
  // ═══════════════════════════════════════════════════════════════════════

  const doc = document.documentElement;
  const isThreadPage = /\/thread\/\d+/.test(location.pathname);
  const isBoardIndex = /^\/[^/]+\/(page\/\d+\/?)?$/.test(location.pathname);
  const isGalleryOrSearch = !isThreadPage && !isBoardIndex; // gallery, search, etc.
  const isIndexPage = !isThreadPage;
  const currentBoard = (location.pathname.match(/^\/([^/]+)/) || [])[1] || '';
  const currentHost = location.hostname.replace(/^www\./, '');

  // Cross-archive routing
  const BOARD_HOSTS = {
    b: 'thebarchive.com',
    s: 'archiveofsins.com', hc: 'archiveofsins.com', h: 'archiveofsins.com',
    hm: 'archiveofsins.com', i: 'archiveofsins.com', lgbt: 'archiveofsins.com',
    r: 'archiveofsins.com', soc: 'archiveofsins.com', t: 'archiveofsins.com', u: 'archiveofsins.com',
  };
  const CROSS_HOSTED_BOARDS = Object.keys(BOARD_HOSTS);
  const isBlockedExpansion = currentHost === 'archived.moe' && CROSS_HOSTED_BOARDS.includes(currentBoard);

  function getBoardUrl(board) {
    const host = BOARD_HOSTS[board] || 'archived.moe';
    return host === currentHost ? `/${board}/` : `https://${host}/${board}/`;
  }

  function getPostNum(article) {
    if (!article) return null;
    const el = article.closest ? (article.closest('article.thread, article.post') || article) : article;
    const id = el.id || '';
    const parts = id.split('_');
    const num = parts.length > 1 ? parts[parts.length - 1] : parts[0];
    return num && /^\d+$/.test(num) ? num : (el.dataset?.num || el.dataset?.docId || null);
  }

  const _postCache = {};
  let _postCacheBuilt = false;
  function buildPostCache() {
    if (_postCacheBuilt) return;
    document.querySelectorAll('article.thread, article.post').forEach(a => { const n = getPostNum(a); if (n) _postCache[n] = a; });
    _postCacheBuilt = true;
  }
  function findPostArticle(pn) {
    buildPostCache();
    const s = String(pn);
    return _postCache[s] || document.getElementById(pn) || document.getElementById(`p${pn}`) || document.querySelector(`article[id$="_${pn}"]`);
  }
  function cachePost(a) { const n = getPostNum(a); if (n) _postCache[n] = a; }

  function getQuoteTarget(link) {
    const dp = link.dataset?.post;
    if (dp) { const p = dp.split(','); const n = p[p.length-1].trim(); if (/^\d+$/.test(n)) return n; }
    const db = link.dataset?.backlink;
    if (db && /^\d+$/.test(db)) return db;
    const href = link.getAttribute('href') || '';
    let m = href.match(/#p?(\d+)/); if (m) return m[1];
    m = href.match(/\/post\/(\d+)/); if (m) return m[1];
    m = link.textContent?.match(/>>(\d+)/); if (m) return m[1];
    return null;
  }

  function getPostQuoteLinks(article) {
    const t = article.querySelector('.text');
    return t ? Array.from(t.querySelectorAll('a.backlink, a[data-backlink], a[data-post]')) : [];
  }

  function getPostMD5(article) {
    // 1) Try View Same link
    const l = getPostSameLink(article);
    if (l) { const m = l.href.match(/\/search\/image\/([^/]+)/); if (m) return m[1]; }
    // 2) Fallback: data-md5 on the image (works even when View Same is absent)
    const img = article.querySelector('img[data-md5]');
    if (img?.dataset?.md5) return img.dataset.md5;
    return null;
  }

  function escapeHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
  // OP is article.thread (rendered by board.php), replies are article.post (rendered by board_comment.php)
  function getAllPosts() { return Array.from(document.querySelectorAll('article.thread, article.post')); }

  function getAllMediaEntries() {
    const entries = [];
    // article.thread = OP (image in .thread_image_box child), article.post = replies
    document.querySelectorAll('article.thread, article.post').forEach(article => {
      const link = article.querySelector('.thread_image_link');
      if (!link?.href) return;
      const thumb = article.querySelector('.post_image, .thread_image');
      const filenameEl = article.querySelector('.post_file_filename');
      entries.push({
        url: link.href,
        thumbSrc: thumb?.src || '',
        filename: filenameEl?.title || filenameEl?.textContent?.trim() || link.href.split('/').pop(),
        postNum: getPostNum(article),
        article
      });
    });
    return entries;
  }

  function applyFitClasses() {
    doc.classList.toggle('ffe-fit-width', cfg.fitWidth);
    doc.classList.toggle('ffe-fit-height', cfg.fitHeight);
  }

  function applyTextSize() {
    doc.classList.toggle('ffe-text-larger', cfg.textSize === 'larger');
  }

  function copyToClipboard(text) {
    if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); return true; }
    if (navigator.clipboard?.writeText) { navigator.clipboard.writeText(text); return true; }
    return false;
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  HOTLINK PROXY — GM_xmlhttpRequest for cross-origin images
  // ═══════════════════════════════════════════════════════════════════════

  function proxyLoadImage(url, onSuccess, onError) {
    try {
      const urlHost = new URL(url).hostname.replace(/^www\./, '');
      if (urlHost === currentHost) {
        onSuccess(url);
        return;
      }
    } catch { onSuccess(url); return; }

    if (typeof GM_xmlhttpRequest === 'function') {
      GM_xmlhttpRequest({
        method: 'GET',
        url: url,
        responseType: 'blob',
        anonymous: true,
        onload: (resp) => {
          if (resp.status >= 200 && resp.status < 400 && resp.response) {
            const blobUrl = URL.createObjectURL(resp.response);
            onSuccess(blobUrl, true);
          } else {
            (onError || onSuccess)(url);
          }
        },
        onerror: () => (onError || onSuccess)(url)
      });
    } else {
      onSuccess(url);
    }
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  1. IMAGE EXPANSION
  // ═══════════════════════════════════════════════════════════════════════

  function initImageExpansion() {
    if (!cfg.imageExpansion) return;
    applyFitClasses();

    document.addEventListener('click', (e) => {
      const link = e.target.closest('.thread_image_link');
      if (!link) return;

      // Gallery/search pages: open thread in new tab (not expand)
      // Board index pages: expand inline (fall through to expansion logic below)
      if (isGalleryOrSearch) {
        const article = link.closest('article.thread, article.post');
        if (!article) return;
        const threadLink = article.querySelector('a[href*="/thread/"]');
        const threadNum = article.dataset?.threadNum || getPostNum(article);
        let url;
        if (threadLink) {
          url = threadLink.href;
          const board = (url.match(/\/([^/]+)\/thread\//) || [])[1] || currentBoard;
          const host = BOARD_HOSTS[board];
          if (host && host !== currentHost) url = url.replace(/^https?:\/\/[^/]+/, `https://${host}`);
        } else if (threadNum) {
          url = getBoardUrl(currentBoard) + `thread/${threadNum}/`;
        }
        if (url) {
          // Rewrite href+target and let the native <a> click open the new tab
          link.href = url; link.target = '_blank'; link.rel = 'noopener';
          return; // don't preventDefault — browser follows the rewritten link naturally
        }
        return;
      }

      const href = link.href || '';
      if (/\.(webm|mp4)$/i.test(href)) return;

      // On blocked boards (archived.moe + /b/,/s/,/hc/), completely skip — no expansion
      if (isBlockedExpansion) return;

      e.preventDefault();
      e.stopPropagation();

      const img = link.querySelector('.post_image, .thread_image');
      if (!img) return;

      if (img.classList.contains('ffe-expanded')) {
        if (img.dataset.ffeBlobUrl) { URL.revokeObjectURL(img.dataset.ffeBlobUrl); delete img.dataset.ffeBlobUrl; }
        img.src = img.dataset.ffeThumb;
        img.classList.remove('ffe-expanded');
        if (cfg.advanceOnContract) {
          const post = link.closest('article.thread, article.post');
          let next = post?.nextElementSibling;
          while (next && !next.matches('article.thread, article.post')) next = next.nextElementSibling;
          if (next) next.scrollIntoView({ behavior: 'smooth', block: 'start' });
        }
        return;
      }

      if (!img.dataset.ffeThumb) img.dataset.ffeThumb = img.src;
      link.classList.add('ffe-expanding');

      proxyLoadImage(href, (loadUrl, isBlob) => {
        img.src = loadUrl;
        if (isBlob) img.dataset.ffeBlobUrl = loadUrl;
        img.classList.add('ffe-expanded');
        link.classList.remove('ffe-expanding');
      }, () => {
        link.classList.remove('ffe-expanding');
      });
    });
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  2. IMAGE HOVER ZOOM
  // ═══════════════════════════════════════════════════════════════════════

  function initImageHoverZoom() {
    if (!cfg.imageHoverZoom) return;
    let hoverImg = null;
    document.addEventListener('mouseover', (e) => {
      const link = e.target.closest('.thread_image_link');
      if (!link || /\.(webm|mp4)$/i.test(link.href || '')) return;
      if (isBlockedExpansion) return; // No hover zoom on blocked boards either
      hoverImg = document.createElement('img');
      hoverImg.className = 'ffe-hover-zoom';
      document.body.appendChild(hoverImg);
      proxyLoadImage(link.href, (url, isBlob) => { if (hoverImg) { hoverImg.src = url; if (isBlob) hoverImg.dataset.ffeBlobUrl = url; } });
    });
    document.addEventListener('mousemove', (e) => {
      if (!hoverImg) return;
      hoverImg.style.left = Math.min(e.clientX + 20, window.innerWidth - hoverImg.offsetWidth - 10) + 'px';
      hoverImg.style.top = Math.min(e.clientY + 10, window.innerHeight - hoverImg.offsetHeight - 10) + 'px';
    });
    document.addEventListener('mouseout', (e) => {
      if (!e.target.closest('.thread_image_link')) return;
      if (hoverImg) { if (hoverImg.dataset.ffeBlobUrl) URL.revokeObjectURL(hoverImg.dataset.ffeBlobUrl); hoverImg.remove(); hoverImg = null; }
    });
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  3. QUOTE PREVIEW (theme-aware)
  // ═══════════════════════════════════════════════════════════════════════

  function initQuotePreview() {
    if (!cfg.quotePreview) return;
    let previewEl = null, activeLink = null;

    function isQL(el) { return el.matches('a.backlink, a[data-backlink], a[data-post], .ffe-backlink'); }

    function show(link, pn) {
      hide();
      const post = findPostArticle(pn);
      if (!post) return;
      activeLink = link;

      previewEl = document.createElement('div');
      previewEl.className = 'ffe-quote-preview';
      const wrapper = post.querySelector('.post_wrapper');
      if (wrapper) {
        const clone = wrapper.cloneNode(true);
        previewEl.appendChild(clone);
      } else {
        previewEl.innerHTML = post.innerHTML;
      }
      document.body.appendChild(previewEl);

      const rect = link.getBoundingClientRect();
      let top = rect.bottom + window.scrollY + 4;
      let left = rect.left + window.scrollX;
      if (left + previewEl.offsetWidth > window.innerWidth + window.scrollX - 10) left = window.innerWidth + window.scrollX - previewEl.offsetWidth - 10;
      if (top + previewEl.offsetHeight > window.innerHeight + window.scrollY) top = rect.top + window.scrollY - previewEl.offsetHeight - 4;
      previewEl.style.left = left + 'px';
      previewEl.style.top = top + 'px';

      if (cfg.quoteHighlight) post.classList.add('ffe-highlight');
    }

    function hide() {
      if (previewEl) { previewEl.remove(); previewEl = null; }
      if (activeLink) { document.querySelectorAll('.ffe-highlight').forEach(el => el.classList.remove('ffe-highlight')); activeLink = null; }
    }

    document.addEventListener('mouseover', (e) => {
      const l = e.target.closest('a');
      if (l && isQL(l)) { const pn = getQuoteTarget(l); if (pn) show(l, pn); }
    });
    document.addEventListener('mouseout', (e) => {
      const l = e.target.closest('a');
      if (l && isQL(l)) hide();
    });
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  4. BACKLINKS
  // ═══════════════════════════════════════════════════════════════════════

  function initBacklinks() {
    if (!cfg.quoteBacklinks) return;
    const bm = new Map();
    getAllPosts().forEach(a => {
      const qn = getPostNum(a); if (!qn) return;
      getPostQuoteLinks(a).forEach(l => {
        const tn = getQuoteTarget(l);
        if (tn && tn !== qn) { if (!bm.has(tn)) bm.set(tn, new Set()); bm.get(tn).add(qn); }
      });
    });
    for (const [tn, qns] of bm) {
      const tp = findPostArticle(tn);
      if (!tp || tp.querySelector('.ffe-backlinks, .ffe-backlinks-bottom')) continue;
      const c = document.createElement('span');
      c.className = cfg.backlinkPosition === 'bottom' ? 'ffe-backlinks-bottom' : 'ffe-backlinks';
      for (const qn of qns) {
        const a = document.createElement('a');
        a.className = 'ffe-backlink'; a.href = '#' + (findPostArticle(qn)?.id || qn);
        a.dataset.post = qn; a.textContent = cfg.backlinkFormat.replace('%id', qn);
        c.appendChild(a); c.appendChild(document.createTextNode(' '));
      }
      if (cfg.backlinkPosition === 'bottom') {
        const t = tp.querySelector('.text'); if (t) t.after(c); else tp.querySelector('.post_wrapper')?.appendChild(c);
      } else {
        (tp.querySelector('.post_data') || tp.querySelector('header'))?.appendChild(c);
      }
    }
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  5. THREADING + FOLDING
  // ═══════════════════════════════════════════════════════════════════════

  const Threading = {
    parentOf: {}, childrenOf: {}, origPositions: [], threaded: false,

    init() {
      if (!cfg.quoteThreading || !isThreadPage) return;
      const posts = getAllPosts();
      if (posts.length < 2) return;
      this.origPositions = posts.map(el => ({ el, parent: el.parentNode, nextSib: el.nextSibling }));

      const nums = [], nta = {};
      posts.forEach(a => { const n = getPostNum(a); if (n) { nums.push(n); nta[n] = a; } });
      const ni = {}; nums.forEach((n, i) => ni[n] = i);

      posts.forEach(a => {
        const my = getPostNum(a); if (!my) return;
        const myI = ni[my];
        let best = null, bestI = -1;
        getPostQuoteLinks(a).forEach(l => {
          const tn = getQuoteTarget(l);
          if (tn && tn !== my && nta[tn] && ni[tn] < myI && ni[tn] > bestI) { bestI = ni[tn]; best = tn; }
        });
        if (!best) return;
        this.parentOf[my] = best;
        if (!this.childrenOf[best]) this.childrenOf[best] = [];
        this.childrenOf[best].push(my);
      });

      this.addToggle();
      if (cfg.threadQuotes) this.thread();
    },

    addToggle() {
      const t = document.querySelector('.letters') || document.querySelector('header') || document.querySelector('nav');
      if (!t) return;
      const l = document.createElement('label'); l.className = 'ffe-threading-toggle';
      const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = cfg.threadQuotes;
      cb.addEventListener('change', () => { cb.checked ? this.thread() : this.unthread(); });
      l.appendChild(cb); l.appendChild(document.createTextNode(' Threading')); t.appendChild(l);
    },

    thread() {
      if (this.threaded) return;
      this.threaded = true;
      const isChild = new Set(Object.keys(this.parentOf));
      const build = (pn) => {
        const ch = this.childrenOf[pn];
        if (!ch?.length) return null;
        const c = document.createElement('div'); c.className = 'ffe-thread-container'; c.dataset.parentNum = pn;
        for (const cn of ch) { const ca = findPostArticle(cn); if (ca) { c.appendChild(ca); const sc = build(cn); if (sc) c.appendChild(sc); } }
        return c.children.length ? c : null;
      };
      for (const a of getAllPosts()) {
        const n = getPostNum(a); if (!n || isChild.has(n)) continue;
        a.classList.add('ffe-threadOP');
        const st = build(n); if (st) a.after(st);
      }
      this.addFoldButtons();
    },

    addFoldButtons() {
      for (const pn of Object.keys(this.childrenOf)) {
        const pa = findPostArticle(pn); if (!pa || pa.querySelector('.ffe-thread-toggle')) continue;
        const cont = pa.nextElementSibling;
        if (!cont?.classList.contains('ffe-thread-container')) continue;
        const cc = this.childrenOf[pn].length;
        const header = pa.querySelector('.post_data') || pa.querySelector('header');
        if (!header) continue;

        // [−] / [+] — toggle direct children only
        const btn = document.createElement('span'); btn.className = 'ffe-thread-toggle';
        btn.textContent = '[\u2212]'; btn.title = `${cc} repl${cc===1?'y':'ies'}`;
        btn.addEventListener('click', () => {
          const col = cont.classList.toggle('ffe-collapsed');
          btn.textContent = col ? '[+]' : '[\u2212]';
        });
        header.insertBefore(btn, header.firstChild);

        // [++] / [−−] — single toggle button for ALL nested replies
        const hasNested = cont.querySelector('.ffe-thread-container');
        if (hasNested) {
          let allExpanded = true;
          const allBtn = document.createElement('span'); allBtn.className = 'ffe-thread-toggle ffe-thread-toggle-all';
          allBtn.textContent = '[++]'; allBtn.title = 'Expand/collapse all nested replies';
          allBtn.addEventListener('click', () => {
            allExpanded = !allExpanded;
            if (allExpanded) {
              // Expand all
              cont.classList.remove('ffe-collapsed');
              btn.textContent = '[\u2212]';
              cont.querySelectorAll('.ffe-thread-container').forEach(c => c.classList.remove('ffe-collapsed'));
              // Update all direct [+]/[−] buttons inside to [−]
              cont.querySelectorAll('.ffe-thread-toggle').forEach(t => {
                if (t !== allBtn && (t.textContent === '[+]')) t.textContent = '[\u2212]';
              });
              allBtn.textContent = '[\u2212\u2212]';
            } else {
              // Collapse all
              cont.querySelectorAll('.ffe-thread-container').forEach(c => c.classList.add('ffe-collapsed'));
              cont.querySelectorAll('.ffe-thread-toggle').forEach(t => {
                if (t !== allBtn && (t.textContent === '[\u2212]')) t.textContent = '[+]';
              });
              cont.classList.add('ffe-collapsed');
              btn.textContent = '[+]';
              allBtn.textContent = '[++]';
            }
          });
          btn.after(allBtn);
        }
      }
    },

    unthread() {
      if (!this.threaded) return; this.threaded = false;
      document.querySelectorAll('.ffe-thread-toggle').forEach(b => b.remove());
      document.querySelectorAll('.ffe-thread-container').forEach(c => { while (c.firstChild) c.before(c.firstChild); c.remove(); });
      for (const e of this.origPositions) {
        if (e.nextSib?.parentNode === e.parent) e.parent.insertBefore(e.el, e.nextSib);
        else e.parent.appendChild(e.el);
      }
      document.querySelectorAll('.ffe-threadOP').forEach(el => el.classList.remove('ffe-threadOP'));
    }
  };

  // ═══════════════════════════════════════════════════════════════════════
  //  6. GOON MODE
  // ═══════════════════════════════════════════════════════════════════════

  const GoonMode = {
    active: false, classified: false,
    init() {
      if (!cfg.goonMode) return;
      const t = document.querySelector('.letters') || document.querySelector('header') || document.querySelector('nav');
      if (!t) return;
      const ind = document.createElement('span'); ind.className = 'ffe-goon-indicator'; ind.textContent = 'G';
      ind.title = 'Goon Mode'; ind.addEventListener('click', () => this.toggle()); t.appendChild(ind);
    },
    classify() { if (this.classified) return; this.classified = true; document.querySelectorAll('article.post').forEach(a => { if (!a.querySelector('.thread_image_link')) a.classList.add('ffe-noimage'); }); },
    toggle() { this.active = !this.active; if (this.active) this.classify(); doc.classList.toggle('ffe-goonMode', this.active); }
  };

  // ═══════════════════════════════════════════════════════════════════════
  //  6b. MD5 FILTER
  // ═══════════════════════════════════════════════════════════════════════

  const MD5Filter = {
    active: false,
    init() {
      if (!cfg.md5Tracking) return;
      const t = document.querySelector('.letters') || document.querySelector('header') || document.querySelector('nav');
      if (!t) return;
      const ind = document.createElement('span'); ind.className = 'ffe-md5filter-indicator'; ind.textContent = 'M';
      ind.title = 'MD5 Filter Mode'; ind.addEventListener('click', () => this.toggle()); t.appendChild(ind);
    },
    toggle() {
      this.active = !this.active;
      if (this.active) {
        this.apply();
      } else {
        document.querySelectorAll('.ffe-md5-hidden').forEach(a => a.classList.remove('ffe-md5-hidden'));
      }
      doc.classList.toggle('ffe-md5Filter', this.active);
    },
    apply() {
      // Only filter article.post elements — never hide article.thread (it wraps everything)
      const trackedPostNums = new Set();
      document.querySelectorAll('article.post').forEach(a => {
        if (a.classList.contains('ffe-md5-tracked')) {
          const pn = getPostNum(a);
          if (pn) trackedPostNums.add(pn);
        }
      });
      // Also count OP as tracked if its image matches
      document.querySelectorAll('article.thread').forEach(a => {
        const md5 = getPostMD5(a);
        if (md5 && trackedMD5s.has(md5)) {
          const pn = getPostNum(a);
          if (pn) trackedPostNums.add(pn);
        }
      });

      document.querySelectorAll('article.post').forEach(a => {
        if (a.classList.contains('ffe-md5-tracked')) return;

        const textEl = a.querySelector('.text');
        if (textEl) {
          const links = textEl.querySelectorAll('a.backlink, a[data-backlink], a[data-post]');
          for (const link of links) {
            const target = getQuoteTarget(link);
            if (target && trackedPostNums.has(target)) return;
          }
        }

        a.classList.add('ffe-md5-hidden');
      });
    }
  };

  // ═══════════════════════════════════════════════════════════════════════
  //  7. MD5 FEATURES
  // ═══════════════════════════════════════════════════════════════════════

  function initMD5Features() {
    if (!cfg.md5Tracking) return;
    document.addEventListener('click', (e) => {
      if (!e.target.closest('.ffe-md5-dropdown') && !e.target.closest('.ffe-md5-menu-btn'))
        document.querySelectorAll('.ffe-md5-dropdown').forEach(d => d.remove());
    });
    highlightTrackedPosts();
    getAllPosts().forEach(a => addMD5MenuButton(a));
  }

  function getPostSameLink(article) {
    // Works for both OP (article.thread) and replies (article.post)
    // View Same link is always inside the article — in .post_file_controls (OP)
    // or .post_file > .post_file_controls (replies)
    return article.querySelector('a[href*="/search/image/"]') || null;
  }

  function addMD5MenuButton(article) {
    const md5 = getPostMD5(article); if (!md5) return;

    // Find the specific "View Same" link for this post
    const sameLink = getPostSameLink(article);
    // Only add one ▼ per "View Same" link — mark it once processed
    if (sameLink && sameLink.dataset.ffeMd5Btn) return;
    if (!sameLink && article.querySelector('.ffe-md5-menu-btn')) return;

    const btn = document.createElement('a'); btn.className = 'ffe-md5-menu-btn'; btn.textContent = '\u25BC'; btn.href = '#';
    btn.style.cssText = 'cursor:pointer;margin-left:4px;font-size:11px;color:#81a2be;text-decoration:none;';
    btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); toggleMD5Dropdown(btn, md5); });

    if (sameLink) {
      sameLink.dataset.ffeMd5Btn = '1';
      // Place before filename link if present (like replies), otherwise after View Same
      const container = sameLink.closest('.post_file') || sameLink.closest('.post_file_controls') || sameLink.parentElement;
      const filenameEl = container?.querySelector('.post_file_filename');
      if (filenameEl) {
        filenameEl.before(btn);
      } else {
        sameLink.after(btn);
      }
    } else {
      // No View Same link — place in post_file_controls (OP) or post_data/header (reply)
      const fi = article.querySelector('.post_file_controls') || article.querySelector('.post_data') || article.querySelector('header');
      if (fi) fi.prepend(btn);
    }
  }

  function toggleMD5Dropdown(btn, md5) {
    const existing = document.querySelector('.ffe-md5-dropdown');
    const wasOurs = existing && existing.dataset.ffeMd5 === md5;
    document.querySelectorAll('.ffe-md5-dropdown').forEach(d => d.remove());
    if (wasOurs) return; // toggle closed
    const dd = document.createElement('div'); dd.className = 'ffe-md5-dropdown';
    dd.dataset.ffeMd5 = md5;

    const items = [
      { text: 'Copy MD5', action: () => { copyToClipboard(md5); dd.remove(); } },
      { text: 'View Same (All Boards)', href: `https://archived.moe/_/search/image/${md5}/`, target: '_blank' },
      { text: `View Same (/${currentBoard}/)`, href: `/${currentBoard}/search/image/${md5}/`, target: '_blank' },
      { text: trackedMD5s.has(md5) ? 'Untrack MD5' : 'Track MD5', action: () => {
        trackedMD5s.has(md5) ? trackedMD5s.delete(md5) : trackedMD5s.add(md5);
        saveTrackedMD5s(); highlightTrackedPosts(); dd.remove();
      }}
    ];

    items.forEach(it => {
      const a = document.createElement('a');
      a.textContent = it.text;
      if (it.href) { a.href = it.href; a.target = it.target || ''; }
      if (it.action) a.addEventListener('click', (e) => { e.preventDefault(); it.action(); });
      dd.appendChild(a);
    });

    const r = btn.getBoundingClientRect();
    dd.style.left = r.left + 'px'; dd.style.top = (r.bottom + 2) + 'px';
    document.body.appendChild(dd);
  }

  function highlightTrackedPosts() {
    // Highlight individual reply posts (article.post)
    document.querySelectorAll('article.post').forEach(a => {
      const md5 = getPostMD5(a);
      a.classList.toggle('ffe-md5-tracked', !!(md5 && trackedMD5s.has(md5)));
    });
    // For OP: highlight the .thread_image_box, never the article.thread container
    document.querySelectorAll('article.thread').forEach(a => {
      a.classList.remove('ffe-md5-tracked'); // never highlight the thread wrapper
      const md5 = getPostMD5(a);
      const box = a.querySelector(':scope > .thread_image_box');
      if (box) box.classList.toggle('ffe-md5-tracked', !!(md5 && trackedMD5s.has(md5)));
    });
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  8. SAVED THREADS
  // ═══════════════════════════════════════════════════════════════════════

  let headerControls = null;
  let topbarLeft = null;
  let topbarRight = null;

  function initSavedThreads() {
    if (!cfg.savedThreads) return;
    const target = topbarRight || headerControls;

    // [Saved] link — bookmark icon
    const savedLink = document.createElement('a');
    savedLink.appendChild(icons.bookmark());
    savedLink.title = 'Saved Threads';
    savedLink.href = '#';
    savedLink.addEventListener('click', (e) => { e.preventDefault(); toggleSavedDropdown(savedLink); });

    // On thread pages: heart icon for save/unsave
    if (isThreadPage) {
      const threadUrl = location.href.replace(/#.*/, '');
      const isSaved = savedThreadsList.some(t => t.url === threadUrl);
      const saveBtn = document.createElement('a');
      saveBtn.appendChild(isSaved ? icons.heartFilled() : icons.heartOutline());
      saveBtn.title = isSaved ? 'Unsave Thread' : 'Save Thread';
      saveBtn.href = '#';
      saveBtn.addEventListener('click', (e) => {
        e.preventDefault();
        const url = threadUrl;
        const idx = savedThreadsList.findIndex(t => t.url === url);
        if (idx >= 0) {
          savedThreadsList.splice(idx, 1);
          saveBtn.innerHTML = '';
          saveBtn.appendChild(icons.heartOutline());
          saveBtn.title = 'Save Thread';
        } else {
          const title = document.querySelector('.post_title')?.textContent?.trim()
            || document.querySelector('article.thread .text, article.post .text')?.textContent?.trim()?.substring(0, 80)
            || `Thread #${location.pathname.match(/\/(\d+)/)?.[1] || '?'}`;
          savedThreadsList.unshift({ url, title, board: currentBoard, addedAt: new Date().toISOString() });
          saveBtn.innerHTML = '';
          saveBtn.appendChild(icons.heartFilled());
          saveBtn.title = 'Unsave Thread';
        }
        saveSavedThreads();
      });
      if (target) target.appendChild(saveBtn);
    }

    // Auto-update saved thread title when visiting the thread
    if (isThreadPage) {
      const threadUrl = location.href.replace(/#.*/, '');
      const saved = savedThreadsList.find(t => t.url === threadUrl);
      if (saved && /^Thread #\d+$/.test(saved.title)) {
        const pageTitle = document.querySelector('.post_title')?.textContent?.trim()
          || document.querySelector('article.thread .text, article.post .text')?.textContent?.trim()?.substring(0, 80);
        if (pageTitle) { saved.title = pageTitle; saveSavedThreads(); }
      }
    }

    if (target) target.appendChild(savedLink);
  }

  function toggleSavedDropdown(anchor) {
    const existing = document.querySelector('.ffe-saved-dropdown');
    if (existing) { existing.remove(); return; }

    const dd = document.createElement('div'); dd.className = 'ffe-saved-dropdown';
    const r = anchor.getBoundingClientRect();
    dd.style.left = Math.min(r.left, window.innerWidth - 340) + 'px';
    dd.style.top = (r.bottom + 4) + 'px';

    function render() {
      dd.innerHTML = '';
      if (savedThreadsList.length === 0) {
        dd.innerHTML = '<div class="ffe-saved-empty">No saved threads</div>';
      } else {
        savedThreadsList.forEach((t, i) => {
          const item = document.createElement('div'); item.className = 'ffe-saved-item';
          const board = document.createElement('span'); board.className = 'ffe-saved-board'; board.textContent = `/${t.board}/`;

          // Smart redirect: rewrite URL domain based on board
          const link = document.createElement('a');
          const boardHost = BOARD_HOSTS[t.board] || 'archived.moe';
          try {
            const parsed = new URL(t.url);
            parsed.hostname = boardHost;
            link.href = parsed.toString();
          } catch {
            link.href = t.url;
          }
          link.textContent = t.title;
          link.title = link.href;
          link.target = '_blank';
          link.rel = 'noopener';

          const rm = document.createElement('span'); rm.className = 'ffe-saved-remove'; rm.textContent = '\u00d7'; rm.title = 'Remove';
          rm.addEventListener('click', () => { savedThreadsList.splice(i, 1); saveSavedThreads(); render(); });
          item.appendChild(board); item.appendChild(link); item.appendChild(rm);
          dd.appendChild(item);
        });
      }

      // Manual add row — always shown
      const addRow = document.createElement('div');
      addRow.style.cssText = 'display:flex;gap:4px;padding:6px 8px;border-top:1px solid #444;';
      const addInput = document.createElement('input');
      addInput.style.cssText = 'flex:1;background:#1d1f21;border:1px solid #555;color:#c5c8c6;padding:2px 6px;font-size:11px;';
      addInput.placeholder = 'URL, board/number, or 4chan link...';
      const addBtn = document.createElement('button');
      addBtn.textContent = 'Add';
      addBtn.style.cssText = 'background:#373b41;border:1px solid #555;color:#c5c8c6;padding:2px 8px;cursor:pointer;font-size:11px;';
      addBtn.addEventListener('click', () => {
        const raw = addInput.value.trim(); if (!raw) return;
        addInput.style.borderColor = '#555';
        let board, threadId;
        // Archive URL
        const archiveM = raw.match(/https?:\/\/[^/]+\/([a-z0-9]+)\/thread\/(\d+)/i);
        // 4chan URL
        const chanM = raw.match(/https?:\/\/boards\.4chan(?:nel)?\.org\/([a-z0-9]+)\/thread\/(\d+)/i);
        // Shorthand: board/number or board number
        const shortM = raw.match(/^([a-z0-9]+)[\/\s]+(\d+)$/i);
        // Path only: /board/thread/number
        const pathM = raw.match(/^\/([a-z0-9]+)\/thread\/(\d+)/i);
        if (archiveM) { board = archiveM[1]; threadId = archiveM[2]; }
        else if (chanM) { board = chanM[1]; threadId = chanM[2]; }
        else if (shortM) { board = shortM[1]; threadId = shortM[2]; }
        else if (pathM) { board = pathM[1]; threadId = pathM[2]; }
        else { addInput.style.borderColor = '#cc6666'; return; }
        const host = BOARD_HOSTS[board] || 'archived.moe';
        const url = `https://${host}/${board}/thread/${threadId}/`;
        if (savedThreadsList.some(t => t.url === url)) { addInput.value = ''; return; }
        savedThreadsList.unshift({ url, title: `Thread #${threadId}`, board, addedAt: new Date().toISOString() });
        saveSavedThreads(); addInput.value = ''; render();
      });
      addRow.appendChild(addInput); addRow.appendChild(addBtn);
      dd.appendChild(addRow);

      // Footer: Export / Bulk Import / Clear All
      const footer = document.createElement('div'); footer.className = 'ffe-saved-footer';
      footer.style.cssText = 'display:flex;gap:8px;padding:4px 8px;border-top:1px solid #444;font-size:11px;flex-wrap:wrap;';

      if (savedThreadsList.length > 0) {
        const exportBtn = document.createElement('a'); exportBtn.textContent = 'Export';
        exportBtn.addEventListener('click', () => {
          const lines = savedThreadsList.map(t => {
            const host = BOARD_HOSTS[t.board] || 'archived.moe';
            return `https://${host}/${t.board}/thread/${t.url.match(/\/thread\/(\d+)/)?.[1] || ''}/ # ${t.title}`;
          });
          const blob = new Blob([lines.join('\n')], {type:'text/plain'});
          const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'ffe-saved-threads.txt'; a.click(); URL.revokeObjectURL(a.href);
        });
        footer.appendChild(exportBtn);
      }

      const bulkBtn = document.createElement('a'); bulkBtn.textContent = 'Bulk Import';
      bulkBtn.addEventListener('click', () => {
        const bulkArea = dd.querySelector('.ffe-bulk-import');
        if (bulkArea) { bulkArea.remove(); return; }
        const wrap = document.createElement('div'); wrap.className = 'ffe-bulk-import';
        wrap.style.cssText = 'padding:6px 8px;border-top:1px solid #444;';
        const ta = document.createElement('textarea');
        ta.style.cssText = 'width:100%;height:80px;background:#1d1f21;border:1px solid #555;color:#c5c8c6;font-size:11px;resize:vertical;';
        ta.placeholder = 'Paste thread URLs or board/number (one per line):\nhttps://archived.moe/a/thread/123/\nhttps://boards.4chan.org/g/thread/456\ng/789\ng 789';
        const importBtn = document.createElement('button');
        importBtn.textContent = 'Import'; importBtn.style.cssText = 'margin-top:4px;background:#373b41;border:1px solid #555;color:#c5c8c6;padding:2px 8px;cursor:pointer;font-size:11px;';
        const statusMsg = document.createElement('span'); statusMsg.style.cssText = 'margin-left:6px;font-size:11px;';
        importBtn.addEventListener('click', () => {
          const lines = ta.value.split('\n').map(l => l.replace(/#.*$/, '').trim()).filter(Boolean);
          let added = 0;
          for (const line of lines) {
            let board, threadId, url;
            // Full archive URL: https://archived.moe/a/thread/123/
            const archiveM = line.match(/https?:\/\/[^/]+\/([a-z0-9]+)\/thread\/(\d+)/i);
            // 4chan URL: https://boards.4chan.org/g/thread/123 or https://boards.4channel.org/g/thread/123
            const chanM = line.match(/https?:\/\/boards\.4chan(?:nel)?\.org\/([a-z0-9]+)\/thread\/(\d+)/i);
            // Shorthand: board/number or board number
            const shortM = line.match(/^([a-z0-9]+)[\/\s]+(\d+)$/i);
            if (archiveM) { board = archiveM[1]; threadId = archiveM[2]; }
            else if (chanM) { board = chanM[1]; threadId = chanM[2]; }
            else if (shortM) { board = shortM[1]; threadId = shortM[2]; }
            else continue;
            const host = BOARD_HOSTS[board] || 'archived.moe';
            url = `https://${host}/${board}/thread/${threadId}/`;
            if (savedThreadsList.some(t => t.url === url)) continue;
            savedThreadsList.unshift({ url, title: `Thread #${threadId}`, board, addedAt: new Date().toISOString() });
            added++;
          }
          if (added > 0) { saveSavedThreads(); render(); }
          statusMsg.textContent = added > 0 ? `Added ${added} thread${added > 1 ? 's' : ''}.` : 'No new threads to add.';
          statusMsg.style.color = added > 0 ? '#b5bd68' : '#888';
        });
        wrap.appendChild(ta); wrap.appendChild(importBtn); wrap.appendChild(statusMsg);
        dd.appendChild(wrap);
      });
      footer.appendChild(bulkBtn);

      if (savedThreadsList.length > 0) {
        const clearAll = document.createElement('a'); clearAll.textContent = 'Clear All'; clearAll.style.color = '#cc6666';
        clearAll.addEventListener('click', () => { if (confirm('Remove all saved threads?')) { savedThreadsList.length = 0; saveSavedThreads(); render(); } });
        footer.appendChild(clearAll);
      }
      dd.appendChild(footer);
    }
    render();
    document.body.appendChild(dd);

    // Only close when clicking the bookmark icon again (not on outside click)
    // This keeps the dropdown persistent while adding threads
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  9. CUSTOM BOARD NAVIGATION
  // ═══════════════════════════════════════════════════════════════════════

  function initBoardNav() {
    if (!cfg.boardNavEnabled) return;
    const customBoards = cfg.customBoardNav.trim();
    if (!customBoards) return;

    const nativeNav = document.querySelector('.letters');
    if (nativeNav) nativeNav.style.display = 'none';

    const nav = document.createElement('div'); nav.className = 'ffe-board-nav';
    const toggle = document.createElement('span'); toggle.className = 'ffe-board-nav-toggle';
    toggle.textContent = '[ \u2212 ]';
    let showingCustom = true;
    toggle.addEventListener('click', () => {
      showingCustom = !showingCustom;
      if (nativeNav) nativeNav.style.display = showingCustom ? 'none' : '';
      customLinks.style.display = showingCustom ? '' : 'none';
      toggle.textContent = showingCustom ? '[ \u2212 ]' : '[ + ]';
    });
    nav.appendChild(toggle);

    const customLinks = document.createElement('span');
    const boards = customBoards.split(/[,\s]+/).filter(Boolean);
    boards.forEach((b, i) => {
      if (i > 0) customLinks.appendChild(document.createTextNode(' / '));
      const a = document.createElement('a');
      const url = getBoardUrl(b);
      a.href = url;
      a.textContent = b;
      if (b === currentBoard) a.className = 'ffe-board-current';
      if (url.startsWith('https://')) { a.target = '_blank'; a.rel = 'noopener'; }
      customLinks.appendChild(a);
    });
    nav.appendChild(customLinks);

    // Insert into unified topbar if available, else fallback to native nav position
    if (topbarLeft) {
      topbarLeft.appendChild(nav);
    } else if (nativeNav) {
      nativeNav.before(nav);
    }
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  10. VIEW ON ORIGINAL BOARD
  // ═══════════════════════════════════════════════════════════════════════

  function initViewOriginalBoard() {
    // Only on archived.moe thread pages for cross-hosted boards
    if (!isThreadPage || !isBlockedExpansion) return;

    const canonicalHost = BOARD_HOSTS[currentBoard];
    if (!canonicalHost) return;
    const threadNum = location.pathname.match(/\/thread\/(\d+)/)?.[1];
    if (!threadNum) return;

    getAllPosts().forEach(article => {
      if (article.querySelector('.ffe-view-original-corner')) return;
      const wrapper = article.querySelector('.post_wrapper') || article.closest('.post_wrapper');
      if (wrapper?.querySelector('.ffe-view-original-corner')) return;
      const postNum = getPostNum(article);
      // Anchor format on target archives: board_postnum (no s_ prefix issue)
      const url = `https://${canonicalHost}/${currentBoard}/thread/${threadNum}/#${postNum}`;

      // Bottom-right corner link only
      const target = wrapper || article;
      const corner = document.createElement('a');
      corner.className = 'ffe-view-original-corner';
      corner.href = url;
      corner.target = '_blank';
      corner.rel = 'noopener';
      corner.appendChild(icons.externalLink());
      corner.appendChild(document.createTextNode(` ${canonicalHost.replace('.com','')}`));
      target.appendChild(corner);
    });
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  10b. CROSS-ARCHIVE LINK REDIRECT
  // ═══════════════════════════════════════════════════════════════════════

  function initCrossArchiveRedirect() {
    // On archived.moe, intercept clicks on links pointing to cross-hosted boards
    // and redirect them to the correct archive (thebarchive/archiveofsins)
    if (currentHost !== 'archived.moe') return;

    // Auto-redirect if we landed directly on a cross-hosted board page
    const targetHost = BOARD_HOSTS[currentBoard];
    if (targetHost && targetHost !== currentHost) {
      const rest = location.pathname.replace(/^\/[^/]+/, '') + location.search + location.hash;
      window.location.replace(`https://${targetHost}/${currentBoard}${rest}`);
      return; // stop script execution, page is redirecting
    }

    document.addEventListener('click', (e) => {
      const a = e.target.closest('a[href]');
      if (!a) return;
      const href = a.href;
      // Match links like /b/thread/123, /s/search/..., /hc/, etc. on this same host
      const m = href.match(/^https?:\/\/(?:www\.)?archived\.moe\/([^/]+)(\/.*)?$/);
      if (!m) return;
      const board = m[1];
      const rest = m[2] || '/';
      const targetHost = BOARD_HOSTS[board];
      if (!targetHost) return; // not a cross-hosted board
      // Don't redirect links pointing to the current page (same thread, just anchor scrolling)
      const sameThread = isThreadPage && board === currentBoard &&
        a.pathname === location.pathname;
      if (sameThread) return;
      e.preventDefault();
      e.stopPropagation();
      const newUrl = `https://${targetHost}/${board}${rest}`;
      if (a.target === '_blank') {
        window.open(newUrl, '_blank');
      } else {
        window.location.href = newUrl;
      }
    }, true); // capture phase to intercept before other handlers
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  11. GALLERY
  // ═══════════════════════════════════════════════════════════════════════

  const Gallery = {
    el: null, nodes: {}, images: [], currentIndex: 0, slideshow: false, slideshowTimer: null, rotation: 0, delay: cfg.galSlideDelay,

    init() {
      if (!cfg.galleryEnabled) return;
      if (isIndexPage) return;
      if (isBlockedExpansion) return; // No gallery on blocked boards
      document.addEventListener('dblclick', (e) => {
        const link = e.target.closest('.thread_image_link'); if (!link) return;
        e.preventDefault(); e.stopPropagation();
        const pn = getPostNum(link.closest('article.thread, article.post'));
        const entries = getAllMediaEntries();
        this.open(Math.max(0, entries.findIndex(m => m.postNum === pn)));
      });
    },

    build() {
      const el = document.createElement('div'); el.id = 'ffe-gallery';
      ['gal-fit-width','gal-fit-height','gal-stretch','gal-hide-thumbnails'].forEach(c => el.classList.toggle(c, !!cfg[{
        'gal-fit-width':'galFitWidth','gal-fit-height':'galFitHeight','gal-stretch':'galStretchToFit','gal-hide-thumbnails':'galHideThumbnails'
      }[c]]));
      el.innerHTML = `<div class="gal-viewport"><div class="gal-topbar"><div class="gal-labels"><span class="gal-count"><span class="gal-count-current"></span> / <span class="gal-count-total"></span></span><a class="gal-name" target="_blank"></a></div><div class="gal-buttons"><a class="gal-start" title="Start slideshow"><i></i></a><a class="gal-stop" title="Stop slideshow"><i></i></a><a class="gal-menu-btn" title="Options">\u2630</a><a class="gal-close" title="Close (Esc)">\u00d7</a></div></div><div class="gal-menu"><label><input type="checkbox" data-opt="galFitWidth"> Fit Width</label><label><input type="checkbox" data-opt="galFitHeight"> Fit Height</label><label><input type="checkbox" data-opt="galStretchToFit"> Stretch to Fit</label><label><input type="checkbox" data-opt="galHideThumbnails"> Hide Thumbnails</label><label><input type="checkbox" data-opt="galScrollToPost"> Scroll to Post</label><label class="gal-delay-label">Slide Delay: <input type="number" min="0" step="0.5" value="${this.delay}" class="gal-delay-input"> s</label></div><div class="gal-prev" title="Previous"></div><div class="gal-image"></div><div class="gal-next" title="Next"></div></div><div class="gal-thumbnails"></div>`;
      const q = s => el.querySelector(s);
      this.nodes = { frame: q('.gal-image'), name: q('.gal-name'), countCur: q('.gal-count-current'), countTotal: q('.gal-count-total'), thumbs: q('.gal-thumbnails'), menu: q('.gal-menu'), buttons: q('.gal-buttons') };
      q('.gal-close').addEventListener('click', () => this.close());
      q('.gal-prev').addEventListener('click', () => this.navigate(-1));
      q('.gal-next').addEventListener('click', () => this.navigate(1));
      q('.gal-start').addEventListener('click', () => this.startSlideshow());
      q('.gal-stop').addEventListener('click', () => this.stopSlideshow());
      q('.gal-menu-btn').addEventListener('click', (e) => { e.stopPropagation(); this.nodes.menu.classList.toggle('gal-menu-open'); });
      el.addEventListener('click', (e) => { if (!e.target.closest('.gal-menu,.gal-menu-btn')) this.nodes.menu.classList.remove('gal-menu-open'); });
      // Download current image button — bottom-right, styled like thread download button
      const galDlBtn = document.createElement('button'); galDlBtn.className = 'gal-dl-btn';
      galDlBtn.appendChild(icons.download());
      galDlBtn.appendChild(document.createTextNode(' Download'));
      galDlBtn.title = 'Download current image';
      galDlBtn.addEventListener('click', () => {
        const e = this.images[this.currentIndex]; if (!e) return;
        downloadFile(e.url, e.filename);
      });
      q('.gal-viewport').appendChild(galDlBtn);
      el.querySelectorAll('.gal-menu input[type="checkbox"]').forEach(cb => {
        cb.checked = !!cfg[cb.dataset.opt];
        cb.addEventListener('change', () => { cfg[cb.dataset.opt] = cb.checked; saveSettings(cfg); this.applyFit(); });
      });
      q('.gal-delay-input').addEventListener('change', (e) => { this.delay = parseFloat(e.target.value)||6; cfg.galSlideDelay = this.delay; saveSettings(cfg); });
      // Click zones — dynamic based on image size
      // On the image: left 10% of image = prev, rest = next
      // Off the image: nav zones = gap between frame edge and image edge (max 10% of frame), rest = close
      // Videos: no click interception (has own controls), off-video clicks = close or edge nav
      this.nodes.frame.addEventListener('click', (e) => {
        if (e.target.closest('.gal-topbar,.gal-menu,.gal-dl-btn,.gal-prev,.gal-next')) return;
        const frameRect = this.nodes.frame.getBoundingClientRect();
        const media = this.nodes.frame.querySelector('img, video');
        const isVideo = media?.tagName === 'VIDEO';
        let onMedia = false;
        if (media) {
          const mr = media.getBoundingClientRect();
          onMedia = e.clientX >= mr.left && e.clientX <= mr.right && e.clientY >= mr.top && e.clientY <= mr.bottom;
        }
        if (onMedia && isVideo) return;
        if (onMedia) {
          const imgRect = media.getBoundingClientRect();
          const xInImg = (e.clientX - imgRect.left) / imgRect.width;
          if (xInImg < 0.10) this.navigate(-1);
          else this.navigate(1);
        } else {
          // Dynamic edge nav zones: the gap between frame edge and image edge, capped at 10% of frame
          const maxZone = frameRect.width * 0.10;
          const mr = media?.getBoundingClientRect();
          const leftGap = mr ? Math.max(0, mr.left - frameRect.left) : 0;
          const rightGap = mr ? Math.max(0, frameRect.right - mr.right) : 0;
          const leftZone = Math.min(leftGap, maxZone);
          const rightZone = Math.min(rightGap, maxZone);
          const xInFrame = e.clientX - frameRect.left;
          if (xInFrame <= leftZone) this.navigate(-1);
          else if (xInFrame >= frameRect.width - rightZone) this.navigate(1);
          else this.close();
        }
      });

      // Trackpad swipe — follows finger 1:1, then slides off smoothly
      const vp = q('.gal-viewport');
      let swipeOffset = 0, swiping = false, lastWheelTime = 0, swipeRaf = null;
      vp.addEventListener('wheel', (e) => {
        if (Math.abs(e.deltaX) < Math.abs(e.deltaY) * 0.5) return;
        if (Math.abs(e.deltaX) < 2) return;
        e.preventDefault();
        const media = this.nodes.frame.querySelector('img, video');
        if (!media || swiping) return;
        swipeOffset -= e.deltaX;
        media.style.transition = 'none';
        media.style.transform = `translateX(${swipeOffset}px)`;
        media.style.opacity = `${1 - Math.min(Math.abs(swipeOffset) / (vp.clientWidth * 0.4), 0.6)}`;
        lastWheelTime = Date.now();
        cancelAnimationFrame(swipeRaf);
        swipeRaf = requestAnimationFrame(() => {
          // Check if swipe gesture ended (no new wheel events for 60ms)
          setTimeout(() => {
            if (Date.now() - lastWheelTime < 55) return;
            const threshold = vp.clientWidth * 0.12;
            if (Math.abs(swipeOffset) > threshold) {
              swiping = true;
              const dir = swipeOffset < 0 ? 1 : -1;
              media.style.transition = 'transform 120ms ease-out, opacity 120ms ease-out';
              media.style.transform = `translateX(${-dir * vp.clientWidth}px)`;
              media.style.opacity = '0';
              setTimeout(() => { swiping = false; swipeOffset = 0; this.navigate(dir); }, 120);
            } else {
              // Snap back
              media.style.transition = 'transform 100ms ease-out, opacity 100ms ease-out';
              media.style.transform = '';
              media.style.opacity = '';
              swipeOffset = 0;
            }
          }, 60);
        });
      }, { passive: false });
      this.el = el;
    },

    applyFit() {
      if (!this.el) return;
      this.el.classList.toggle('gal-fit-width', cfg.galFitWidth);
      this.el.classList.toggle('gal-fit-height', cfg.galFitHeight);
      this.el.classList.toggle('gal-stretch', cfg.galStretchToFit);
      this.el.classList.toggle('gal-hide-thumbnails', cfg.galHideThumbnails);
    },

    findNearestIndex() {
      // Find the media entry whose post is closest to the top of the viewport (just below header)
      const entries = getAllMediaEntries();
      if (!entries.length) return 0;
      const headerH = document.querySelector('.ffe-topbar')?.offsetHeight || document.querySelector('.letters')?.offsetHeight || 0;
      const targetY = headerH + 10;
      let bestIdx = 0, bestDist = Infinity;
      entries.forEach((e, i) => {
        if (!e.article) return;
        const rect = e.article.getBoundingClientRect();
        const dist = Math.abs(rect.top - targetY);
        if (dist < bestDist) { bestDist = dist; bestIdx = i; }
      });
      return bestIdx;
    },

    open(idx) {
      this.images = getAllMediaEntries(); if (!this.images.length) return;
      if (!this.el) this.build();
      // Close saved threads dropdown if open
      const savedDd = document.querySelector('.ffe-saved-dropdown');
      if (savedDd) savedDd.remove();
      document.body.appendChild(this.el); document.body.style.overflow = 'hidden';
      this.buildThumbnails(); this.currentIndex = Math.max(0, Math.min(idx||0, this.images.length-1));
      this.rotation = 0; this.show();
    },

    close() { this.stopSlideshow(); if (this.el?.parentNode) { this.el.remove(); document.body.style.overflow = ''; } },

    buildThumbnails() {
      const t = this.nodes.thumbs; t.innerHTML = '';
      this.images.forEach((e, i) => {
        const a = document.createElement('a');
        const img = document.createElement('img'); img.src = e.thumbSrc || e.url; img.loading = 'lazy';
        a.appendChild(img); a.addEventListener('click', () => { this.currentIndex = i; this.show(); }); t.appendChild(a);
      });
    },

    navigate(d) { this.currentIndex = (this.currentIndex + d + this.images.length) % this.images.length; this.rotation = 0; this.show(); },

    show() {
      const e = this.images[this.currentIndex]; if (!e) return;
      const frame = this.nodes.frame;
      const isVid = /\.(webm|mp4)$/i.test(e.url);
      // Revoke any existing blob URL before destroying the element
      const oldMedia = frame.querySelector('[data-ffe-blob-url]');
      if (oldMedia) URL.revokeObjectURL(oldMedia.dataset.ffeBlobUrl);
      frame.innerHTML = '';

      if (isVid) {
        frame.innerHTML = `<video src="${escapeHtml(e.url)}" controls autoplay loop style="display:block;margin:auto;"></video>`;
      } else {
        const img = document.createElement('img');
        img.style.cssText = 'display:block;margin:auto;';
        img.style.transform = `rotate(${this.rotation}deg)`;
        frame.appendChild(img);
        proxyLoadImage(e.url, (url, isBlob) => {
          img.src = url;
          if (isBlob) img.dataset.ffeBlobUrl = url;
        });
      }

      this.nodes.countCur.textContent = this.currentIndex + 1;
      this.nodes.countTotal.textContent = this.images.length;
      this.nodes.name.href = e.url; this.nodes.name.textContent = e.filename;
      this.nodes.thumbs.querySelectorAll('a').forEach((a, i) => a.classList.toggle('gal-thumb-active', i === this.currentIndex));
      this.nodes.thumbs.querySelector('.gal-thumb-active')?.scrollIntoView({ block: 'nearest' });
      if (cfg.galScrollToPost && e.article) {
        const headerH = document.querySelector('.ffe-topbar')?.offsetHeight || document.querySelector('.letters')?.offsetHeight || 0;
        const rect = e.article.getBoundingClientRect();
        window.scrollBy({ top: rect.top - headerH, behavior: 'smooth' });
      }
      if (this.slideshow) this.setupTimer();
    },

    rotate(d) { this.rotation = (this.rotation + d) % 360; const m = this.nodes.frame.querySelector('img,video'); if (m) m.style.transform = `rotate(${this.rotation}deg)`; },
    startSlideshow() { this.slideshow = true; this.nodes.buttons.classList.add('gal-playing'); this.setupTimer(); },
    stopSlideshow() { this.slideshow = false; this.nodes.buttons?.classList.remove('gal-playing'); this.cleanupTimer(); },
    setupTimer() {
      this.cleanupTimer();
      const v = this.nodes.frame.querySelector('video');
      if (v) { v.loop = false; v.addEventListener('ended', () => this.navigate(1), {once:true}); }
      else { this.slideshowTimer = setTimeout(() => this.navigate(1), this.delay * 1000); }
    },
    cleanupTimer() { if (this.slideshowTimer) { clearTimeout(this.slideshowTimer); this.slideshowTimer = null; } },
    handleKey(e) {
      if (!this.el?.parentNode) return false;
      switch (e.key) {
        case 'Escape': this.close(); return true;
        case 'ArrowLeft': if (e.shiftKey) this.rotate(-90); else if (e.ctrlKey) this.slideshow?this.stopSlideshow():this.startSlideshow(); else this.navigate(-1); return true;
        case 'ArrowRight': if (e.shiftKey) this.rotate(90); else if (e.ctrlKey) this.slideshow?this.stopSlideshow():this.startSlideshow(); else this.navigate(1); return true;
        case 'Enter': { const v = this.nodes.frame.querySelector('video'); if (v) v.paused?v.play():v.pause(); else this.navigate(1); return true; }
        case 'p': case 'P': { const v = this.nodes.frame.querySelector('video'); if (v) v.paused?v.play():v.pause(); return true; }
      }
      return false;
    }
  };

  // ═══════════════════════════════════════════════════════════════════════
  //  12. DOWNLOADS
  // ═══════════════════════════════════════════════════════════════════════

  function downloadFile(url, filename) {
    if (typeof GM_download === 'function') { GM_download({ url, name: filename, saveAs: false }); }
    else { const a = document.createElement('a'); a.href = url; a.download = filename; a.target = '_blank'; document.body.appendChild(a); a.click(); a.remove(); }
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  13. FLOATING PANEL (scroll nav + download + MD5 nav)
  // ═══════════════════════════════════════════════════════════════════════

  function initFloatingPanel() {
    const panel = document.createElement('div');
    panel.className = 'ffe-float-panel';

    // Download / redirect button (thread pages only)
    if (isThreadPage && cfg.batchDownload) {
      const dlBtn = document.createElement('button');
      dlBtn.className = 'ffe-float-dl';

      if (isBlockedExpansion) {
        // On blocked boards: redirect to original archive
        const canonicalHost = BOARD_HOSTS[currentBoard];
        const threadNum = location.pathname.match(/\/thread\/(\d+)/)?.[1];
        dlBtn.appendChild(icons.externalLink());
        dlBtn.appendChild(document.createTextNode(' Download On Original Board'));
        dlBtn.addEventListener('click', () => {
          if (canonicalHost && threadNum) {
            window.open(`https://${canonicalHost}/${currentBoard}/thread/${threadNum}/`, '_blank');
          }
        });
      } else {
        // Normal download
        dlBtn.appendChild(icons.download());
        dlBtn.appendChild(document.createTextNode(' Download Thread Media'));
        dlBtn.addEventListener('click', () => {
          const entries = getAllMediaEntries();
          if (!entries.length) { alert('No images found.'); return; }
          if (!confirm(`Download ${entries.length} files (including OP)?`)) return;
          let i = 0;
          (function next() {
            if (i >= entries.length) return;
            downloadFile(entries[i].url, entries[i].filename);
            i++;
            if (i < entries.length) setTimeout(next, cfg.batchDownloadDelay);
          })();
        });
      }
      panel.appendChild(dlBtn);
    }

    // Collapse / Expand all threaded replies — two separate buttons, always visible
    if (isThreadPage && cfg.quoteThreading) {
      const collapseBtn = document.createElement('button');
      collapseBtn.className = 'ffe-float-dl';
      collapseBtn.textContent = 'Collapse';
      collapseBtn.title = 'Collapse all threaded replies';
      collapseBtn.addEventListener('click', () => {
        document.querySelectorAll('.ffe-thread-container').forEach(c => c.classList.add('ffe-collapsed'));
        document.querySelectorAll('.ffe-thread-toggle:not(.ffe-thread-toggle-all)').forEach(b => { b.textContent = '[+]'; });
        document.querySelectorAll('.ffe-thread-toggle-all').forEach(b => { b.textContent = '[++]'; });
      });
      panel.appendChild(collapseBtn);

      const expandBtn = document.createElement('button');
      expandBtn.className = 'ffe-float-dl';
      expandBtn.textContent = 'Expand';
      expandBtn.title = 'Expand all threaded replies';
      expandBtn.addEventListener('click', () => {
        document.querySelectorAll('.ffe-thread-container').forEach(c => c.classList.remove('ffe-collapsed'));
        document.querySelectorAll('.ffe-thread-toggle:not(.ffe-thread-toggle-all)').forEach(b => { b.textContent = '[\u2212]'; });
        document.querySelectorAll('.ffe-thread-toggle-all').forEach(b => { b.textContent = '[\u2212\u2212]'; });
      });
      panel.appendChild(expandBtn);
    }

    // Previous tracked MD5 post button
    const skipPrevBtn = document.createElement('button');
    skipPrevBtn.className = 'ffe-float-skip-prev';
    skipPrevBtn.appendChild(icons.skipPrev());
    skipPrevBtn.title = 'Previous tracked MD5 post';
    skipPrevBtn.addEventListener('click', () => navigateTrackedMD5(-1));
    panel.appendChild(skipPrevBtn);

    // Up arrow
    const upBtn = document.createElement('button');
    upBtn.className = 'ffe-float-up';
    upBtn.appendChild(icons.arrowUp());
    upBtn.title = 'Go to top';
    upBtn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
    panel.appendChild(upBtn);

    // Down arrow
    const downBtn = document.createElement('button');
    downBtn.className = 'ffe-float-down';
    downBtn.appendChild(icons.arrowDown());
    downBtn.title = 'Go to bottom';
    downBtn.addEventListener('click', () => window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }));
    panel.appendChild(downBtn);

    // Next tracked MD5 post button
    const skipNextBtn = document.createElement('button');
    skipNextBtn.className = 'ffe-float-skip-next';
    skipNextBtn.appendChild(icons.skipNext());
    skipNextBtn.title = 'Next tracked MD5 post';
    skipNextBtn.addEventListener('click', () => navigateTrackedMD5(1));
    panel.appendChild(skipNextBtn);

    document.body.appendChild(panel);

    // Throttled scroll handler
    let ticking = false;
    function updateArrows() {
      const scrollY = window.scrollY;
      const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
      upBtn.classList.toggle('ffe-float-hidden', scrollY <= 50);
      downBtn.classList.toggle('ffe-float-hidden', scrollY >= maxScroll - 50);
      ticking = false;
    }
    window.addEventListener('scroll', () => {
      if (!ticking) { ticking = true; requestAnimationFrame(updateArrows); }
    });
    updateArrows();
  }

  function navigateTrackedMD5(direction) {
    const tracked = Array.from(document.querySelectorAll('.ffe-md5-tracked'));
    if (!tracked.length) return;

    // Account for fixed topbar height so posts don't hide behind it
    const topbar = document.querySelector('.ffe-topbar');
    const offset = topbar ? topbar.offsetHeight + 4 : 0;
    const scrollTop = window.scrollY + offset;

    function scrollToPost(el) {
      const y = el.getBoundingClientRect().top + window.scrollY - offset;
      window.scrollTo({ top: y, behavior: 'smooth' });
    }

    if (direction < 0) {
      for (let i = tracked.length - 1; i >= 0; i--) {
        const postTop = tracked[i].getBoundingClientRect().top + window.scrollY;
        if (postTop < scrollTop - 5) { scrollToPost(tracked[i]); return; }
      }
      scrollToPost(tracked[tracked.length - 1]);
    } else {
      for (let i = 0; i < tracked.length; i++) {
        const postTop = tracked[i].getBoundingClientRect().top + window.scrollY;
        if (postTop > scrollTop + 50) { scrollToPost(tracked[i]); return; }
      }
      scrollToPost(tracked[0]);
    }
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  14. INDEX PAGE CUSTOMIZATION — promote pinned/tracked threads
  // ═══════════════════════════════════════════════════════════════════════

  function initIndexCustomization() {
    if (isIndexPage) {
      // Index/gallery thumbnail size
      const thumbSize = cfg.indexThumbSize;
      if (thumbSize && thumbSize !== 'default') {
        doc.classList.add('ffe-index-thumb-' + thumbSize);
      }
      // Promote pinned threads and MD5-tracked threads to top
      promoteSpecialThreads();
    }

    if (isThreadPage) {
      // Thread thumbnail size
      const tSize = cfg.threadThumbSize;
      if (tSize && tSize !== 'default') {
        doc.classList.add('ffe-thread-thumb-' + tSize);
      }
    }
  }

  function promoteSpecialThreads() {
    // Find all OP posts (first post in each thread) on index pages
    // On FoolFuuka index, threads are separated — each thread's OP is the first article
    const allArticles = document.querySelectorAll('article.thread, article.post');
    if (!allArticles.length) return;

    // Collect thread containers and check if they should be promoted
    const promoted = [];
    const normal = [];

    // On index pages, threads are often wrapped in containers or separated by <hr>
    // We work with the thread-level containers
    const threadContainers = document.querySelectorAll('div[id^="thread_"], article.thread, div.thread');

    if (threadContainers.length > 0) {
      threadContainers.forEach(tc => {
        const shouldPromote = checkThreadPromotion(tc);
        if (shouldPromote.pinned) {
          tc.classList.add('ffe-promoted');
          promoted.push({ el: tc, reason: 'pinned' });
        } else if (shouldPromote.md5) {
          tc.classList.add('ffe-promoted-md5');
          promoted.push({ el: tc, reason: 'md5' });
        } else {
          normal.push(tc);
        }
      });

      // Move promoted threads to top
      if (promoted.length > 0) {
        const parent = promoted[0].el.parentElement;
        if (parent) {
          // Insert promoted threads before the first normal thread
          const firstNormal = normal[0];
          promoted.forEach(p => {
            if (firstNormal) parent.insertBefore(p.el, firstNormal);
            else parent.appendChild(p.el);
          });
        }
      }
    } else {
      // Fallback: work with individual articles on index
      allArticles.forEach(article => {
        const threadLink = article.querySelector('a[href*="/thread/"]');
        const threadUrl = threadLink?.href || '';

        // Check if this thread is in saved threads
        const isPinned = savedThreadsList.some(t => {
          return threadUrl && threadUrl.includes(t.url.split('#')[0]);
        });

        // Check if any image in this thread has a tracked MD5
        const md5 = getPostMD5(article);
        const isMD5Tracked = md5 && trackedMD5s.has(md5);

        if (isPinned) article.classList.add('ffe-promoted');
        if (isMD5Tracked) article.classList.add('ffe-promoted-md5');
      });
    }
  }

  function checkThreadPromotion(container) {
    // Include the container itself if it's article.thread (it holds the OP image/links)
    const articles = [container, ...container.querySelectorAll('article.post')];
    let pinned = false, md5 = false;

    articles.forEach(article => {
      // Check saved/pinned
      const threadLink = article.querySelector('a[href*="/thread/"]');
      if (threadLink) {
        const href = threadLink.href;
        if (savedThreadsList.some(t => href.includes(t.url.split('#')[0]))) {
          pinned = true;
        }
      }
      // Check MD5 tracked
      const postMD5 = getPostMD5(article);
      if (postMD5 && trackedMD5s.has(postMD5)) md5 = true;
    });

    return { pinned, md5 };
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  15. SETTINGS PANEL — "Archive Enhancer Settings"
  // ═══════════════════════════════════════════════════════════════════════

  function openSettings(initialTab) {
    if (document.getElementById('ffe-overlay')) return;

    const sections = {};
    for (const [key, meta] of Object.entries(SETTING_META)) {
      if (!sections[meta.section]) sections[meta.section] = [];
      sections[meta.section].push({ key, ...meta });
    }
    sections['MD5'] = null;
    const sectionNames = Object.keys(sections);

    const overlay = document.createElement('div'); overlay.id = 'ffe-overlay';
    const dialog = document.createElement('div'); dialog.id = 'ffe-settings';

    const nav = document.createElement('nav');
    const tabsDiv = document.createElement('div'); tabsDiv.className = 'ffe-tabs';
    const startTab = initialTab || sectionNames[0];
    sectionNames.forEach(name => {
      const tab = document.createElement('a'); tab.className = 'ffe-tab' + (name === startTab ? ' ffe-tab-selected' : '');
      tab.textContent = name; tab.dataset.section = name;
      tab.addEventListener('click', () => switchTab(name)); tabsDiv.appendChild(tab);
    });
    nav.appendChild(tabsDiv);
    const closeBtn = document.createElement('a'); closeBtn.className = 'ffe-close-btn'; closeBtn.textContent = '\u00d7';
    closeBtn.addEventListener('click', closeSettings); nav.appendChild(closeBtn);
    dialog.appendChild(nav);

    const sectionContainer = document.createElement('div'); sectionContainer.className = 'ffe-section-container';

    for (const sName of sectionNames) {
      const sec = document.createElement('div');
      sec.className = 'ffe-section' + (sName === startTab ? ' ffe-section-active' : '');
      sec.dataset.section = sName;

      if (sName === 'MD5') {
        renderMD5Manager(sec);
      } else {
        for (const item of sections[sName]) {
          const row = document.createElement('div'); row.className = 'ffe-option';
          const type = item.type || 'checkbox';

          if (type === 'checkbox') {
            const lbl = document.createElement('label');
            const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!cfg[item.key];
            cb.addEventListener('change', () => { cfg[item.key] = cb.checked; saveSettings(cfg); });
            lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + item.label)); row.appendChild(lbl);
          } else if (type === 'text' || type === 'text-wide' || type === 'number') {
            const lbl = document.createElement('label'); lbl.textContent = item.label + ': ';
            const input = document.createElement('input');
            input.type = type === 'number' ? 'number' : 'text';
            input.value = cfg[item.key];
            if (type === 'number') { input.min = 0; input.max = 10000; input.style.width = '70px'; }
            else if (type === 'text-wide') { input.className = 'ffe-text-wide'; }
            else { input.style.width = '60px'; }
            input.addEventListener('change', () => { cfg[item.key] = type === 'number' ? (parseFloat(input.value)||0) : input.value; saveSettings(cfg); });
            lbl.appendChild(input); row.appendChild(lbl);
          } else if (type === 'select') {
            const lbl = document.createElement('label'); lbl.textContent = item.label + ': ';
            const sel = document.createElement('select');
            for (const opt of item.options) { const o = document.createElement('option'); o.value = opt; o.textContent = opt; if (cfg[item.key] === opt) o.selected = true; sel.appendChild(o); }
            sel.addEventListener('change', () => { cfg[item.key] = sel.value; saveSettings(cfg); });
            lbl.appendChild(sel); row.appendChild(lbl);
          }
          const desc = document.createElement('span'); desc.className = 'ffe-desc'; desc.textContent = item.desc;
          row.appendChild(desc); sec.appendChild(row);
        }
      }
      sectionContainer.appendChild(sec);
    }
    dialog.appendChild(sectionContainer);

    // Footer
    const footer = document.createElement('div'); footer.className = 'ffe-footer';
    const statusEl = document.createElement('span'); statusEl.className = 'ffe-status';

    const exportLink = document.createElement('a'); exportLink.textContent = 'Export All';
    exportLink.addEventListener('click', () => {
      const allData = { settings: cfg, trackedMD5s: [...trackedMD5s], savedThreads: savedThreadsList };
      const blob = new Blob([JSON.stringify(allData, null, 2)], {type:'application/json'});
      const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'ffe-backup.json'; a.click(); URL.revokeObjectURL(a.href);
      statusEl.textContent = 'Exported settings, MD5s, and saved threads!'; statusEl.style.color = '#b5bd68';
    });
    footer.appendChild(exportLink); footer.appendChild(document.createTextNode(' | '));

    const importLink = document.createElement('a'); importLink.textContent = 'Import All';
    const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.json';
    fileInput.addEventListener('change', () => {
      const file = fileInput.files[0]; if (!file) return;
      const reader = new FileReader();
      reader.onload = () => {
        try {
          const data = JSON.parse(reader.result);
          // Support both old format (flat settings) and new format (with sections)
          if (data.settings) {
            Object.assign(cfg, {...DEFAULTS, ...data.settings}); saveSettings(cfg);
            if (data.trackedMD5s?.length) { trackedMD5s = new Set(data.trackedMD5s); saveTrackedMD5s(); }
            if (data.savedThreads?.length) { savedThreadsList = data.savedThreads; saveSavedThreads(); }
            statusEl.textContent = 'Imported all data! Reload to apply.';
          } else {
            // Legacy: flat settings object
            Object.assign(cfg, {...DEFAULTS, ...data}); saveSettings(cfg);
            statusEl.textContent = 'Imported settings! Reload to apply.';
          }
          statusEl.style.color = '#b5bd68';
        } catch { statusEl.textContent = 'Invalid JSON.'; statusEl.style.color = '#cc6666'; }
      };
      reader.readAsText(file);
    });
    importLink.addEventListener('click', () => fileInput.click()); footer.appendChild(importLink); footer.appendChild(fileInput);
    footer.appendChild(document.createTextNode(' | '));

    const resetLink = document.createElement('a'); resetLink.textContent = 'Reset';
    resetLink.addEventListener('click', () => { if (!confirm('Reset all settings?')) return; Object.assign(cfg, DEFAULTS); saveSettings(cfg); statusEl.textContent = 'Reset! Reload to apply.'; });
    footer.appendChild(resetLink);

    footer.appendChild(Object.assign(document.createElement('span'), {className:'ffe-spacer'}));
    footer.appendChild(statusEl); footer.appendChild(document.createTextNode(' '));
    footer.appendChild(Object.assign(document.createElement('span'), {style:'color:#707880', textContent:`Archive Enhancer Settings v${VERSION}`}));
    dialog.appendChild(footer);

    overlay.appendChild(dialog); document.body.appendChild(overlay);
    overlay.addEventListener('click', (e) => { if (e.target === overlay) closeSettings(); });

    function switchTab(name) {
      dialog.querySelectorAll('.ffe-tab').forEach(t => t.classList.toggle('ffe-tab-selected', t.dataset.section === name));
      dialog.querySelectorAll('.ffe-section').forEach(s => s.classList.toggle('ffe-section-active', s.dataset.section === name));
    }
  }

  function renderMD5Manager(container) {
    const mgr = document.createElement('div'); mgr.className = 'ffe-md5-manager';

    const count = document.createElement('div');
    count.style.cssText = 'margin-bottom:6px;color:#888;';

    function refresh() {
      count.textContent = `${trackedMD5s.size} hash${trackedMD5s.size === 1 ? '' : 'es'} tracked`;
      list.innerHTML = '';
      if (trackedMD5s.size === 0) {
        list.innerHTML = '<div style="padding:12px;text-align:center;color:#666;font-style:italic;">No tracked hashes</div>';
        return;
      }
      for (const hash of trackedMD5s) {
        const entry = document.createElement('div'); entry.className = 'ffe-md5-entry';
        const span = document.createElement('span'); span.textContent = hash;
        const rm = document.createElement('a'); rm.textContent = '\u00d7'; rm.title = 'Remove';
        rm.addEventListener('click', () => { trackedMD5s.delete(hash); saveTrackedMD5s(); highlightTrackedPosts(); refresh(); });
        entry.appendChild(span); entry.appendChild(rm); list.appendChild(entry);
      }
    }

    mgr.appendChild(count);

    const list = document.createElement('div'); list.className = 'ffe-md5-list';
    mgr.appendChild(list);

    // Single hash add row
    const addRow = document.createElement('div'); addRow.className = 'ffe-md5-add';
    const addInput = document.createElement('input'); addInput.placeholder = 'Paste MD5 hash (base64)...';
    const addBtn = document.createElement('button'); addBtn.textContent = 'Add';
    addBtn.addEventListener('click', () => {
      const v = addInput.value.trim(); if (!v) return;
      trackedMD5s.add(v); saveTrackedMD5s(); highlightTrackedPosts(); addInput.value = ''; refresh();
    });
    addRow.appendChild(addInput); addRow.appendChild(addBtn);
    mgr.appendChild(addRow);

    // Bulk import textarea
    const bulkWrap = document.createElement('div'); bulkWrap.style.cssText = 'margin-top:8px;';
    const bulkLabel = document.createElement('div'); bulkLabel.style.cssText = 'font-size:11px;color:#888;margin-bottom:3px;';
    bulkLabel.textContent = 'Bulk import (one hash per line):';
    const bulkArea = document.createElement('textarea');
    bulkArea.style.cssText = 'width:100%;height:80px;background:#1d1f21;border:1px solid #555;color:#c5c8c6;padding:4px 6px;font-size:11px;font-family:monospace;resize:vertical;box-sizing:border-box;';
    bulkArea.placeholder = 'zIlsrrqc9tHGYGj9YgIdjg\nKibmdWOC0nHEOFS2tkhZ7A\n...';
    const bulkBtn = document.createElement('button');
    bulkBtn.textContent = 'Import All';
    bulkBtn.style.cssText = 'margin-top:4px;background:#373b41;border:1px solid #555;color:#c5c8c6;padding:3px 10px;cursor:pointer;font-size:12px;';
    bulkBtn.addEventListener('click', () => {
      const lines = bulkArea.value.split(/[\n\r]+/).map(l => l.trim()).filter(l => l.length > 0);
      if (!lines.length) return;
      let added = 0;
      for (const line of lines) {
        if (!trackedMD5s.has(line)) { trackedMD5s.add(line); added++; }
      }
      saveTrackedMD5s(); highlightTrackedPosts(); bulkArea.value = '';
      refresh();
      statusSpan.textContent = `Imported ${added} new hash${added === 1 ? '' : 'es'} (${lines.length - added} duplicate${lines.length - added === 1 ? '' : 's'} skipped)`;
      statusSpan.style.color = '#b5bd68';
    });
    bulkWrap.appendChild(bulkLabel); bulkWrap.appendChild(bulkArea); bulkWrap.appendChild(bulkBtn);
    mgr.appendChild(bulkWrap);

    // Action links row
    const actions = document.createElement('div'); actions.className = 'ffe-md5-actions';
    actions.style.cssText = 'margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;';

    const exportLink = document.createElement('a'); exportLink.textContent = 'Export Hashes';
    exportLink.addEventListener('click', () => {
      if (trackedMD5s.size === 0) { statusSpan.textContent = 'Nothing to export.'; statusSpan.style.color = '#cc6666'; return; }
      const text = [...trackedMD5s].join('\n');
      const blob = new Blob([text], { type: 'text/plain' });
      const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
      a.download = `ffe-md5-hashes-${trackedMD5s.size}.txt`; a.click(); URL.revokeObjectURL(a.href);
      statusSpan.textContent = `Exported ${trackedMD5s.size} hashes.`; statusSpan.style.color = '#b5bd68';
    });
    actions.appendChild(exportLink);

    const clearLink = document.createElement('a'); clearLink.textContent = 'Clear All';
    clearLink.style.color = '#cc6666';
    clearLink.addEventListener('click', () => {
      if (!confirm('Remove all tracked MD5 hashes?')) return;
      trackedMD5s.clear(); saveTrackedMD5s(); highlightTrackedPosts(); refresh();
      statusSpan.textContent = 'All hashes cleared.'; statusSpan.style.color = '#cc6666';
    });
    actions.appendChild(clearLink);
    mgr.appendChild(actions);

    // Status message
    const statusSpan = document.createElement('div');
    statusSpan.style.cssText = 'margin-top:6px;font-size:11px;color:#888;min-height:14px;';
    mgr.appendChild(statusSpan);

    container.appendChild(mgr);
    refresh();
  }

  function closeSettings() { document.getElementById('ffe-overlay')?.remove(); }

  // ═══════════════════════════════════════════════════════════════════════
  //  HEADER CONTROLS & KEYBOARD
  // ═══════════════════════════════════════════════════════════════════════

  function addSettingsLink() {
    headerControls = document.createElement('div');
    headerControls.className = 'ffe-topbar';

    topbarLeft = document.createElement('div');
    topbarLeft.className = 'ffe-topbar-left';
    headerControls.appendChild(topbarLeft);

    topbarRight = document.createElement('div');
    topbarRight.className = 'ffe-topbar-right';

    // Pin/unpin toggle
    const pinBtn = document.createElement('a');
    pinBtn.appendChild(cfg.headerPinned ? icons.lock('Header pinned') : icons.unlock('Header unpinned'));
    pinBtn.title = cfg.headerPinned ? 'Header pinned (click to unpin)' : 'Header unpinned (click to pin)';
    pinBtn.href = '#';
    pinBtn.addEventListener('click', (e) => {
      e.preventDefault();
      cfg.headerPinned = !cfg.headerPinned;
      saveSettings(cfg);
      pinBtn.innerHTML = '';
      pinBtn.appendChild(cfg.headerPinned ? icons.lock('Header pinned') : icons.unlock('Header unpinned'));
      pinBtn.title = cfg.headerPinned ? 'Header pinned (click to unpin)' : 'Header unpinned (click to pin)';
      applyHeaderPinState();
    });
    topbarRight.appendChild(pinBtn);

    const settingsLink = document.createElement('a');
    settingsLink.appendChild(icons.cog());
    settingsLink.title = "Archive Enhancer Settings";
    settingsLink.href = '#';
    settingsLink.addEventListener('click', (e) => { e.preventDefault(); openSettings(); });
    topbarRight.appendChild(settingsLink);

    headerControls.appendChild(topbarRight);
    document.body.appendChild(headerControls);

    applyHeaderPinState();
  }

  function applyHeaderPinState() {
    if (!headerControls) return;
    if (cfg.headerPinned) {
      headerControls.classList.remove('ffe-header-fading');
    } else {
      headerControls.classList.add('ffe-header-fading');
    }
  }

  function initKeyboardShortcuts() {
    if (!cfg.keyboardNav) return;
    document.addEventListener('keydown', (e) => {
      if (Gallery.handleKey(e)) { e.preventDefault(); e.stopPropagation(); return; }
      const inInput = document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA';
      if (inInput) return;
      const key = e.key.toLowerCase();
      if (key === cfg.settingsKey.toLowerCase()) { e.preventDefault(); document.getElementById('ffe-overlay') ? closeSettings() : openSettings(); return; }
      if (key === cfg.galleryKey.toLowerCase() && !document.getElementById('ffe-overlay')) { e.preventDefault(); Gallery.open(Gallery.findNearestIndex()); return; }
      if (cfg.goonMode && key === cfg.goonKey.toLowerCase()) { e.preventDefault(); GoonMode.toggle(); return; }
      if (cfg.md5Tracking && key === cfg.md5FilterKey.toLowerCase()) { e.preventDefault(); MD5Filter.toggle(); return; }
    });
  }

  // ═══════════════════════════════════════════════════════════════════════
  //  INIT
  // ═══════════════════════════════════════════════════════════════════════

  function bootstrap() {
    const inits = [
      applyFitClasses, applyTextSize, addSettingsLink, initBoardNav,
      initImageExpansion, initImageHoverZoom, initQuotePreview, initBacklinks,
      () => Threading.init(), () => GoonMode.init(), () => MD5Filter.init(),
      initMD5Features, initSavedThreads, () => Gallery.init(),
      initViewOriginalBoard, initCrossArchiveRedirect,
      initFloatingPanel, initIndexCustomization, initKeyboardShortcuts,
    ];
    for (const fn of inits) {
      try { fn(); } catch (e) { console.error('[4AE] Init failed:', fn.name || 'anonymous', e); }
    }

    const observer = new MutationObserver((mutations) => {
      for (const m of mutations) for (const node of m.addedNodes) {
        if (node.nodeType !== 1) continue;
        if (node.matches?.('article.thread, article.post')) { cachePost(node); if (cfg.md5Tracking) addMD5MenuButton(node); }
        node.querySelectorAll?.('article.thread, article.post').forEach(a => { cachePost(a); if (cfg.md5Tracking) addMD5MenuButton(a); });
      }
      if (GoonMode.active) { GoonMode.classified = false; GoonMode.classify(); }
      if (MD5Filter.active) { MD5Filter.apply(); }
      if (cfg.md5Tracking) highlightTrackedPosts();
    });
    const target = document.querySelector('.thread, #thread, article.thread') || document.getElementById('main') || document.body;
    observer.observe(target, { childList: true, subtree: true });
  }

  let bootstrapped = false;
  function tryBootstrap() {
    if (bootstrapped) return;
    if (!document.body) return;
    bootstrapped = true;
    bootstrap();
  }

  if (document.readyState !== 'loading') {
    tryBootstrap();
  } else {
    document.addEventListener('DOMContentLoaded', tryBootstrap);
    window.addEventListener('load', tryBootstrap);
    // Polling fallback for AdGuard timing edge cases
    const poll = setInterval(() => { if (document.body) { clearInterval(poll); tryBootstrap(); } }, 50);
    setTimeout(() => clearInterval(poll), 10000);
  }

})();