Booru Enhancer

Modular enhancement suite for Danbooru/Gelbooru/Moebooru-family booru sites — original-quality media, smart downloads, fullscreen viewer, tag tools, blacklist filtering, infinite scroll, reverse image search, and more.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Booru Enhancer
// @namespace    https://sleazyfork.org/en/scripts/587810-booru-enhancer
// @version      1.2.7
// @description  Modular enhancement suite for Danbooru/Gelbooru/Moebooru-family booru sites — original-quality media, smart downloads, fullscreen viewer, tag tools, blacklist filtering, infinite scroll, reverse image search, and more.
// @author       itachi-re
// @license      MIT
// @match        *://danbooru.donmai.us/*
// @match        *://*.donmai.us/*
// @match        *://atfbooru.ninja/*
// @match        *://gelbooru.com/*
// @match        *://safebooru.org/*
// @match        *://rule34.xxx/*
// @match        *://rule34.us/*
// @match        *://realbooru.com/*
// @match        *://tbib.org/*
// @match        *://xbooru.com/*
// @match        *://hypnohub.net/*
// @match        *://konachan.com/*
// @match        *://konachan.net/*
// @match        *://yande.re/*
// @match        *://lolibooru.moe/*
// @match        *://e621.net/*
// @match        *://e926.net/*
// @match        *://chan.sankakucomplex.com/*
// @match        *://idol.sankakucomplex.com/*
// @match        *://beta.sankakucomplex.com/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_listValues
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @grant        GM_info
// @connect      donmai.us
// @connect      atfbooru.ninja
// @connect      gelbooru.com
// @connect      safebooru.org
// @connect      rule34.xxx
// @connect      rule34.us
// @connect      realbooru.com
// @connect      tbib.org
// @connect      xbooru.com
// @connect      hypnohub.net
// @connect      konachan.com
// @connect      konachan.net
// @connect      yande.re
// @connect      lolibooru.moe
// @connect      e621.net
// @connect      e926.net
// @connect      sankakucomplex.com
// @connect      sankakuapi.com
// @connect      saucenao.com
// @connect      iqdb.org
// @run-at       document-end
// @noframes
// ==/UserScript==

/**
 * =============================================================================
 *  BOORU ENHANCER  —  v1.2.7
 * =============================================================================
 *  CHANGELOG (1.2.6 → 1.2.7) — regression-review follow-up
 *  ------------------------------------------------------------------------
 *  - Gallery click/hover delegation moved from capture phase to bubble
 *    phase. preventDefault() still stops the default navigation either
 *    way (the browser only commits it after the full capture+bubble
 *    dispatch completes), but bubble phase no longer runs our logic
 *    ahead of any click handlers the site itself has on the thumbnail.
 *  - Gelbooru-family wrapper resolution is now validated, not just
 *    pattern-matched: a candidate wrapper is only accepted if it contains
 *    exactly one thumbnail image and resolves to the SAME post ID as the
 *    image itself. This specifically tightens the generic
 *    [data-id]/[data-post-id] fallback (used by some Gelbooru-family
 *    mirrors with non-standard markup), which was broad enough to
 *    potentially match a multi-post container.
 *  - Removed the hard-coded "+42" Gelbooru pagination fallback.
 *    calculateNextUrl() now only extrapolates a next page from a
 *    genuinely observed page-size delta; with no observation yet it
 *    reports "no next page" so native pagination is used instead of a
 *    fabricated URL — this was contradicting the "no fabricated
 *    pagination" pagination-safety fix from 1.2.5/1.2.6.
 *
 *  CHANGELOG (1.2.5 → 1.2.6) — Gelbooru-family interaction/settings fixes
 *  ------------------------------------------------------------------------
 *  - Root cause fix: thumbnail "wrapper" detection no longer uses a broad
 *    img.closest('article, span, div, a'). Every adapter now provides an
 *    explicit getThumbWrapper(img), so exactly one element per post is
 *    tagged .be-thumb-wrap — never an unrelated ancestor, and never the
 *    gallery container itself.
 *  - This one fix is what was silently breaking the viewer, hover preview,
 *    favorite/download actions, and grid sizing together: click/hover
 *    delegation used to fall back to a broad selector list that could
 *    match the gallery container as "the thumbnail", and .be-thumb-wrap
 *    could land on the wrong DOM node so grid sizing CSS never reached
 *    the actual grid item.
 *  - Click/hover delegation now only ever recognizes '.be-thumb-wrap' —
 *    no more matching arbitrary article/span/div/a ancestors.
 *  - Hover preview no longer flickers: pointerout is checked against
 *    relatedTarget so it only fires once the pointer truly leaves the
 *    thumbnail, not on every internal child boundary crossing.
 *  - gallery.thumbnailSize, gallery.gridDensity, and gallery.gridGap now
 *    visibly affect the grid immediately, because the wrapper receiving
 *    the sizing CSS is the actual grid item; site-native float/width on
 *    that element is neutralized so our grid governs layout.
 *  - gallery.compactMode now has a real, visible effect (denser square
 *    thumbnails, smaller action buttons) instead of only toggling a class
 *    with no CSS consumer.
 *  - --be-thumbnail-size is now actually set as a CSS custom property
 *    (previously computed in JS but never written to the DOM).
 *  - The viewer/action-button click path now only ever depends on
 *    '.be-thumb-wrap' + the post ID, never on metadata enrichment — so it
 *    keeps working immediately even if the API/HTML enrichment fails.
 *  - Thumbnails inserted by infinite scroll now go through the same
 *    wrapper-detection + action-bar-building path as initial thumbnails
 *    (a cloned node carries no live JS listeners, so the per-thumbnail
 *    hover action bar is rebuilt explicitly rather than assumed to work).
 *  - Preserved the 1.2.5 pagination-safety fixes (idempotent init/setup,
 *    loop detection, native paginator restoration) and the no-overlap
 *    grid fix (min-width/min-height/overflow/aspect-ratio/object-fit).
 * =============================================================================
 */
(function booruEnhancer() {
	'use strict';

	if (window.top !== window.self) return;
	if (window.__BOORU_ENHANCER_LOADED__) return;
	window.__BOORU_ENHANCER_LOADED__ = true;

	const BE = (window.BE = window.BE || {});
	BE.VERSION = '1.2.7';

	/* ============================================================ *
	 *  EVENT BUS
	 * ============================================================ */
	BE.bus = (() => {
		const listeners = new Map();
		return {
			on(evt, fn) {
				if (!listeners.has(evt)) listeners.set(evt, new Set());
				listeners.get(evt).add(fn);
				return () => listeners.get(evt)?.delete(fn);
			},
			once(evt, fn) {
				const off = this.on(evt, (p) => { off(); fn(p); });
				return off;
			},
			emit(evt, payload) {
				const set = listeners.get(evt);
				if (!set) return;
				for (const fn of [...set]) {
					try { fn(payload); } catch (err) { BE.log.error(`[bus:${evt}]`, err); }
				}
			},
		};
	})();

	/* ============================================================ *
	 *  LOGGER
	 * ============================================================ */
	BE.log = (() => {
		const PREFIX = '%c[Booru Enhancer]';
		const STYLE = 'color:#ff8ac6;font-weight:bold';
		const enabled = () => BE.settings?.get('debug.verboseLogging') ?? false;
		return {
			debug: (...a) => enabled() && console.debug(PREFIX, STYLE, ...a),
			  info: (...a) => console.info(PREFIX, STYLE, ...a),
			  warn: (...a) => console.warn(PREFIX, STYLE, ...a),
			  error: (...a) => console.error(PREFIX, STYLE, ...a),
		};
	})();

	/* ============================================================ *
	 *  GM SHIM
	 * ============================================================ */
	const _GM = {
		getValue: (typeof GM_getValue === 'function') ? GM_getValue : (k, d) => GM.getValue(k, d),
 setValue: (typeof GM_setValue === 'function') ? GM_setValue : (k, v) => GM.setValue(k, v),
 deleteValue: (typeof GM_deleteValue === 'function') ? GM_deleteValue : (k) => GM.deleteValue(k),
 listValues: (typeof GM_listValues === 'function') ? GM_listValues : () => GM.listValues(),
 xhr: (typeof GM_xmlhttpRequest === 'function') ? GM_xmlhttpRequest : (opts) => GM.xmlHttpRequest(opts),
 download: (typeof GM_download === 'function') ? GM_download : (opts) => GM.download(opts),
 addStyle: (typeof GM_addStyle === 'function') ? GM_addStyle : (css) => {
	 const s = document.createElement('style');
	 s.textContent = css;
	 document.head.appendChild(s);
	 return s;
 },
 notification: (typeof GM_notification === 'function') ? GM_notification : (opts) => {
	 try { new Notification(opts.title || 'Booru Enhancer', { body: opts.text }); } catch { /* noop */ }
 },
 registerMenuCommand: (typeof GM_registerMenuCommand === 'function') ? GM_registerMenuCommand : () => {},
	};
	BE.gm = _GM;

	/* ============================================================ *
	 *  DOM UTILITIES
	 * ============================================================ */
	BE.dom = {
		qs: (sel, root = document) => root.querySelector(sel),
 qsa: (sel, root = document) => Array.from(root.querySelectorAll(sel)),

 create(tag, attrs = {}, children = []) {
	 const el = document.createElement(tag);
	 for (const [k, v] of Object.entries(attrs)) {
		 if (k === 'class') el.className = v;
		 else if (k === 'html') el.innerHTML = v;
		 else if (k === 'text') el.textContent = v;
		 else if (k === 'dataset') Object.assign(el.dataset, v);
		 else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
		 else if (v !== null && v !== undefined) el.setAttribute(k, v);
	 }
	 for (const c of [].concat(children)) {
		 if (c === null || c === undefined) continue;
		 el.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
	 }
	 return el;
 },

 debounce(fn, ms = 150) {
	 let t;
	 return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
 },

 throttle(fn, ms = 150) {
	 let last = 0, timer = null;
	 return (...args) => {
		 const now = Date.now();
		 if (now - last >= ms) { last = now; fn(...args); }
		 else {
			 clearTimeout(timer);
			 timer = setTimeout(() => { last = Date.now(); fn(...args); }, ms - (now - last));
		 }
	 };
 },

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

 onVisible(el, fn, opts = { rootMargin: '600px 0px' }) {
	 const io = new IntersectionObserver((entries) => {
		 for (const e of entries) if (e.isIntersecting) { fn(el); io.unobserve(el); }
	 }, opts);
	 io.observe(el);
	 return () => io.unobserve(el);
 },

 sanitizeFilename(name, maxLen = 180) {
	 return String(name)
	 .replace(/[/\\?%*:|"<>]/g, '_')
	 .replace(/\s+/g, ' ')
	 .trim()
	 .slice(0, maxLen) || 'untitled';
 },

 formatBytes(bytes) {
	 if (!bytes && bytes !== 0) return '?';
	 const units = ['B', 'KB', 'MB', 'GB'];
	 let i = 0, n = bytes;
	 while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
	 return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
 },
	};

	/* ============================================================ *
	 *  STORAGE
	 * ============================================================ */
	BE.store = (() => {
		const NS = 'be:';
		const cache = new Map();
		let ready = false;
		const readyCallbacks = [];

		function safeParse(raw) {
			if (raw === undefined || raw === null) return { ok: false };
			if (typeof raw !== 'string') return { ok: true, value: raw };
			try { return { ok: true, value: JSON.parse(raw) }; }
			catch { return { ok: false }; }
		}

		async function init() {
			try {
				const keys = await _GM.listValues();
				await Promise.all((keys || []).filter((k) => typeof k === 'string' && k.startsWith(NS)).map(async (k) => {
					let raw;
					try { raw = await _GM.getValue(k); } catch (err) { BE.log.error('store.get failed for', k, err); return; }
					const parsed = safeParse(raw);
					if (parsed.ok) cache.set(k.slice(NS.length), parsed.value);
				}));
			} catch (err) { BE.log.error('store init failed', err); }
			ready = true;
			readyCallbacks.splice(0).forEach((cb) => cb());
		}

		return {
			whenReady(cb) { ready ? cb() : readyCallbacks.push(cb); },
				get isReady() { return ready; },
				get(key, fallback) { return cache.has(key) ? cache.get(key) : fallback; },
				set(key, value) {
					cache.set(key, value);
					Promise.resolve(_GM.setValue(NS + key, JSON.stringify(value))).catch((e) => {
						BE.log.error('store.set failed to persist', key, e);
					});
				},
				delete(key) {
					cache.delete(key);
					Promise.resolve(_GM.deleteValue(NS + key)).catch(() => {});
				},
				keys(prefix = '') { return [...cache.keys()].filter((k) => k.startsWith(prefix)); },
				_init: init,
		};
	})();
	BE.store._init();

	/* ============================================================ *
	 *  SETTINGS SCHEMA
	 * ============================================================ */
	BE.settings = (() => {
		const SCHEMA = {
			'general.enabled': { cat: 'General', type: 'bool', def: true, label: 'Enable Booru Enhancer on this site' },
			'general.theme': { cat: 'General', type: 'select', def: 'dark', choices: ['dark', 'light'], label: 'Theme' },
			'general.accentColor': { cat: 'General', type: 'color', def: '#ff8ac6', label: 'Accent color' },
			'general.toolbarPosition': { cat: 'General', type: 'select', def: 'bottom-right', choices: ['bottom-right', 'bottom-left', 'top-right', 'top-left'], label: 'Toolbar position' },

			'media.mode': { cat: 'Media', type: 'select', def: 'post-page', choices: ['always', 'post-page', 'on-click', 'disabled'], label: 'Load original media' },
			'media.neverUpscale': { cat: 'Media', type: 'bool', def: true, label: 'Never upscale beyond native resolution' },
			'media.hoverPreview': { cat: 'Media', type: 'bool', def: true, label: 'Hover preview on thumbnails' },
			'media.thumbQuality': { cat: 'Media', type: 'select', def: 'sample', choices: ['preview', 'sample', 'original'], label: 'Grid thumbnail quality' },

			'download.filenameTemplate': { cat: 'Downloads', type: 'text', def: '{character} - {artist} ({id})', label: 'Filename template' },
				   'download.maxCharacters': { cat: 'Downloads', type: 'number', def: 3, min: 1, max: 10, label: 'Max characters in filename' },
				   'download.tagDelimiter': { cat: 'Downloads', type: 'text', def: ', ', label: 'Tag delimiter' },
				   'download.retries': { cat: 'Downloads', type: 'number', def: 3, min: 0, max: 10, label: 'Retry attempts on failure' },
				   'download.openMode': { cat: 'Downloads', type: 'select', def: 'new-tab', choices: ['new-tab', 'background-tab', 'popup', 'browser-viewer'], label: '"Open original" behavior' },

				   'viewer.enabled': { cat: 'Viewer', type: 'bool', def: true, label: 'Enable fullscreen viewer' },
				   'viewer.autoplayVideo': { cat: 'Viewer', type: 'bool', def: true, label: 'Autoplay videos/animations' },
				   'viewer.loopVideo': { cat: 'Viewer', type: 'bool', def: true, label: 'Loop videos' },
				   'viewer.muteVideo': { cat: 'Viewer', type: 'bool', def: true, label: 'Start videos muted' },
				   'viewer.rememberVolume': { cat: 'Viewer', type: 'bool', def: true, label: 'Remember volume between videos' },
				   'viewer.fitMode': { cat: 'Viewer', type: 'select', def: 'fit-both', choices: ['fit-both', 'fit-width', 'fit-height', 'original-size'], label: 'Default fit mode' },

				   'gallery.infiniteScroll': { cat: 'Gallery', type: 'bool', def: true, label: 'Infinite scrolling (replaces pagination)' },
				   'gallery.gridDensity': { cat: 'Gallery', type: 'range', def: 0, min: 0, max: 10, label: 'Grid columns (0 = auto-fit by size)' },
				   'gallery.thumbnailSize': { cat: 'Gallery', type: 'range', def: 220, min: 120, max: 500, label: 'Thumbnail size (px)' },
				   'gallery.gridGap': { cat: 'Gallery', type: 'range', def: 8, min: 0, max: 20, label: 'Grid gap (px)' },
				   'gallery.compactMode': { cat: 'Gallery', type: 'bool', def: false, label: 'Compact mode' },

				   'tags.colorize': { cat: 'Tags', type: 'bool', def: true, label: 'Colorize tags by category' },
				   'tags.showCounts': { cat: 'Tags', type: 'bool', def: true, label: 'Show tag post counts' },
				   'tags.collapseThreshold': { cat: 'Tags', type: 'number', def: 25, min: 5, max: 200, label: 'Collapse tag list above N tags' },

				   'filter.blacklist': { cat: 'Filters', type: 'textarea', def: '', label: 'Blacklisted tags (one per line, supports rating:*, -tag)' },
				   'filter.whitelist': { cat: 'Filters', type: 'textarea', def: '', label: 'Whitelist overrides (one per line)' },
				   'filter.hideVideos': { cat: 'Filters', type: 'bool', def: false, label: 'Hide videos' },
				   'filter.hideAnimations': { cat: 'Filters', type: 'bool', def: false, label: 'Hide animated images/GIFs' },
				   'filter.minResolution': { cat: 'Filters', type: 'number', def: 0, min: 0, max: 10000, label: 'Hide posts below width (px), 0 = off' },
				   'filter.hideAIGenerated': { cat: 'Filters', type: 'bool', def: false, label: 'Hide posts tagged ai-generated' },

				   'keys.download': { cat: 'Keybinds', type: 'text', def: 'd', label: 'Download' },
				   'keys.favorite': { cat: 'Keybinds', type: 'text', def: 'f', label: 'Favorite' },
				   'keys.openOriginal': { cat: 'Keybinds', type: 'text', def: 'o', label: 'Open original (new tab)' },
				   'keys.viewOriginal': { cat: 'Keybinds', type: 'text', def: 'v', label: 'Toggle viewer' },
				   'keys.next': { cat: 'Keybinds', type: 'text', def: 'ArrowRight', label: 'Next post' },
				   'keys.prev': { cat: 'Keybinds', type: 'text', def: 'ArrowLeft', label: 'Previous post' },
				   'keys.close': { cat: 'Keybinds', type: 'text', def: 'Escape', label: 'Close viewer' },
				   'keys.playPause': { cat: 'Keybinds', type: 'text', def: ' ', label: 'Play/pause video' },
				   'keys.commandPalette': { cat: 'Keybinds', type: 'text', def: 'k', label: 'Command palette (needs Ctrl)' },

				   'debug.verboseLogging': { cat: 'Debug', type: 'bool', def: false, label: 'Verbose console logging' },
		};

		const values = new Map();
		let loaded = false;

		function keyDefault(key) { return SCHEMA[key]?.def; }

		function load() {
			for (const key of Object.keys(SCHEMA)) {
				const stored = BE.store.get('setting:' + key);
				values.set(key, stored !== undefined ? stored : keyDefault(key));
			}
			loaded = true;
		}

		return {
			SCHEMA,
			get isLoaded() { return loaded; },
				   get(key) {
					   if (!loaded) {
						   BE.log.debug('settings.get() called before load — returning default for', key);
						   return keyDefault(key);
					   }
					   return values.has(key) ? values.get(key) : keyDefault(key);
				   },
				   set(key, val) {
					   values.set(key, val);
					   BE.store.set('setting:' + key, val);
					   BE.bus.emit('settings:changed', { key, val });
				   },
				   reset(key) {
					   const def = keyDefault(key);
					   this.set(key, def);
					   return def;
				   },
				   resetAll() { for (const key of Object.keys(SCHEMA)) this.reset(key); },
				   categories() {
					   const set = [];
					   for (const def of Object.values(SCHEMA)) if (!set.includes(def.cat)) set.push(def.cat);
					   return set;
				   },
				   byCategory(cat) { return Object.entries(SCHEMA).filter(([, d]) => d.cat === cat).map(([k]) => k); },
				   exportJSON() {
					   const out = {};
					   for (const key of Object.keys(SCHEMA)) out[key] = this.get(key);
					   return JSON.stringify({ __booruEnhancerSettings: true, version: BE.VERSION, values: out }, null, 2);
				   },
				   importJSON(json) {
					   try {
						   const parsed = JSON.parse(json);
						   const values_ = parsed.values || parsed;
						   for (const [key, val] of Object.entries(values_)) {
							   if (SCHEMA[key] !== undefined) this.set(key, val);
						   }
						   return true;
					   } catch (err) {
						   BE.log.error('settings import failed', err);
						   return false;
					   }
				   },
				   blacklistTags() {
					   return String(this.get('filter.blacklist')).split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean);
				   },
				   whitelistTags() {
					   return String(this.get('filter.whitelist')).split('\n').map((s) => s.trim().toLowerCase()).filter(Boolean);
				   },
				   _load: load,
		};
	})();

	/* ============================================================ *
	 *  NETWORK
	 * ============================================================ */
	BE.net = {
		request(opts, retries = 0) {
			return new Promise((resolve, reject) => {
				const attempt = (n) => {
					_GM.xhr({
						method: 'GET',
						...opts,
						onload: (res) => {
							if (res.status >= 200 && res.status < 300) {
								resolve(res);
							} else if (res.status === 429) {
								const delay = Math.pow(2, (retries - n + 1)) * 1000;
								BE.log.warn(`[Gallery] 429 Rate limit. Retrying in ${delay/1000}s...`);
								if (n > 0) {
									setTimeout(() => attempt(n - 1), delay);
								} else {
									reject(new Error(`HTTP 429 Rate limit exceeded for ${opts.url}`));
								}
							} else if (n > 0) {
								setTimeout(() => attempt(n - 1), 400 * (retries - n + 1));
							} else {
								reject(new Error(`HTTP ${res.status} for ${opts.url}`));
							}
						},
						onerror: (err) => (n > 0 ? setTimeout(() => attempt(n - 1), 400 * (retries - n + 1)) : reject(err)),
							ontimeout: (err) => (n > 0 ? setTimeout(() => attempt(n - 1), 400 * (retries - n + 1)) : reject(err))
					});
				};
				attempt(retries);
			});
		},
 async json(url, opts = {}) {
	 const res = await this.request({ url, headers: { Accept: 'application/json', ...(opts.headers || {}) }, ...opts }, opts.retries ?? 2);
	 return JSON.parse(res.responseText);
 }
	};

	/* ============================================================ *
	 *  POST MODEL
	 * ============================================================ */
	function emptyPost(overrides = {}) {
		return {
			id: '', originalUrl: '', sampleUrl: '', previewUrl: '', mediaType: 'unknown',
 width: 0, height: 0, fileSize: 0, md5: '', rating: 'unknown', score: 0, favCount: 0,
 artists: [], characters: [], copyrights: [], generalTags: [], metaTags: [], allTags: [],
 source: '', postUrl: location.href, createdAt: '', siteId: '', ...overrides,
		};
	}

	function guessMediaType(url) {
		if (!url) return 'unknown';
		const ext = (url.split('?')[0].split('.').pop() || '').toLowerCase();
		if (['mp4', 'webm', 'mov'].includes(ext)) return 'video';
		if (ext === 'gif') return 'gif';
		if (['jpg', 'jpeg', 'png', 'webp', 'bmp'].includes(ext)) return 'image';
		return 'unknown';
	}

	/* ============================================================ *
	 *  ADAPTER REGISTRY
	 * ============================================================ */
	BE.adapters = { registry: [], active: null };

	BE.core = BE.core || {};
	BE.core.registerAdapter = function registerAdapter(adapter) {
		BE.adapters.registry.push({ priority: 0, ...adapter });
	};

	BE.core.detectAdapter = function detectAdapter() {
		const candidates = BE.adapters.registry
		.filter((a) => a.hostPattern.test(location.hostname))
		.sort((a, b) => b.priority - a.priority);
		return candidates[0] || BE.adapters.registry.find((a) => a.id === 'generic');
	};

	/* ============================================================ *
	 *  PAGINATION HELPER UTILITIES
	 * ============================================================ */

	function safeURL(href, base = location.href) {
		try { return new URL(href, base); } catch { return null; }
	}

	function findFirstNextLink(doc, selectors) {
		for (const sel of selectors) {
			try {
				const link = doc.querySelector(sel);
				if (link && link.href) return link;
			} catch { /* invalid selector for this doc type */ }
		}
		return null;
	}

	function findAdvancingPidLink(doc, paramName, currentVal) {
		const links = Array.from(doc.querySelectorAll(`a[href*="${paramName}="]`));
		const candidates = [];
		for (const a of links) {
			const u = safeURL(a.href);
			if (!u) continue;
			const val = parseInt(u.searchParams.get(paramName) || '0', 10);
			if (!isNaN(val) && val > currentVal) candidates.push({ href: a.href, val });
		}
		if (!candidates.length) return null;
		candidates.sort((a, b) => a.val - b.val);
		return candidates[0].href;
	}

	/* ============================================================ *
	 *  DANBOORU ADAPTER
	 * ============================================================ */
	BE.core.registerAdapter({
		id: 'danbooru',
		hostPattern: /donmai\.us$|atfbooru\.ninja$/,
		priority: 10,
		isPostPage: () => /\/posts\/\d+/.test(location.pathname),
							getPostId: () => (location.pathname.match(/\/posts\/(\d+)/) || [])[1] || null,
							getThumbElements: (root) => BE.dom.qsa('article[id^="post_"] img, .post-preview img', root),
							getThumbPostId: (img) => {
								const art = img.closest('[id^="post_"]') || img.closest('a[href*="/posts/"]');
								const idAttr = art?.id?.match(/post_(\d+)/)?.[1];
								if (idAttr) return idAttr;
								const href = art?.getAttribute?.('href') || img.closest('a')?.getAttribute('href') || '';
								return (href.match(/\/posts\/(\d+)/) || [])[1] || null;
							},
							// Requirement 3: the actual post card, not a generic
							// "closest article/span/div/a" — that would grab the
							// inner <a> (or worse) instead of the real per-post
							// container that CSS grid sizing needs to land on.
							getThumbWrapper: (img) => img.closest('article[id^="post_"], .post-preview') || img.closest('a[href*="/posts/"]') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('#posts-container, .posts-container, #post-list'),
							async fetchPost(id) {
								const data = await BE.net.json(`${location.origin}/posts/${id}.json`);
								return normalizeDanbooru(data);
							},
							async fetchThumbBatch(ids) {
								if (!ids.length) return [];
								const tags = `id:${ids.join(',')}`;
								const data = await BE.net.json(`${location.origin}/posts.json?tags=${encodeURIComponent(tags)}&limit=${ids.length}`);
								return data.map(normalizeDanbooru);
							},
							favoriteSelector: 'a#post-favorite-link, button[data-post-id] .favorite, .post-vote-favorite-count',
							pagination: {
								containerSelectors: ['.paginator', '#paginator', 'nav.pagination', 'section#paginating-nav'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '1';
									return u.searchParams.get('page') || '1';
								},
								getNextUrl(doc) {
									const link = findFirstNextLink(doc, [
										'a[rel="next"]', 'a.next', 'a.next_page',
										'.paginator a[rel="next"]', 'nav.pagination a[rel="next"]',
										'section#paginating-nav a[rel="next"]',
									]);
									return link?.href || null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									const page = parseInt(u.searchParams.get('page') || '1', 10);
									u.searchParams.set('page', String(page + 1));
									return u.toString();
								},
								getPostIdentity(el) {
									const art = el.closest('[id^="post_"], article.post-preview');
									return art?.id?.match(/post_(\d+)/)?.[1] || null;
								},
							},
	});

	function normalizeDanbooru(d) {
		const original = d.file_url || d.large_file_url || '';
		return emptyPost({
			id: String(d.id),
						 originalUrl: original,
						 sampleUrl: d.large_file_url || original,
						 previewUrl: d.preview_file_url || d.large_file_url || original,
						 mediaType: d.is_video ? 'video' : (guessMediaType(original) === 'unknown' ? guessMediaType(d.preview_file_url) : guessMediaType(original)),
						 width: d.image_width || 0,
						 height: d.image_height || 0,
						 fileSize: d.file_size || 0,
						 md5: d.md5 || '',
						 rating: { s: 'safe', q: 'questionable', e: 'explicit', g: 'safe' }[d.rating] || d.rating || 'unknown',
						 score: d.score || 0,
						 favCount: d.fav_count || 0,
						 artists: (d.tag_string_artist || '').split(' ').filter(Boolean),
						 characters: (d.tag_string_character || '').split(' ').filter(Boolean),
						 copyrights: (d.tag_string_copyright || '').split(' ').filter(Boolean),
						 generalTags: (d.tag_string_general || '').split(' ').filter(Boolean),
						 metaTags: (d.tag_string_meta || '').split(' ').filter(Boolean),
						 allTags: (d.tag_string || '').split(' ').filter(Boolean),
						 source: d.source || '',
						 postUrl: `${location.origin}/posts/${d.id}`,
						 createdAt: d.created_at || '',
						 siteId: 'danbooru',
		});
	}

	/* ============================================================ *
	 *  GELBOORU-FAMILY ADAPTER
	 * ============================================================ */
	function gelbooruPostId(el) {
		const a = el.closest('a');
		const href = a?.getAttribute('href') || '';
		const byHref = href.match(/[?&]id=(\d+)/);
		if (byHref) return byHref[1];
		const byAnchorId = a?.id?.match(/^p(\d+)$/);
		if (byAnchorId) return byAnchorId[1];
		const wrapper = el.closest('[data-id], [data-post-id]');
		if (wrapper) return wrapper.dataset.id || wrapper.dataset.postId;
		const byImgId = el.id?.match(/\d+/);
		return byImgId ? byImgId[0] : null;
	}

	// Follow-up review fix: try wrapper candidates in order of specificity
	// and VALIDATE each one before accepting it, instead of trusting the
	// first ancestor that matches any selector in the list. The generic
	// [data-id]/[data-post-id] fallback in particular exists for
	// Gelbooru-family mirror sites with non-standard markup, but is broad
	// enough that it could match a container holding more than one post.
	// A wrapper is only accepted if it holds exactly one thumbnail image
	// and resolves to the SAME post ID as the image itself.
	function gelbooruThumbWrapper(img) {
		const selectors = ['.thumbnail-preview', 'article.thumbnail-preview', 'span.thumb', '.thumb', '[data-id]', '[data-post-id]'];
		const imgId = gelbooruPostId(img);
		for (const sel of selectors) {
			let el;
			try { el = img.closest(sel); } catch { continue; }
			if (!el) continue;
			if (el.querySelectorAll('img').length !== 1) continue; // more than one post's worth of content
			const wrapId = gelbooruPostId(el);
			if (imgId && wrapId && wrapId !== imgId) continue; // wrapper resolves to a different post
			return el;
		}
		return img.closest('a') || img.parentElement;
	}

	let _gelbooruObservedPageSize = null;

	BE.core.registerAdapter({
		id: 'gelbooru-family',
		hostPattern: /gelbooru\.com$|safebooru\.org$|rule34\.xxx$|realbooru\.com$|tbib\.org$|xbooru\.com$|hypnohub\.net$/,
		priority: 10,
		isPostPage: () => {
			const p = new URLSearchParams(location.search);
			return p.get('page') === 'post' && p.get('s') === 'view';
		},
		getPostId: () => new URLSearchParams(location.search).get('id'),
							getThumbElements: (root) => BE.dom.qsa(
								'.thumbnail-preview img, article.thumbnail-preview img, span.thumb img, #post-list img.preview, #post-list-posts img, .thumb img',
								root,
							),
							getThumbPostId: (img) => gelbooruPostId(img),
							// Requirement 3 (+ follow-up review): identify the
							// actual post/card element, not an arbitrary
							// "closest div" — and validate the candidate
							// (exactly one thumbnail image, same resolved post
							// ID as the image) before accepting it, since the
							// [data-id]/[data-post-id] fallback used by some
							// Gelbooru-family mirrors is otherwise too generic
							// to trust blindly.
							getThumbWrapper: (img) => gelbooruThumbWrapper(img),
							getGalleryContainer: (root = document) => root.querySelector('#post-list-posts, #post-list, .content'),
							async fetchPost(id) {
								try {
									const posts = await gelbooruApiFetch([id]);
									if (posts[0]) return posts[0];
								} catch (err) {
									BE.log.warn('gelbooru: dapi fetchPost failed, falling back to HTML parse', err);
								}
								return gelbooruFetchPostHTML(id);
							},
							async fetchThumbBatch(ids) {
								if (!ids.length) return [];
								try {
									const results = await gelbooruApiFetch(ids);
									if (results.length) return results;
								} catch (err) {
									BE.log.warn('gelbooru: dapi batch fetch failed, falling back to HTML parsing', err);
								}
								return gelbooruFetchPostsHTMLBounded(ids, 3);
							},
							favoriteSelector: 'a[href*="s=favorite"], #favorite-button',
							pagination: {
								containerSelectors: ['#paginator', '.pagination', '.pagination-controls'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '0';
									const pid = u.searchParams.get('pid') || '0';
									const tags = u.searchParams.get('tags') || '';
									const s = u.searchParams.get('s') || '';
									return `${tags}|${s}|${pid}`;
								},
								getNextUrl(doc) {
									const nextLink = findFirstNextLink(doc, [
										'#paginator a[alt="next"]', '#paginator a[title="next"]', '#paginator a.next',
										'.pagination a[alt="next"]', '.pagination a[title="next"]', '.pagination a.next',
										'a[alt="next"]', 'a[rel="next"]', 'a.next',
									]);
									if (nextLink) {
										const currentPid = parseInt(this.getPageIdentity(location.href).split('|').pop() || '0', 10) || 0;
										const nextPid = parseInt(this.getPageIdentity(nextLink.href).split('|').pop() || '0', 10) || 0;
										if (nextPid > currentPid && nextPid - currentPid <= 200) {
											_gelbooruObservedPageSize = nextPid - currentPid;
										}
										return nextLink.href;
									}

									const currentPidStr = this.getPageIdentity(location.href).split('|').pop() || '0';
									const currentPid = parseInt(currentPidStr, 10) || 0;
									const advancingHref = findAdvancingPidLink(doc, 'pid', currentPid);
									if (advancingHref) {
										const advPid = parseInt(safeURL(advancingHref)?.searchParams.get('pid') || '0', 10) || 0;
										if (advPid > currentPid && advPid - currentPid <= 200) {
											_gelbooruObservedPageSize = advPid - currentPid;
										}
										return advancingHref;
									}

									return null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									// Follow-up review fix: no more hard-coded "+42"
									// guess. Only extrapolate a next page once we've
									// actually observed a real page-size delta from a
									// genuine next-page link (see getNextUrl above);
									// otherwise report "no next page" so the caller
									// falls back to the site's native pagination
									// instead of fabricating a URL.
									if (!_gelbooruObservedPageSize) return null;
									const currentPid = parseInt(u.searchParams.get('pid') || '0', 10) || 0;
									u.searchParams.set('pid', String(currentPid + _gelbooruObservedPageSize));
									return u.toString();
								},
								getPostIdentity(el) {
									return gelbooruPostId(el);
								},
							},
	});

	let _gelbooruApiHealthy = true;

	async function gelbooruApiFetch(ids) {
		const url = `${location.origin}/index.php?page=dapi&s=post&q=index&json=1&tags=${encodeURIComponent('id:' + ids.join(','))}&limit=${ids.length}`;
		let data;
		try {
			const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } });
			if (!res.ok) throw new Error(`HTTP ${res.status}`);
			data = await res.json();
			_gelbooruApiHealthy = true;
		} catch (fetchErr) {
			BE.log.debug('gelbooru: same-origin fetch failed, trying GM.xhr', fetchErr);
			data = await BE.net.json(url, { retries: 1 });
			_gelbooruApiHealthy = true;
		}
		const list = Array.isArray(data) ? data : (data?.post || []);
		if (!Array.isArray(list) || !list.length) return [];
		return list.map(normalizeGelbooru);
	}

	async function gelbooruFetchPostHTML(id) {
		try {
			const url = `${location.origin}/index.php?page=post&s=view&id=${id}`;
			const res = await BE.net.request({ url }, 1);
			return parseGelbooruPostHTML(res.responseText, id);
		} catch (err) {
			BE.log.warn(`gelbooru: HTML fallback failed for post ${id}`, err);
			return null;
		}
	}

	async function gelbooruFetchPostsHTMLBounded(ids, concurrency = 3) {
		const results = [];
		let i = 0;
		async function worker() {
			while (i < ids.length) {
				const id = ids[i++];
				const post = await gelbooruFetchPostHTML(id);
				if (post) results.push(post);
				await new Promise((r) => setTimeout(r, 150));
			}
		}
		await Promise.all(Array.from({ length: Math.min(concurrency, ids.length) }, worker));
		return results;
	}

	function parseGelbooruPostHTML(html, id) {
		const doc = new DOMParser().parseFromString(html, 'text/html');
		const img = doc.querySelector('#image, img#image');
		const video = doc.querySelector('video source, video#gelcomVideoPlayer source, video');
		const originalLink = doc.querySelector('a.download-link, a[download], li a[href*="/samples/"], li a[href*="/images/"]');
		const original = video?.getAttribute('src') || originalLink?.getAttribute('href') || img?.getAttribute('src') || '';
		const sample = img?.getAttribute('src') || original;
		const tagLinks = Array.from(doc.querySelectorAll('.tag-list li, #tag-sidebar li, li[class*="tag-type-"]'));
		const tags = [];
		const artists = [], characters = [], copyrights = [];
		for (const li of tagLinks) {
			const a = li.querySelector('a[href*="tags="]') || li.querySelector('a');
			const text = (a?.textContent || '').trim().replace(/\s+/g, '_').toLowerCase();
			if (!text) continue;
			tags.push(text);
			const cls = li.className || '';
			if (/tag-type-artist|category-1/.test(cls)) artists.push(text);
			else if (/tag-type-character|category-4/.test(cls)) characters.push(text);
			else if (/tag-type-copyright|category-3/.test(cls)) copyrights.push(text);
		}
		const ratingText = (doc.querySelector('[id*="rating"]')?.textContent || '').toLowerCase();
		const rating = ratingText.includes('explicit') ? 'explicit' : ratingText.includes('question') ? 'questionable' : ratingText.includes('safe') ? 'safe' : 'unknown';
		const dims = (img?.getAttribute('data-original-width') && img?.getAttribute('data-original-height'))
		? { w: Number(img.getAttribute('data-original-width')), h: Number(img.getAttribute('data-original-height')) }
		: { w: img?.naturalWidth || 0, h: img?.naturalHeight || 0 };

		if (!original && !tags.length) return null;

		return emptyPost({
			id: String(id),
						 originalUrl: original,
						 sampleUrl: sample,
						 previewUrl: sample,
						 mediaType: video ? 'video' : guessMediaType(original),
						 width: dims.w,
						 height: dims.h,
						 rating,
						 artists, characters, copyrights,
						 generalTags: tags,
						 allTags: tags,
						 postUrl: `${location.origin}/index.php?page=post&s=view&id=${id}`,
						 siteId: 'gelbooru-family',
		});
	}

	function normalizeGelbooru(d) {
		const original = d.file_url || '';
		return emptyPost({
			id: String(d.id),
						 originalUrl: original,
						 sampleUrl: d.sample_url || original,
						 previewUrl: d.preview_url || d.sample_url || original,
						 mediaType: guessMediaType(original),
						 width: d.width || 0,
						 height: d.height || 0,
						 fileSize: d.file_size || 0,
						 md5: d.md5 || '',
						 rating: d.rating || 'unknown',
						 score: Number(d.score) || 0,
						 favCount: 0,
						 artists: [], characters: [], copyrights: [],
						 generalTags: String(d.tags || '').split(' ').filter(Boolean),
						 metaTags: [],
						 allTags: String(d.tags || '').split(' ').filter(Boolean),
						 source: d.source || '',
						 postUrl: `${location.origin}/index.php?page=post&s=view&id=${d.id}`,
						 createdAt: d.created_at || '',
						 siteId: 'gelbooru-family',
		});
	}

	/* ============================================================ *
	 *  MOEBOORU ADAPTER
	 * ============================================================ */
	BE.core.registerAdapter({
		id: 'moebooru',
		hostPattern: /yande\.re$|konachan\.(com|net)$|lolibooru\.moe$/,
							priority: 10,
							isPostPage: () => /\/post\/show\/\d+/.test(location.pathname),
							getPostId: () => (location.pathname.match(/\/post\/show\/(\d+)/) || [])[1] || null,
							getThumbElements: (root) => BE.dom.qsa('a.thumb img, ul#post-list-posts img.preview, .post-preview img', root),
							getThumbPostId: (img) => {
								const a = img.closest('a');
								const href = a?.getAttribute('href') || '';
								return (href.match(/\/post\/show\/(\d+)/) || [])[1] || null;
							},
							getThumbWrapper: (img) => img.closest('li, .post-preview, a.thumb') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('#post-list-posts, #post-list, .content'),
							async fetchPost(id) {
								const data = await BE.net.json(`${location.origin}/post.json?tags=id:${id}`);
								return data[0] ? normalizeMoebooru(data[0]) : null;
							},
							async fetchThumbBatch(ids) {
								if (!ids.length) return [];
								const tags = `id:${ids.join(',')}`;
								const data = await BE.net.json(`${location.origin}/post.json?tags=${encodeURIComponent(tags)}&limit=${ids.length}`);
								return data.map(normalizeMoebooru);
							},
							favoriteSelector: 'a.favorite-button-fav, #post-vote-fav-link',
							pagination: {
								containerSelectors: ['.pagination', '#paginator', '.pagination-controls'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '1';
									return u.searchParams.get('page') || '1';
								},
								getNextUrl(doc) {
									const link = findFirstNextLink(doc, [
										'a.next_page', 'a[rel="next"]', 'a.next',
										'.pagination a.next_page', '.pagination a[rel="next"]',
									]);
									return link?.href || null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									const page = parseInt(u.searchParams.get('page') || '1', 10);
									u.searchParams.set('page', String(page + 1));
									return u.toString();
								},
								getPostIdentity(el) {
									const a = el.closest('a');
									const href = a?.getAttribute('href') || '';
									return (href.match(/\/post\/show\/(\d+)/) || [])[1] || null;
								},
							},
	});

	function normalizeMoebooru(d) {
		const original = d.file_url || '';
		return emptyPost({
			id: String(d.id),
						 originalUrl: original,
						 sampleUrl: d.sample_url || original,
						 previewUrl: d.preview_url || original,
						 mediaType: guessMediaType(original),
						 width: d.width || 0,
						 height: d.height || 0,
						 fileSize: d.file_size || 0,
						 md5: d.md5 || '',
						 rating: { s: 'safe', q: 'questionable', e: 'explicit' }[d.rating] || 'unknown',
						 score: d.score || 0,
						 favCount: d.fav_count || 0,
						 artists: [], characters: [], copyrights: [],
						 generalTags: String(d.tags || '').split(' ').filter(Boolean),
						 metaTags: [],
						 allTags: String(d.tags || '').split(' ').filter(Boolean),
						 source: d.source || '',
						 postUrl: `${location.origin}/post/show/${d.id}`,
						 createdAt: d.created_at ? new Date(d.created_at * 1000).toISOString() : '',
						 siteId: 'moebooru',
		});
	}

	/* ============================================================ *
	 *  E621 / E926 ADAPTER
	 * ============================================================ */
	BE.core.registerAdapter({
		id: 'e621',
		hostPattern: /e621\.net$|e926\.net$/,
		priority: 10,
		isPostPage: () => /\/posts\/\d+/.test(location.pathname),
							getPostId: () => (location.pathname.match(/\/posts\/(\d+)/) || [])[1] || null,
							getThumbElements: (root) => BE.dom.qsa('article.post-preview img, #posts-container img', root),
							getThumbPostId: (img) => {
								const art = img.closest('article[id^="post_"]');
								return art?.id?.match(/post_(\d+)/)?.[1] || (img.closest('a')?.getAttribute('href')?.match(/\/posts\/(\d+)/) || [])[1] || null;
							},
							getThumbWrapper: (img) => img.closest('article.post-preview, article[id^="post_"]') || img.closest('a') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('#posts-container, .posts-container'),
							async fetchPost(id) {
								const data = await BE.net.json(`${location.origin}/posts/${id}.json`, {
									headers: { 'User-Agent': `BooruEnhancer/${BE.VERSION} (userscript)` },
								});
								return data.post ? normalizeE621(data.post) : null;
							},
							async fetchThumbBatch(ids) {
								if (!ids.length) return [];
								const tags = `id:${ids.join(',')}`;
								const data = await BE.net.json(`${location.origin}/posts.json?tags=${encodeURIComponent(tags)}&limit=${ids.length}`, {
									headers: { 'User-Agent': `BooruEnhancer/${BE.VERSION} (userscript)` },
								});
								return (data.posts || []).map(normalizeE621);
							},
							favoriteSelector: '#add-to-favorites, #remove-from-favorites',
							pagination: {
								containerSelectors: ['#paginator', 'nav.paginator', 'section#paginating-nav'],
								getPageIdentity(url) {
									const u = safeURL(url);
									if (!u) return '1';
									return u.searchParams.get('page') || u.searchParams.get('b') || u.searchParams.get('a') || '1';
								},
								getNextUrl(doc) {
									const link = findFirstNextLink(doc, [
										'a[rel="next"]', 'a.next',
										'#paginator a[rel="next"]', 'nav.paginator a[rel="next"]',
										'section#paginating-nav a[rel="next"]',
									]);
									return link?.href || null;
								},
								calculateNextUrl(currentUrl) {
									const u = safeURL(currentUrl);
									if (!u) return null;
									if (u.searchParams.has('b') || u.searchParams.has('a')) return null;
									const page = parseInt(u.searchParams.get('page') || '1', 10);
									u.searchParams.set('page', String(page + 1));
									return u.toString();
								},
								getPostIdentity(el) {
									const art = el.closest('article[id^="post_"]');
									return art?.id?.match(/post_(\d+)/)?.[1] || null;
								},
							},
	});

	function normalizeE621(p) {
		const original = p.file?.url || '';
		return emptyPost({
			id: String(p.id),
						 originalUrl: original,
						 sampleUrl: p.sample?.url || original,
						 previewUrl: p.preview?.url || original,
						 mediaType: p.file?.ext === 'webm' || p.file?.ext === 'mp4' ? 'video' : (p.file?.ext === 'gif' ? 'gif' : 'image'),
						 width: p.file?.width || 0,
						 height: p.file?.height || 0,
						 fileSize: p.file?.size || 0,
						 md5: p.file?.md5 || '',
						 rating: { s: 'safe', q: 'questionable', e: 'explicit' }[p.rating] || 'unknown',
						 score: p.score?.total ?? 0,
						 favCount: p.fav_count || 0,
						 artists: p.tags?.artist || [],
						 characters: p.tags?.character || [],
						 copyrights: p.tags?.copyright || [],
						 generalTags: p.tags?.general || [],
						 metaTags: p.tags?.meta || [],
						 allTags: Object.values(p.tags || {}).flat(),
						 source: (p.sources || [])[0] || '',
						 postUrl: `${location.origin}/posts/${p.id}`,
						 createdAt: p.created_at || '',
						 siteId: 'e621',
		});
	}

	/* ============================================================ *
	 *  GENERIC ADAPTER
	 * ============================================================ */
	let _genericObservedPageSize = null;

	BE.core.registerAdapter({
		id: 'generic',
		hostPattern: /.*/,
		priority: -1,
		isPostPage: () => !!BE.dom.qs('#image, #main_image, .image-container img, main img.post-image'),
							getPostId: () => (location.pathname.match(/(\d{3,})/) || [])[1] || location.href,
							getThumbElements: (root) => BE.dom.qsa('img.preview, img.thumb, .thumbnail img, .post-thumbnail img', root),
							getThumbPostId: (img) => {
								const a = img.closest('a');
								const href = a?.getAttribute('href') || '';
								return (href.match(/(\d{3,})/) || [])[0] || href || null;
							},
							getThumbWrapper: (img) => img.closest('.thumbnail, .post-thumbnail, a') || img.parentElement,
							getGalleryContainer: (root = document) => root.querySelector('.content, #post-list, body'),
							async fetchPost() {
								const img = BE.dom.qs('#image, #main_image, .image-container img, main img.post-image');
								const video = BE.dom.qs('video source, video');
								const original = video?.src || video?.currentSrc || img?.src || '';
								return emptyPost({
									id: this.getPostId(),
												 originalUrl: original,
												 sampleUrl: original,
												 previewUrl: img?.src || original,
												 mediaType: video ? 'video' : guessMediaType(original),
												 width: img?.naturalWidth || 0,
												 height: img?.naturalHeight || 0,
												 allTags: BE.dom.qsa('a[href*="tags="], .tag-link, li.tag a').map((a) => a.textContent.trim()).filter(Boolean),
												 postUrl: location.href,
												 siteId: 'generic',
								});
							},
							async fetchThumbBatch() { return []; },
							favoriteSelector: 'a[href*="favorite" i], button[class*="favorite" i]',
							pagination: {
								containerSelectors: ['.pagination', '#paginator', '.pagination-controls', 'nav.paginator'],
						 getPageIdentity(url) {
							 const u = safeURL(url);
							 if (!u) return '1';
							 return u.searchParams.get('pid') || u.searchParams.get('page') || '1';
						 },
						 getNextUrl(doc) {
							 // 1. Explicit next-page link is always the most trustworthy signal.
							 const link = findFirstNextLink(doc, [
								 'a[rel="next"]', 'a.next',
								 'a[aria-label*="next" i]', 'a[title*="next" i]',
								 '.pagination a[rel="next"]', '#paginator a[rel="next"]',
							 ]);
							 if (link) {
								 // If we can also observe the pid delta from this genuine link,
								 // remember it so calculateNextUrl() can use real evidence later.
								 const curPidStr = safeURL(location.href)?.searchParams.get('pid');
								 const nextPidStr = safeURL(link.href)?.searchParams.get('pid');
								 if (curPidStr !== undefined && nextPidStr !== undefined) {
									 const curPid = parseInt(curPidStr || '0', 10) || 0;
									 const nextPid = parseInt(nextPidStr || '0', 10) || 0;
									 if (nextPid > curPid && nextPid - curPid <= 500) {
										 _genericObservedPageSize = nextPid - curPid;
									 }
								 }
								 return link.href;
							 }

							 // 2. No explicit link: inspect pagination links for one that
							 // advances the current pid/page, using that as real evidence.
							 const currentPid = parseInt(this.getPageIdentity(location.href), 10) || 0;
							 if (location.href.includes('pid=')) {
								 const adv = findAdvancingPidLink(doc, 'pid', currentPid);
								 if (adv) {
									 const advPid = parseInt(safeURL(adv)?.searchParams.get('pid') || '0', 10) || 0;
									 if (advPid > currentPid && advPid - currentPid <= 500) {
										 _genericObservedPageSize = advPid - currentPid;
									 }
									 return adv;
								 }
							 }

							 const currentPage = parseInt(safeURL(location.href)?.searchParams.get('page') || '1', 10) || 1;
							 const pageLinks = Array.from(doc.querySelectorAll('a[href*="page="]'));
							 const candidates = [];
							 for (const a of pageLinks) {
								 const u = safeURL(a.href);
								 if (!u) continue;
								 const pg = parseInt(u.searchParams.get('page') || '1', 10);
								 if (!isNaN(pg) && pg > currentPage) candidates.push({ href: a.href, pg });
							 }
							 if (candidates.length) {
								 candidates.sort((a, b) => a.pg - b.pg);
								 return candidates[0].href;
							 }
							 return null;
						 },
						 calculateNextUrl(currentUrl) {
							 // Only ever synthesize a next URL when we have real evidence
							 // (an observed pid delta from a genuine link seen earlier).
							 // Never assume a fixed page-size like "+42" — that produced
							 // invalid/looping URLs on sites with a different page size.
							 const u = safeURL(currentUrl);
							 if (!u) return null;
							 if (u.searchParams.has('page')) {
								 const page = parseInt(u.searchParams.get('page'), 10) || 1;
								 u.searchParams.set('page', String(page + 1));
								 return u.toString();
							 }
							 if (u.searchParams.has('pid') && _genericObservedPageSize) {
								 const pid = parseInt(u.searchParams.get('pid'), 10) || 0;
								 u.searchParams.set('pid', String(pid + _genericObservedPageSize));
								 return u.toString();
							 }
							 return null;
						 },
						 getPostIdentity(el) {
							 const a = el.closest('a');
							 const href = a?.getAttribute('href') || '';
							 return (href.match(/(\d{3,})/) || [])[0] || href || null;
						 },
							},
	});

	/* ============================================================ *
	 *  NAMING
	 * ============================================================ */
	BE.naming = {
		build(post) {
			const tpl = BE.settings.get('download.filenameTemplate') || '{id}';
			const delim = BE.settings.get('download.tagDelimiter') || ', ';
			const maxChars = BE.settings.get('download.maxCharacters') || 3;

			const charList = post.characters.length
			? post.characters.slice(0, maxChars).map(prettyTag)
			: ['Unknown Character'];

			const fields = {
				character: charList.join(delim),
 artist: (post.artists.length ? post.artists.map(prettyTag) : ['Unknown Artist']).join(delim),
 copyright: (post.copyrights.length ? post.copyrights.map(prettyTag) : ['Unknown Copyright']).join(delim),
 id: post.id,
 md5: post.md5 || post.id,
 rating: post.rating,
 score: String(post.score),
 resolution: post.width && post.height ? `${post.width}x${post.height}` : '',
 date: post.createdAt ? post.createdAt.slice(0, 10) : '',
			};

			let name = tpl.replace(/\{(\w+)\}/g, (_, key) => (fields[key] !== undefined ? fields[key] : ''));
			name = name.replace(/\s{2,}/g, ' ').replace(/^[\s\-,]+|[\s\-,]+$/g, '');
			if (!name) name = post.id || 'download';
			return BE.dom.sanitizeFilename(name);
		},
 extensionFor(post) {
	 const url = post.originalUrl || post.sampleUrl || '';
	 const m = url.split('?')[0].match(/\.([a-z0-9]+)$/i);
	 return m ? m[1] : (post.mediaType === 'video' ? 'mp4' : 'jpg');
 },
	};
	function prettyTag(tag) {
		return tag.replace(/_/g, ' ').replace(/\s+\(.*?\)$/, '').replace(/\b\w/g, (c) => c.toUpperCase());
	}

	/* ============================================================ *
	 *  TOAST / STATUS NOTIFICATIONS
	 * ============================================================ */
	BE.modules = BE.modules || {};
	BE.modules.toast = (() => {
		let container = null;
		function ensure() {
			if (container) return container;
			container = document.createElement('div');
			container.id = 'be-toast-container';
			container.style.cssText = 'position:fixed;bottom:50px;right:10px;z-index:1000000;display:flex;flex-direction:column;gap:6px;align-items:flex-end;pointer-events:none;';
			document.body.appendChild(container);
			return container;
		}
		function show(text, kind = 'info', ms = 3000) {
			const root = ensure();
			const el = document.createElement('div');
			const colors = { info: '#3b82f6', success: '#22c55e', error: '#ef4444', warn: '#f59e0b' };
			el.textContent = text;
			el.style.cssText = `background:${colors[kind] || colors.info};color:#fff;padding:6px 12px;border-radius:4px;font:13px/1.4 sans-serif;box-shadow:0 2px 6px rgba(0,0,0,.3);max-width:320px;pointer-events:auto;`;
			root.appendChild(el);
			setTimeout(() => { el.style.transition = 'opacity .3s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, ms);
			return el;
		}
		return { show };
	})();

	/* ============================================================ *
	 *  DOWNLOADER
	 * ============================================================ */
	BE.modules.downloader = (() => {
		function browserFallbackDownload(url, filename) {
			try {
				const a = document.createElement('a');
				a.href = url;
				a.download = filename;
				a.rel = 'noopener';
				a.target = '_blank';
				document.body.appendChild(a);
				a.click();
				a.remove();
				return true;
			} catch (err) {
				BE.log.error('[Download] browser fallback failed', err);
				return false;
			}
		}

		async function resolveOriginalUrl(post) {
			if (post?.originalUrl) return post;
			BE.log.debug('[Download] no originalUrl yet, fetching metadata for', post?.id);
			try {
				const fetched = await BE.adapters.active.fetchPost(post.id);
				if (fetched?.originalUrl) return fetched;
			} catch (err) {
				BE.log.warn('[Download] metadata fetch failed', err);
			}
			// Fall back to whatever the post page itself links to as "original"
			try {
				if (typeof BE.adapters.active.getOriginalUrl === 'function') {
					const url = BE.adapters.active.getOriginalUrl();
					if (url) return { ...post, originalUrl: url };
				}
			} catch { /* ignore */ }
			return post;
		}

		async function downloadPost(post) {
			if (!post) {
				BE.log.warn('[Download] no post supplied');
				BE.modules.toast.show('Download failed: nothing to download', 'error');
				return false;
			}

			BE.modules.toast.show('Preparing download…', 'info', 1500);

			let resolved = post;
			if (!resolved.originalUrl) {
				resolved = await resolveOriginalUrl(post);
			}

			if (!resolved.originalUrl) {
				BE.log.warn('[Download] failed: no original URL could be resolved for', post.id);
				BE.modules.toast.show('Download failed: could not find original media URL', 'error');
				return false;
			}

			const filename = BE.naming.build(resolved) + '.' + BE.naming.extensionFor(resolved);
			BE.log.debug('[Download] starting', resolved.originalUrl, '->', filename);
			BE.modules.toast.show('Downloading…', 'info', 1500);

			const retries = BE.settings.get('download.retries') ?? 3;

			return new Promise((resolvePromise) => {
				let settled = false;
				const finishOk = () => {
					if (settled) return;
					settled = true;
					BE.log.debug('[Download] complete', filename);
					BE.modules.toast.show(`Download complete: ${filename}`, 'success');
					resolvePromise(true);
				};
				const finishFail = (err) => {
					if (settled) return;
					settled = true;
					BE.log.error('[Download] GM_download failed, trying browser fallback', err);
					const ok = browserFallbackDownload(resolved.originalUrl, filename);
					if (ok) {
						BE.modules.toast.show(`Download started via browser: ${filename}`, 'success');
					} else {
						BE.modules.toast.show('Download failed', 'error');
					}
					resolvePromise(ok);
				};

				try {
					const maybePromise = _GM.download({
						url: resolved.originalUrl,
						name: filename,
						saveAs: false,
						onload: finishOk,
						onerror: finishFail,
						ontimeout: finishFail,
					});
					// Some GM.download implementations return a Promise instead of using callbacks.
					if (maybePromise && typeof maybePromise.then === 'function') {
						maybePromise.then(finishOk).catch(finishFail);
					}
					// Safety net: if neither callback nor promise resolves within a
					// reasonable time, fall back to a normal browser download.
					setTimeout(() => { if (!settled) finishFail(new Error('GM_download timed out')); }, 8000);
				} catch (err) {
					finishFail(err);
				}
			});
		}

		return { downloadPost, browserFallbackDownload, resolveOriginalUrl };
	})();

	/* ============================================================ *
	 *  FAVORITES
	 * ============================================================ */
	BE.modules.favorites = (() => {
		function supported() {
			return !!BE.adapters.active?.favoriteSelector;
		}

		function findFavoriteControl(root = document) {
			const sel = BE.adapters.active?.favoriteSelector;
			if (!sel) return null;
			try { return root.querySelector(sel); } catch { return null; }
		}

		// Uses the site's own logged-in favorite control rather than a
		// separate account/API — we simply trigger the real button and
		// watch the DOM to confirm the site accepted it.
		async function toggle(post) {
			if (!supported()) {
				BE.log.warn('[Favorites] not supported on this adapter');
				BE.modules.toast.show('Favoriting is not supported on this site', 'warn');
				return false;
			}

			let control = findFavoriteControl();

			// On gallery/viewer contexts the real control lives on the post
			// page, which we may not be on. Fetch it in the background.
			if (!control && post?.postUrl && post.postUrl !== location.href) {
				try {
					const res = await BE.net.request({ url: post.postUrl }, 1);
					const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
					const remoteControl = findFavoriteControl(doc);
					if (remoteControl?.href) {
						// Re-request the actual favorite link so the session cookie applies.
						await BE.net.request({ url: remoteControl.href }, 1);
						BE.modules.toast.show('Favorited', 'success');
						return true;
					}
				} catch (err) {
					BE.log.error('[Favorites] remote favorite failed', err);
					BE.modules.toast.show('Favorite failed', 'error');
					return false;
				}
			}

			if (!control) {
				BE.log.warn('[Favorites] control not found on page');
				BE.modules.toast.show('Favorite control not found on this page', 'warn');
				return false;
			}

			try {
				const before = control.outerHTML;
				control.click();
				// Give the site's own JS/network request a moment to run, then
				// check whether anything actually changed before declaring success.
				await new Promise((r) => setTimeout(r, 400));
				const after = findFavoriteControl()?.outerHTML;
				if (after !== undefined && after !== before) {
					BE.modules.toast.show('Favorited', 'success');
					return true;
				}
				// Some sites don't change markup; treat click as best-effort success.
				BE.modules.toast.show('Favorite toggled', 'success');
				return true;
			} catch (err) {
				BE.log.error('[Favorites] click failed', err);
				BE.modules.toast.show('Favorite failed', 'error');
				return false;
			}
		}

		return { supported, toggle };
	})();

	/* ============================================================ *
	 *  HOVER PREVIEW
	 * ============================================================ */
	BE.modules.hover = (() => {
		let hoverEl = null;

		function init() {
			hoverEl = document.createElement('div');
			hoverEl.id = 'be-hover-preview';
			hoverEl.style.cssText = 'position:fixed; pointer-events:none; z-index:999999; display:none; max-width:400px; max-height:400px; border:2px solid var(--be-accent, #ff8ac6); background:#000;';
			document.body.appendChild(hoverEl);
		}

		function show(img) {
			if (!hoverEl) init();
			hoverEl.innerHTML = '';
			const preview = new Image();
			preview.src = img.src;
			preview.style.cssText = 'width:100%; height:100%; object-fit:contain;';
			hoverEl.appendChild(preview);

			const rect = img.getBoundingClientRect();
			let left = rect.right + 10;
			if (left + 400 > window.innerWidth) left = rect.left - 410;
			hoverEl.style.left = `${left}px`;
			hoverEl.style.top = `${rect.top}px`;
			hoverEl.style.display = 'block';
		}

		function hide() {
			if (hoverEl) hoverEl.style.display = 'none';
		}

		return { show, hide };
	})();

	/* ============================================================ *
	 *  VIEWER
	 * ============================================================ */
	BE.modules.viewer = (() => {
		let overlay = null;
		let stage = null;
		let mediaEl = null;
		let statusEl = null;
		let currentPost = null;
		let onNext = null;
		let onPrev = null;

		let zoom = 1;
		let rotation = 0;
		let flipH = false;
		let flipV = false;
		let panX = 0;
		let panY = 0;
		let dragging = false;
		let dragStart = { x: 0, y: 0 };

		function init() {
			overlay = BE.dom.create('div', { id: 'be-viewer-overlay' });
			overlay.style.cssText = 'position:fixed;inset:0;width:100vw;height:100vh;background:rgba(0,0,0,0.92);z-index:999998;display:none;align-items:center;justify-content:center;flex-direction:column;user-select:none;';

			stage = BE.dom.create('div', { class: 'be-viewer-stage' });
			stage.style.cssText = 'position:relative;width:100%;flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;';
			overlay.appendChild(stage);

			const bar = BE.dom.create('div', { class: 'be-viewer-toolbar' });
			bar.style.cssText = 'display:flex;gap:6px;padding:8px;background:rgba(0,0,0,0.6);flex-wrap:wrap;justify-content:center;';
			const mkBtn = (label, title, fn) => {
				const b = document.createElement('button');
				b.textContent = label;
				b.title = title;
				b.className = 'be-viewer-btn';
				b.style.cssText = 'background:var(--be-accent,#ff8ac6);color:#fff;border:none;padding:6px 10px;border-radius:4px;cursor:pointer;font-size:13px;';
				b.addEventListener('click', (e) => { e.stopPropagation(); fn(); });
				bar.appendChild(b);
				return b;
			};
			mkBtn('◀', 'Previous (←)', () => onPrev && onPrev());
			mkBtn('▶', 'Next (→)', () => onNext && onNext());
			mkBtn('−', 'Zoom out', () => applyZoom(-0.25));
			mkBtn('Fit', 'Reset zoom/pan', resetTransform);
			mkBtn('+', 'Zoom in', () => applyZoom(0.25));
			mkBtn('1:1', 'Original size', () => setZoomAbs(1));
			mkBtn('⟲', 'Rotate left', () => { rotation -= 90; render(); });
			mkBtn('⟳', 'Rotate right', () => { rotation += 90; render(); });
			mkBtn('⇋', 'Flip horizontal', () => { flipH = !flipH; render(); });
			mkBtn('⇅', 'Flip vertical', () => { flipV = !flipV; render(); });
			mkBtn('⭳', 'Download (d)', () => currentPost && BE.modules.downloader.downloadPost(currentPost));
			mkBtn('★', 'Favorite (f)', () => currentPost && BE.modules.favorites.toggle(currentPost));
			mkBtn('⤢', 'Open original (o)', () => currentPost && openOriginalInNewTab(currentPost));
			mkBtn('✕', 'Close (Esc)', close);
			overlay.appendChild(bar);

			statusEl = BE.dom.create('div', { class: 'be-viewer-status' });
			statusEl.style.cssText = 'position:absolute;top:8px;left:12px;color:#fff;font:12px/1.4 sans-serif;text-shadow:0 1px 2px rgba(0,0,0,.8);';
			overlay.appendChild(statusEl);

			overlay.addEventListener('click', (e) => { if (e.target === overlay || e.target === stage) close(); });
			overlay.addEventListener('wheel', (e) => {
				if (overlay.style.display !== 'flex') return;
				e.preventDefault();
				applyZoom(e.deltaY < 0 ? 0.15 : -0.15);
			}, { passive: false });

			document.addEventListener('keydown', onKeydown);
			document.body.appendChild(overlay);
		}

		function openOriginalInNewTab(post) {
			const url = post.originalUrl || post.sampleUrl || post.previewUrl;
			if (url) window.open(url, '_blank', 'noopener');
		}

		function onKeydown(e) {
			if (!overlay || overlay.style.display !== 'flex') return;
			const keys = {
				[BE.settings.get('keys.close') || 'Escape']: close,
						 [BE.settings.get('keys.next') || 'ArrowRight']: () => onNext && onNext(),
						 [BE.settings.get('keys.prev') || 'ArrowLeft']: () => onPrev && onPrev(),
						 [BE.settings.get('keys.download') || 'd']: () => currentPost && BE.modules.downloader.downloadPost(currentPost),
						 [BE.settings.get('keys.favorite') || 'f']: () => currentPost && BE.modules.favorites.toggle(currentPost),
						 [BE.settings.get('keys.openOriginal') || 'o']: () => currentPost && openOriginalInNewTab(currentPost),
						 [BE.settings.get('keys.playPause') || ' ']: () => togglePlayPause(),
			};
			const fn = keys[e.key];
			if (fn) { e.preventDefault(); fn(); }
		}

		function togglePlayPause() {
			if (mediaEl && mediaEl.tagName === 'VIDEO') {
				mediaEl.paused ? mediaEl.play() : mediaEl.pause();
			}
		}

		function resetTransform() {
			zoom = 1; rotation = 0; flipH = false; flipV = false; panX = 0; panY = 0;
			render();
		}

		function applyZoom(delta) {
			setZoomAbs(Math.min(8, Math.max(0.1, zoom + delta)));
		}

		function setZoomAbs(z) {
			zoom = z;
			render();
		}

		function render() {
			if (!mediaEl) return;
			mediaEl.style.transform =
			`translate(${panX}px, ${panY}px) scale(${zoom}) rotate(${rotation}deg) scaleX(${flipH ? -1 : 1}) scaleY(${flipV ? -1 : 1})`;
		}

		function setupDrag(el) {
			el.addEventListener('pointerdown', (e) => {
				if (zoom <= 1) return;
				dragging = true;
				dragStart = { x: e.clientX - panX, y: e.clientY - panY };
				el.setPointerCapture(e.pointerId);
			});
			el.addEventListener('pointermove', (e) => {
				if (!dragging) return;
				panX = e.clientX - dragStart.x;
				panY = e.clientY - dragStart.y;
				render();
			});
			el.addEventListener('pointerup', () => { dragging = false; });
			el.addEventListener('pointercancel', () => { dragging = false; });
		}

		function buildMedia(post) {
			const isVideo = post.mediaType === 'video';
			const el = document.createElement(isVideo ? 'video' : 'img');
			el.style.cssText = 'max-width:90vw;max-height:75vh;object-fit:contain;cursor:grab;touch-action:none;';
			if (isVideo) {
				el.src = post.sampleUrl || post.previewUrl || post.originalUrl;
				el.autoplay = BE.settings.get('viewer.autoplayVideo');
				el.loop = BE.settings.get('viewer.loopVideo');
				el.muted = BE.settings.get('viewer.muteVideo');
				el.controls = true;
				if (BE.settings.get('viewer.rememberVolume')) {
					const vol = BE.store.get('viewer:volume', 1);
					el.volume = vol;
					el.addEventListener('volumechange', () => BE.store.set('viewer:volume', el.volume));
				}
			} else {
				el.src = post.sampleUrl || post.previewUrl || post.originalUrl;
				el.decoding = 'async';
			}
			setupDrag(el);
			return el;
		}

		function open(post, navigation = {}) {
			if (!overlay) init();
			currentPost = post;
			onNext = navigation.next || null;
			onPrev = navigation.prev || null;
			resetTransform();

			stage.innerHTML = '';
			mediaEl = buildMedia(post);
			stage.appendChild(mediaEl);
			statusEl.textContent = `#${post.id || '?'}${post.width && post.height ? ` · ${post.width}×${post.height}` : ''}`;

			overlay.style.display = 'flex';
			BE.bus.emit('viewer:open', post);
		}

		function updatePost(post) {
			if (!currentPost || currentPost.id !== post.id) return;
			currentPost = post;
			if (mediaEl && post.originalUrl && mediaEl.src !== post.originalUrl) {
				mediaEl.src = post.originalUrl;
			}
			statusEl.textContent = `#${post.id || '?'}${post.width && post.height ? ` · ${post.width}×${post.height}` : ''}`;
		}

		function close() {
			if (overlay) overlay.style.display = 'none';
			if (mediaEl && mediaEl.tagName === 'VIDEO') { try { mediaEl.pause(); } catch { /* noop */ } }
			currentPost = null;
			onNext = null;
			onPrev = null;
		}

		function isOpen() { return !!overlay && overlay.style.display === 'flex'; }

		return { open, updatePost, close, isOpen, get currentPost() { return currentPost; } };
	})();

	/* ============================================================ *
	 *  GALLERY MODULE
	 * ============================================================ */
	BE.modules.gallery = (() => {
		let galleryContainer = null;
		let scrollObserver = null;
		let sentinel = null;
		let nextPageUrl = null;
		let loadedPostIds = new Set();
		let enrichedPostIds = new Set();
		const postCache = new Map();
		let state = 'IDLE';
		let retryTimer = null;
		let paginatorEl = null;
		let paginatorHiddenByUs = false;
		let visitedPageIdentities = new Set();
		let settingsListenerAttached = false;

		function seedLoadedPostIds(root) {
			// Requirement 2: loadedPostIds must represent every post already
			// present in the gallery *before* infinite scroll can insert more.
			// No network requests — purely a DOM scan using the active adapter.
			let seeded = 0;
			for (const img of BE.adapters.active.getThumbElements(root)) {
				const postId = BE.adapters.active.getThumbPostId(img);
				if (!postId) continue;
				const id = String(postId);
				if (!loadedPostIds.has(id)) {
					loadedPostIds.add(id);
					seeded++;
				}
			}
			BE.log.debug(`[Gallery] seeded loadedPostIds with ${seeded} existing post(s), total ${loadedPostIds.size}`);
		}

		function resetPageScopedState() {
			// Requirement 8/11: called only when a genuinely NEW gallery/page
			// context is initialized (real SPA navigation / container swap) —
			// never on unrelated DOM mutations of the same gallery.
			loadedPostIds = new Set();
			enrichedPostIds = new Set();
			visitedPageIdentities = new Set();
			nextPageUrl = null;
			state = 'IDLE';
			clearTimeout(retryTimer);
			paginatorHiddenByUs = false;
		}

		function init(container) {
			const isNewContainer = galleryContainer !== container;

			if (isNewContainer) {
				// Requirement 8: disconnect anything tied to the OLD container
				// before adopting the new one.
				if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
				if (sentinel) { sentinel.remove(); sentinel = null; }
				resetPageScopedState();
				restorePaginatorVisibility();
				paginatorEl = null;
			}

			galleryContainer = container;

			// Requirement 7: gallery init() must be idempotent — a marker on
			// the container prevents re-adding delegated listeners or redoing
			// work that's already in place for THIS container.
			if (galleryContainer.dataset.beGalleryInit === '1') {
				if (isNewContainer) {
					// Shouldn't normally happen (marker would only exist on an
					// already-adopted container), but guard anyway.
					seedLoadedPostIds(galleryContainer);
				}
				applyGridSettings();
				return;
			}
			galleryContainer.dataset.beGalleryInit = '1';
			galleryContainer.classList.add('be-gallery-grid');

			// Event Delegation (attached exactly once per container).
			// Requirement 4 / follow-up review: bubble phase, not capture.
			// Capture-phase interception ran our logic before the site's own
			// listeners on the thumbnail/link ever saw the event, which is
			// more aggressive than necessary and risks interfering with
			// Gelbooru's own click handling. Bubble phase still lets
			// preventDefault() stop the default navigation (the browser
			// only commits the default action after the whole dispatch,
			// capture + target + bubble, completes) — we just no longer
			// jump the queue ahead of the site's own handlers.
			galleryContainer.addEventListener('click', onGalleryClick);
			galleryContainer.addEventListener('pointerover', onGalleryHover);
			galleryContainer.addEventListener('pointerout', onGalleryHoverEnd);

			applyGridSettings();

			if (!settingsListenerAttached) {
				settingsListenerAttached = true;
				BE.bus.on('settings:changed', ({ key }) => {
					if (key.startsWith('gallery.') || key === 'general.theme' || key === 'general.accentColor') {
						applyGridSettings();
					}
					if (key === 'gallery.infiniteScroll') {
						onInfiniteScrollSettingChanged(BE.settings.get('gallery.infiniteScroll'));
					}
				});
			}

			enhanceThumbnails(galleryContainer);
			seedLoadedPostIds(galleryContainer);
		}

		function onInfiniteScrollSettingChanged(enabled) {
			if (!galleryContainer) return;
			if (enabled) {
				setupInfiniteScroll();
			} else {
				if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
				if (sentinel) { sentinel.remove(); sentinel = null; }
				restorePaginatorVisibility();
			}
		}

		function applyGridSettings() {
			if (!galleryContainer) return;
			const cols = BE.settings.get('gallery.gridDensity') || 0;
			const thumbSize = BE.settings.get('gallery.thumbnailSize') || 220;
			const gap = BE.settings.get('gallery.gridGap') ?? 8;
			const compact = !!BE.settings.get('gallery.compactMode');

			// Requirement 20: schema stores these as numbers already (the
			// settings panel does Number(input.value) for range/number
			// fields), but coerce defensively so a stale/corrupt stored
			// string value can never become "300pxpx" below.
			const thumbSizePx = Number(thumbSize) || 220;
			const gapPx = Number(gap) || 0;
			const colsNum = Number(cols) || 0;

			// Requirement 12: --be-thumbnail-size is an actual consumed
			// custom property now, not just a value baked into a template
			// string — other rules (e.g. compact mode) can reference it too.
			galleryContainer.style.setProperty('--be-thumbnail-size', `${thumbSizePx}px`);

			if (colsNum > 0) {
				galleryContainer.style.setProperty('--be-grid-template', `repeat(${colsNum}, minmax(0, 1fr))`);
			} else {
				galleryContainer.style.setProperty('--be-grid-template', `repeat(auto-fill, minmax(var(--be-thumbnail-size), 1fr))`);
			}
			galleryContainer.style.setProperty('--be-grid-gap', `${gapPx}px`);
			galleryContainer.classList.toggle('be-compact-mode', compact);

			BE.log.debug(`[Gallery] Grid: ${colsNum > 0 ? colsNum + ' columns' : 'auto'}, ${thumbSizePx}px, ${gapPx}px gap${compact ? ', compact' : ''}`);
		}

		function orderedPostIds() {
			if (!galleryContainer) return [];
			return BE.adapters.active.getThumbElements(galleryContainer)
			.map((img) => img.dataset.bePostId || BE.adapters.active.getThumbPostId(img))
			.filter(Boolean);
		}

		function openViewerForThumb(img, thumb) {
			const postId = img.dataset.bePostId || BE.adapters.active.getThumbPostId(img);
			if (!postId) return;

			const previewUrl = img.src || img.dataset.src;
			const postUrl = thumb.closest('a')?.href || img.closest('a')?.href || location.href;

			const minimalPost = emptyPost({
				id: postId,
				previewUrl,
				sampleUrl: previewUrl,
				postUrl,
			});

			const ids = orderedPostIds();
			const navigateBy = (delta) => {
				const idx = ids.indexOf(postId);
				const nextId = idx === -1 ? null : ids[idx + delta];
				if (!nextId) return;
				const nextImg = BE.dom.qs(`img[data-be-post-id="${CSS.escape(nextId)}"]`, galleryContainer)
				|| BE.dom.qs(`img[data-be-post-id="${CSS.escape(nextId)}"]`, document);
				if (nextImg) openViewerForThumb(nextImg, nextImg.closest('.be-thumb-wrap') || nextImg);
			};

				BE.modules.viewer.open(minimalPost, {
					next: () => navigateBy(1),
									   prev: () => navigateBy(-1),
				});

				enrichSinglePost(postId).then((fullPost) => {
					if (fullPost) BE.modules.viewer.updatePost(fullPost);
				}).catch((err) => BE.log.error('enrichment failed', err));
		}

		function buildThumbActions(wrap, img) {
			if (wrap.querySelector('.be-thumb-actions')) return;
			const bar = BE.dom.create('div', { class: 'be-thumb-actions' });
			bar.style.cssText = 'position:absolute;top:4px;right:4px;display:flex;gap:3px;opacity:0;transition:opacity .15s;z-index:5;';
			const mk = (label, title, action) => {
				const b = document.createElement('button');
				b.textContent = label;
				b.title = title;
				b.dataset.beAction = action;
				b.className = 'be-thumb-action-btn';
				b.style.cssText = 'background:rgba(0,0,0,.65);color:#fff;border:none;border-radius:3px;padding:2px 5px;font-size:11px;cursor:pointer;line-height:1.4;';
				bar.appendChild(b);
			};
			mk('👁', 'Open viewer', 'viewer');
			mk('⭳', 'Download', 'download');
			mk('⤢', 'Open original', 'open');
			if (BE.modules.favorites.supported()) mk('★', 'Favorite', 'favorite');
			wrap.appendChild(bar);
			wrap.addEventListener('pointerenter', () => { bar.style.opacity = '1'; });
			wrap.addEventListener('pointerleave', () => { bar.style.opacity = '0'; });
		}

		// Requirement 3 / 29: resolve the actual per-post wrapper for a
		// thumbnail <img> through the active adapter's getThumbWrapper (each
		// adapter now provides one). This replaces the old
		// img.closest('article, span, div, a') strategy, which was the
		// common root cause behind several independent-looking symptoms:
		// grabbing the wrong (too broad, or even the gallery-container-
		// level) ancestor meant the sizing/no-overlap CSS landed on the
		// wrong element, and click/hover delegation could end up treating
		// "the whole gallery" as a single thumbnail.
		function getWrapperForImg(img) {
			const adapter = BE.adapters.active;
			let wrap = null;
			if (adapter && typeof adapter.getThumbWrapper === 'function') {
				try { wrap = adapter.getThumbWrapper(img); } catch (err) {
					BE.log.debug('[Gallery] getThumbWrapper threw, falling back', err);
				}
			}
			if (!wrap) wrap = img.closest('a') || img.parentElement;
			// Safety net: never accept the gallery container itself (or an
			// ancestor of it) as "a thumbnail wrapper" — that would make a
			// single wrapper swallow the entire gallery.
			if (!wrap || wrap === galleryContainer || (galleryContainer && wrap.contains(galleryContainer))) {
				return null;
			}
			return wrap;
		}

		// Single helper for enhancing one thumbnail — used for both the
		// initial gallery render and dynamically-inserted (infinite scroll)
		// thumbnails, per Requirement 17, so the two paths can never drift
		// out of sync with each other.
		function enhanceThumbnail(img) {
			const wrap = getWrapperForImg(img);
			if (wrap) {
				wrap.classList.add('be-thumb-wrap');
				if (getComputedStyle(wrap).position === 'static') wrap.style.position = 'relative';
				buildThumbActions(wrap, img);
			}
			img.classList.add('be-thumb-img');

			const postId = BE.adapters.active.getThumbPostId(img);
			if (postId) {
				img.dataset.bePostId = postId;
			}
			return wrap;
		}

		function enhanceThumbnails(root) {
			const thumbs = BE.adapters.active.getThumbElements(root);
			BE.log.debug(`[Gallery] Adapter: ${BE.adapters.active?.id}`);
			BE.log.debug(`[Gallery] Thumbnails: ${thumbs.length}`);
			for (const img of thumbs) enhanceThumbnail(img);
		}

		async function handleThumbAction(action, img, thumb) {
			const postId = img.dataset.bePostId || BE.adapters.active.getThumbPostId(img);
			if (!postId) return;
			const previewUrl = img.src || img.dataset.src;
			const postUrl = thumb.closest('a')?.href || img.closest('a')?.href || location.href;
			let post = emptyPost({ id: postId, previewUrl, sampleUrl: previewUrl, postUrl });

			if (action === 'viewer') {
				openViewerForThumb(img, thumb);
				return;
			}

			// download/open/favorite need real metadata (original URL) first.
			try {
				const full = await enrichSinglePost(postId);
				if (full) post = full;
			} catch (err) {
				BE.log.warn('[Gallery] could not enrich post before action', action, err);
			}

			if (action === 'download') {
				BE.modules.downloader.downloadPost(post);
			} else if (action === 'open') {
				const url = post.originalUrl || post.sampleUrl || post.previewUrl;
				if (url) window.open(url, '_blank', 'noopener');
			} else if (action === 'favorite') {
				BE.modules.favorites.toggle(post);
			}
		}

		function onGalleryClick(e) {
			// Per-thumbnail action buttons (download/open/favorite/viewer overlay).
			const actionBtn = e.target.closest('[data-be-action]');
			if (actionBtn) {
				const wrap = actionBtn.closest('.be-thumb-wrap');
				const img = wrap?.querySelector('img');
				if (img) {
					e.preventDefault();
					e.stopPropagation();
					BE.log.debug(`[Gallery] action button: ${actionBtn.dataset.beAction}`);
					handleThumbAction(actionBtn.dataset.beAction, img, wrap);
				}
				return;
			}

			if (!BE.settings.get('viewer.enabled')) return;

			// Never hijack modifier-key clicks or middle-clicks — let the
			// browser/site handle "open in new tab", "open in background", etc.
			if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;

			// Requirement 3/29: only ever recognize an element we've
			// explicitly marked as a thumbnail wrapper. The old fallback
			// list (article, span.thumb, a.thumb, .post-preview) could
			// match an unrelated ancestor — including, in the worst case,
			// the gallery container itself — and silently open the viewer
			// for the wrong post (or make clicks appear to do nothing).
			const thumb = e.target.closest('.be-thumb-wrap');
			if (!thumb) return;

			const img = thumb.matches('img') ? thumb : (thumb.querySelector('.be-thumb-img') || thumb.querySelector('img'));
			if (!img) return;

			e.preventDefault();
			e.stopPropagation();
			BE.log.debug(`[Gallery] Viewer opening post #${img.dataset.bePostId || BE.adapters.active.getThumbPostId(img)}`);
			openViewerForThumb(img, thumb);
		}

		function onGalleryHover(e) {
			if (!BE.settings.get('media.hoverPreview')) return;
			const thumb = e.target.closest('.be-thumb-wrap');
			if (!thumb) return;
			// pointerover bubbles for every child element boundary inside
			// the thumbnail (image, action bar, etc). Only (re)trigger the
			// preview the first time we enter THIS wrapper, not on every
			// bubble — this is what was causing the reported flicker.
			if (thumb.dataset.beHovering === '1') return;
			thumb.dataset.beHovering = '1';
			const img = thumb.matches('img') ? thumb : (thumb.querySelector('.be-thumb-img') || thumb.querySelector('img'));
			if (img) BE.modules.hover.show(img);
		}

		function onGalleryHoverEnd(e) {
			if (!BE.settings.get('media.hoverPreview')) return;
			const thumb = e.target.closest('.be-thumb-wrap');
			if (!thumb) return;
			// Only actually hide once the pointer has left the wrapper
			// entirely — relatedTarget still inside it means this pointerout
			// was just an internal child boundary crossing.
			if (thumb.contains(e.relatedTarget)) return;
			delete thumb.dataset.beHovering;
			BE.modules.hover.hide();
		}

		async function enrichSinglePost(postId) {
			if (postCache.has(postId)) return postCache.get(postId);
			try {
				const post = await BE.adapters.active.fetchPost(postId);
				if (post) postCache.set(postId, post);
				return post;
			} catch (err) {
				BE.log.debug('[Gallery] enrichSinglePost failed for', postId, err);
				return null;
			}
		}

		// Background metadata enrichment: batch-fetch original-media URLs for
		// visible thumbnails so download/viewer/favorite don't have to make a
		// per-click network round-trip afterwards. Never blocks initial render.
		async function enrichThumbnails(root = galleryContainer) {
			if (!root || !BE.adapters.active || typeof BE.adapters.active.fetchThumbBatch !== 'function') return;

			const ids = BE.adapters.active.getThumbElements(root)
			.map((img) => img.dataset.bePostId || BE.adapters.active.getThumbPostId(img))
			.filter((id) => id && !enrichedPostIds.has(id));
			if (!ids.length) return;

			const CHUNK = 40;
			for (let i = 0; i < ids.length; i += CHUNK) {
				const chunk = ids.slice(i, i + CHUNK);
				chunk.forEach((id) => enrichedPostIds.add(id));
				try {
					const posts = await BE.adapters.active.fetchThumbBatch(chunk);
					for (const post of posts || []) {
						if (!post?.id) continue;
						postCache.set(String(post.id), post);
						const img = BE.dom.qs(`img[data-be-post-id="${CSS.escape(String(post.id))}"]`, root);
						if (img && post.originalUrl) img.dataset.beOriginalUrl = post.originalUrl;
					}
					BE.log.debug(`[Gallery] enriched ${posts?.length || 0}/${chunk.length} thumbnails`);
				} catch (err) {
					BE.log.debug('[Gallery] batch enrichment failed for chunk', err);
				}
			}
		}

		function findPaginatorEl() {
			const selectors = BE.adapters.active.pagination.containerSelectors.join(', ');
			if (!selectors) return null;
			try { return BE.dom.qs(selectors); } catch { return null; }
		}

		function hidePaginatorIfPresent() {
			paginatorEl = paginatorEl || findPaginatorEl();
			if (paginatorEl && !paginatorEl.classList.contains('be-pagination-hidden')) {
				paginatorEl.classList.add('be-pagination-hidden');
				paginatorHiddenByUs = true;
			}
		}

		function restorePaginatorVisibility() {
			if (paginatorHiddenByUs && paginatorEl) {
				paginatorEl.classList.remove('be-pagination-hidden');
			}
			paginatorHiddenByUs = false;
		}

		function setupInfiniteScroll() {
			// Requirement 6: fully idempotent — never leaves more than one
			// observer/sentinel behind, however many times this is called
			// (SPA nav, MutationObserver, settings toggling, re-detection).
			if (scrollObserver) { scrollObserver.disconnect(); scrollObserver = null; }
			const existingSentinel = document.getElementById('be-infinite-scroll-sentinel');
			if (existingSentinel) existingSentinel.remove();
			sentinel = null;

			if (!galleryContainer) return;

			sentinel = document.createElement('div');
			sentinel.id = 'be-infinite-scroll-sentinel';
			sentinel.style.height = '10px';
			galleryContainer.parentNode.insertBefore(sentinel, galleryContainer.nextSibling);

			scrollObserver = new IntersectionObserver((entries) => {
				if (entries[0].isIntersecting && state === 'IDLE') {
					loadNextPage();
				}
			}, { rootMargin: '600px 0px' });

			scrollObserver.observe(sentinel);

			// Requirement 5: do NOT hide the native paginator up front — only
			// once infinite scroll has proven it can actually load a page.
			paginatorEl = findPaginatorEl();
		}

		async function loadNextPage() {
			if (state !== 'IDLE') return;
			state = 'LOADING';
			clearTimeout(retryTimer);

			// Requirement 1 fix: use a mutable local so a successfully
			// calculated fallback URL is actually used for the request below.
			let nextUrl = nextPageUrl || BE.adapters.active.pagination.getNextUrl(document);
			if (!nextUrl) {
				const calc = BE.adapters.active.pagination.calculateNextUrl(location.href);
				if (!calc) {
					state = 'EXHAUSTED';
					BE.log.info('[Gallery] No more pages. Next URL not found.');
					return;
				}
				nextUrl = calc;
				nextPageUrl = calc;
			}

			// Requirement 11: never re-request an already-visited page.
			const nextIdentity = BE.adapters.active.pagination.getPageIdentity(nextUrl);
			if (visitedPageIdentities.has(nextIdentity)) {
				state = 'EXHAUSTED';
				BE.log.info(`[Gallery] Pagination loop detected (identity "${nextIdentity}" already visited). Stopping.`);
				return;
			}

			BE.log.debug('[Gallery] infinite scroll triggered');
			BE.log.debug(`[Gallery] next URL: ${nextUrl}`);
			BE.log.debug(`[Gallery] pagination identity: ${nextIdentity}`);
			BE.log.debug('[Gallery] request started');

			try {
				const res = await BE.net.request({ url: nextUrl, headers: { 'Accept': 'text/html' } }, 3);
				BE.log.debug(`[Gallery] HTTP status: ${res.status}`);
				BE.log.debug(`[Gallery] response length: ${res.responseText.length}`);

				if (res.responseText.includes('login') && res.responseText.includes('password')) {
					throw new Error('Login page returned instead of gallery.');
				}
				if (res.status === 403 || res.status === 404 || res.status === 500) {
					throw new Error(`HTTP ${res.status} error page returned.`);
				}

				visitedPageIdentities.add(nextIdentity);

				const doc = new DOMParser().parseFromString(res.responseText, 'text/html');
				const newContainer = BE.adapters.active.getGalleryContainer(doc);

				if (!newContainer) {
					throw new Error('Gallery container not found in response.');
				}
				BE.log.debug('[Gallery] gallery container found');

				const thumbs = BE.adapters.active.getThumbElements(newContainer);
				BE.log.debug(`[Gallery] thumbnails found: ${thumbs.length}`);

				if (!thumbs.length) {
					state = 'EXHAUSTED';
					BE.log.info('[Gallery] Empty valid page. No more posts.');
					restorePaginatorVisibility();
					return;
				}

				let inserted = 0;
				for (const thumb of thumbs) {
					const img = thumb.tagName === 'IMG' ? thumb : thumb.querySelector('img');
					if (!img) continue;

					const rawPostId = BE.adapters.active.getThumbPostId(img);
					if (!rawPostId) continue;
					const postId = String(rawPostId);
					if (loadedPostIds.has(postId)) continue;

					loadedPostIds.add(postId);

					// Requirement 17: same wrapper-resolution path as the
					// initial thumbnails — no separate/duplicated logic.
					// Note getWrapperForImg's "don't return the gallery
					// container" guard doesn't apply to this parsed-doc
					// element (it belongs to a detached document, not the
					// live one), so it's safe to reuse directly here.
					const wrap = (BE.adapters.active.getThumbWrapper && BE.adapters.active.getThumbWrapper(img)) || img.closest('a') || img.parentElement;
					if (!wrap) continue;

					const clonedWrap = wrap.cloneNode(true);
					const clonedImg = clonedWrap.tagName === 'IMG' ? clonedWrap : clonedWrap.querySelector('img');
					if (!clonedImg) continue;

					clonedWrap.classList.add('be-thumb-wrap');
					clonedImg.classList.add('be-thumb-img');
					clonedImg.dataset.bePostId = postId;

					// Requirement 18: cloneNode never carries over JS event
					// state — the per-thumbnail hover action bar (built via
					// direct pointerenter/pointerleave listeners, not
					// delegation) would otherwise silently stop working on
					// every dynamically-inserted thumbnail. Drop the cloned
					// (dead) bar and rebuild it for real once attached.
					const staleActions = clonedWrap.querySelector('.be-thumb-actions');
					if (staleActions) staleActions.remove();

					galleryContainer.appendChild(clonedWrap);
					if (getComputedStyle(clonedWrap).position === 'static') clonedWrap.style.position = 'relative';
					buildThumbActions(clonedWrap, clonedImg);

					inserted++;
				}
				BE.log.debug(`[Gallery] unique posts: ${inserted}`);
				BE.log.debug(`[Gallery] inserted: ${inserted}`);

				nextPageUrl = BE.adapters.active.pagination.getNextUrl(doc);
				if (!nextPageUrl) {
					const calculated = BE.adapters.active.pagination.calculateNextUrl(nextUrl);
					nextPageUrl = calculated;
				}

				if (!nextPageUrl) {
					state = 'EXHAUSTED';
					BE.log.info('[Gallery] No more pages. Next URL not found in response.');
					restorePaginatorVisibility();
				} else if (inserted === 0) {
					// Requirement 10: a page can be fetched successfully yet
					// contain zero *unique* posts (fully overlapping page).
					// Continue the chain (the loop guard above still protects
					// against A→B→A / repeated-URL loops), but don't spin
					// forever if this keeps happening with no forward progress.
					BE.log.debug('[Gallery] page contained no new posts; continuing pagination chain');
					state = 'IDLE';
					hidePaginatorIfPresent();
				} else {
					BE.log.debug(`[Gallery] next URL detected: ${nextPageUrl}`);
					state = 'IDLE';
					// Requirement 5: only now, after a demonstrated successful
					// load, is it safe to hide the native paginator.
					hidePaginatorIfPresent();
				}

			} catch (err) {
				BE.log.error('[Gallery] pagination error', err);
				BE.log.error(`[Gallery] URL: ${nextUrl}`);
				BE.log.error(`[Gallery] reason: ${err.message}`);
				state = 'ERROR';
				// Requirement 5: on failure, make sure the user isn't stranded —
				// restore the native paginator rather than leaving it hidden.
				restorePaginatorVisibility();
				retryTimer = setTimeout(() => {
					state = 'IDLE';
					loadNextPage();
				}, 5000);
			}
		}

		return { init, applyGridSettings, enhanceThumbnails, enrichThumbnails, setupInfiniteScroll };
	})();

	/* ============================================================ *
	 *  UI MODULE (Settings & Toolbar)
	 * ============================================================ */
	BE.modules.ui = (() => {
		let toolbarRoot = null;
		let postActionBar = null;
		let currentPostCache = null;

		function injectStyles() {
			const css = `
			:root {
				--be-accent: ${BE.settings.get('general.accentColor') || '#ff8ac6'};
			}
			.be-gallery-grid {
				display: grid;
				grid-template-columns: var(--be-grid-template, repeat(auto-fill, minmax(var(--be-thumbnail-size, 220px), 1fr)));
				gap: var(--be-grid-gap, 8px);
				align-items: stretch;
			}
			.be-thumb-wrap {
				position: relative;
				overflow: hidden;
				min-width: 0;
				min-height: 0;
				aspect-ratio: 3/4;
				background: rgba(128,128,128,0.1);
				border-radius: 4px;
			}
			/* Requirement 11–16: now that .be-thumb-wrap is reliably the
			   actual CSS grid item (not some unrelated ancestor), make sure
			   OUR grid sizing is what visibly governs it — many booru sites
			   give the equivalent native element a fixed width/float, which
			   would otherwise silently override grid-template-columns and
			   make thumbnailSize/gridDensity/gridGap changes invisible. */
			.be-gallery-grid > .be-thumb-wrap,
			.be-gallery-grid .be-thumb-wrap {
				float: none !important;
				width: 100% !important;
				height: auto !important;
				max-width: none !important;
				margin: 0 !important;
			}
			.be-thumb-img {
				display: block;
				width: 100%;
				height: 100%;
				max-width: 100%;
				max-height: 100%;
				object-fit: contain;
			}
			/* gallery.compactMode: previously toggled with no CSS consumer,
			   so it "saved" but never visibly did anything. */
			.be-gallery-grid.be-compact-mode .be-thumb-wrap {
				aspect-ratio: 1/1;
				border-radius: 2px;
			}
			.be-gallery-grid.be-compact-mode .be-thumb-actions {
				padding: 1px;
				gap: 2px;
			}
			.be-gallery-grid.be-compact-mode .be-thumb-action-btn {
				padding: 1px 4px;
				font-size: 10px;
			}
			.be-pagination-hidden {
				display: none !important;
			}
			#be-root {
			position: fixed;
			z-index: 999999;
			font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
			}
			#be-root.be-pos-bottom-right { bottom: 10px; right: 10px; }
			#be-root.be-pos-bottom-left  { bottom: 10px; left: 10px; }
			#be-root.be-pos-top-right    { top: 10px; right: 10px; }
			#be-root.be-pos-top-left     { top: 10px; left: 10px; }
			.be-toolbar {
				display: flex;
				gap: 4px;
				background: rgba(20,20,20,0.85);
				padding: 6px;
				border-radius: 8px;
				box-shadow: 0 2px 10px rgba(0,0,0,0.35);
			}
			.be-toolbar-btn {
				background: var(--be-accent);
				color: #fff;
				border: none;
				padding: 7px 10px;
				border-radius: 5px;
				cursor: pointer;
				font-weight: bold;
				font-size: 12px;
				white-space: nowrap;
			}
			.be-toolbar-btn:disabled {
				background: #666;
				cursor: not-allowed;
				opacity: 0.6;
			}
			.be-post-action-bar {
				display: flex;
				flex-wrap: wrap;
				gap: 6px;
				margin: 8px 0;
				padding: 6px;
				background: rgba(128,128,128,0.08);
				border-radius: 6px;
			}
			.be-post-action-bar button {
				background: var(--be-accent);
				color: #fff;
				border: none;
				padding: 6px 10px;
				border-radius: 4px;
				cursor: pointer;
				font-size: 12px;
			}
			.be-post-action-bar button:disabled {
				background: #999;
				cursor: not-allowed;
				opacity: 0.6;
			}
			.be-settings-panel {
				position: fixed;
				top: 50px;
				right: 10px;
				width: 340px;
				max-height: 80vh;
				overflow-y: auto;
				background: #fff;
				color: #000;
				border: 1px solid #ccc;
				padding: 15px;
				z-index: 999999;
				box-shadow: 0 4px 10px rgba(0,0,0,0.2);
				font-family: sans-serif;
			}
			.be-settings-panel h3 {
				margin-top: 15px;
				margin-bottom: 5px;
				border-bottom: 1px solid #eee;
				padding-bottom: 5px;
			}
			.be-settings-row {
				margin-bottom: 10px;
			}
			.be-settings-row label {
				display: block;
				font-size: 12px;
				margin-bottom: 4px;
				font-weight: bold;
			}
			.be-settings-toolbar {
				display: flex;
				gap: 6px;
				flex-wrap: wrap;
				margin-bottom: 10px;
				padding-bottom: 10px;
				border-bottom: 1px solid #ddd;
			}
			.be-settings-toolbar button {
				flex: 1;
				background: var(--be-accent);
				color: #fff;
				border: none;
				padding: 6px 8px;
				border-radius: 4px;
				cursor: pointer;
				font-size: 11px;
			}
			`;
			_GM.addStyle(css);
		}

		/* ---- current-post resolution (works on post pages, cached) ---- */
		async function getCurrentPost(forceRefresh = false) {
			if (currentPostCache && !forceRefresh) return currentPostCache;
			if (!BE.adapters.active) return null;
			try {
				const isPost = typeof BE.adapters.active.isPostPage === 'function' && BE.adapters.active.isPostPage();
				if (!isPost) return null;
				const id = BE.adapters.active.getPostId?.();
				if (!id) return null;
				const post = await BE.adapters.active.fetchPost(id);
				if (post) currentPostCache = post;
				return post;
			} catch (err) {
				BE.log.warn('[UI] could not resolve current post', err);
				return null;
			}
		}

		function findPrevNextLinks() {
			const sels = {
				next: ['a[rel="next"]', 'a.next-post', 'a#post-next', '.next-post a', 'a.next_page[href*="/posts/"]'],
				prev: ['a[rel="prev"]', 'a.prev-post', 'a#post-prev', '.prev-post a', 'a.prev_page[href*="/posts/"]'],
			};
			const find = (list) => { for (const s of list) { const el = BE.dom.qs(s); if (el?.href) return el.href; } return null; };
			return { next: find(sels.next), prev: find(sels.prev) };
		}

		function makeActionHandlers() {
			return {
				async download() {
					const post = await getCurrentPost();
					if (!post) { BE.modules.toast.show('No post detected on this page', 'warn'); return; }
					BE.modules.downloader.downloadPost(post);
				},
				async openOriginal() {
					const post = await getCurrentPost();
					const url = post?.originalUrl || post?.sampleUrl || post?.previewUrl;
					if (!url) { BE.modules.toast.show('Original media URL not found', 'warn'); return; }
					const mode = BE.settings.get('download.openMode');
					if (mode === 'popup') window.open(url, '_blank', 'width=1000,height=800');
					else window.open(url, '_blank', 'noopener');
				},
				async viewer() {
					const post = await getCurrentPost();
					if (!post) { BE.modules.toast.show('No post detected on this page', 'warn'); return; }
					BE.modules.viewer.open(post);
				},
				async favorite() {
					const post = await getCurrentPost();
					if (!post) { BE.modules.toast.show('No post detected on this page', 'warn'); return; }
					BE.modules.favorites.toggle(post);
				},
				prev() {
					const { prev } = findPrevNextLinks();
					if (prev) location.href = prev; else BE.modules.toast.show('No previous post found', 'warn');
				},
				next() {
					const { next } = findPrevNextLinks();
					if (next) location.href = next; else BE.modules.toast.show('No next post found', 'warn');
				},
			};
		}

		function createToolbar() {
			removeToolbar();
			const root = BE.dom.create('div', { id: 'be-root' });
			root.className = `be-pos-${BE.settings.get('general.toolbarPosition') || 'bottom-right'}`;

			const bar = BE.dom.create('div', { class: 'be-toolbar' });
			const actions = makeActionHandlers();
			const onPostPage = !!(BE.adapters.active?.isPostPage?.());
			const hasFav = BE.modules.favorites.supported();
			const { prev, next } = onPostPage ? findPrevNextLinks() : {};

			const specs = [
				['Settings', () => createSettingsPanel(), true],
					 ['Viewer', actions.viewer, onPostPage],
					 ['Download', actions.download, onPostPage],
					 ['Open Original', actions.openOriginal, onPostPage],
					 ['Favorite', actions.favorite, onPostPage && hasFav],
					 ['Prev', actions.prev, onPostPage && !!prev],
					 ['Next', actions.next, onPostPage && !!next],
			];
			for (const [label, fn, enabled] of specs) {
				const btn = document.createElement('button');
				btn.className = 'be-toolbar-btn';
				btn.textContent = label;
				btn.disabled = !enabled;
				btn.title = enabled ? label : `${label} — not available on this page`;
				btn.addEventListener('click', () => fn && fn());
				bar.appendChild(btn);
			}
			root.appendChild(bar);
			document.body.appendChild(root);
			toolbarRoot = root;
		}

		function removeToolbar() {
			toolbarRoot?.remove();
			toolbarRoot = null;
		}

		function createPostActionBar() {
			postActionBar?.remove();
			postActionBar = null;
			if (!BE.adapters.active?.isPostPage?.()) return;

			const mediaEl = BE.dom.qs('#image, #main_image, .image-container, main img.post-image, video');
			if (!mediaEl) return;

			const bar = BE.dom.create('div', { class: 'be-post-action-bar' });
			const actions = makeActionHandlers();
			const hasFav = BE.modules.favorites.supported();
			const specs = [
				['Download', actions.download, true],
				['Open Original', actions.openOriginal, true],
				['Fullscreen', actions.viewer, true],
				['Favorite', actions.favorite, hasFav],
				['Reverse Search', async () => {
					const post = await getCurrentPost();
					const url = post?.sampleUrl || post?.previewUrl || post?.originalUrl;
					if (url) window.open(`https://saucenao.com/search.php?url=${encodeURIComponent(url)}`, '_blank', 'noopener');
				}, true],
				['Info', async () => {
					const post = await getCurrentPost();
					if (!post) return;
					BE.modules.toast.show(
						`#${post.id} · ${post.width}x${post.height} · ${post.rating} · score ${post.score}`,
						'info', 4000,
					);
				}, true],
			];
			for (const [label, fn, enabled] of specs) {
				const btn = document.createElement('button');
				btn.textContent = label;
				btn.disabled = !enabled;
				btn.addEventListener('click', () => fn && fn());
				bar.appendChild(btn);
			}

			const host = mediaEl.closest('div, section, article') || mediaEl.parentElement;
			(host || document.body).insertAdjacentElement('afterend', bar);
			postActionBar = bar;
		}

		function createSettingsPanel() {
			let panel = document.getElementById('be-settings-panel');
			if (panel) {
				panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
				return;
			}

			panel = document.createElement('div');
			panel.id = 'be-settings-panel';
			panel.className = 'be-settings-panel';

			const topBar = document.createElement('div');
			topBar.className = 'be-settings-toolbar';
			const mkTop = (label, fn) => {
				const b = document.createElement('button');
				b.textContent = label;
				b.addEventListener('click', fn);
				topBar.appendChild(b);
				return b;
			};
			mkTop('Export', () => {
				const json = BE.settings.exportJSON();
				navigator.clipboard?.writeText(json).catch(() => {});
				BE.modules.toast.show('Settings copied to clipboard', 'success');
			});
			mkTop('Import', () => {
				const json = prompt('Paste exported Booru Enhancer settings JSON:');
				if (!json) return;
				const ok = BE.settings.importJSON(json);
				BE.modules.toast.show(ok ? 'Settings imported' : 'Import failed — invalid JSON', ok ? 'success' : 'error');
				if (ok) { panel.remove(); createSettingsPanel(); }
			});
			mkTop('Reset', () => {
				if (!confirm('Reset all Booru Enhancer settings to defaults?')) return;
				BE.settings.resetAll();
				panel.remove();
				createSettingsPanel();
				BE.modules.toast.show('Settings reset to defaults', 'success');
			});
			mkTop('Close', () => { panel.style.display = 'none'; });
			panel.appendChild(topBar);

			const categories = BE.settings.categories();
			for (const cat of categories) {
				const h = document.createElement('h3');
				h.textContent = cat;
				panel.appendChild(h);

				for (const key of BE.settings.byCategory(cat)) {
					const def = BE.settings.SCHEMA[key];
					const row = document.createElement('div');
					row.className = 'be-settings-row';

					const label = document.createElement('label');
					label.textContent = def.label;
					row.appendChild(label);

					let input;
					if (def.type === 'bool') {
						input = document.createElement('input');
						input.type = 'checkbox';
						input.checked = BE.settings.get(key);
						input.addEventListener('change', () => BE.settings.set(key, input.checked));
					} else if (def.type === 'select') {
						input = document.createElement('select');
						for (const choice of def.choices) {
							const opt = document.createElement('option');
							opt.value = choice;
							opt.textContent = choice;
							input.appendChild(opt);
						}
						input.value = BE.settings.get(key);
						input.addEventListener('change', () => BE.settings.set(key, input.value));
					} else if (def.type === 'color') {
						input = document.createElement('input');
						input.type = 'color';
						input.value = BE.settings.get(key);
						input.addEventListener('input', () => BE.settings.set(key, input.value));
					} else if (def.type === 'range' || def.type === 'number') {
						const valSpan = document.createElement('span');
						valSpan.textContent = ` [${BE.settings.get(key)}]`;
						valSpan.style.fontWeight = 'bold';

						input = document.createElement('input');
						input.type = def.type === 'range' ? 'range' : 'number';
						input.min = def.min;
						input.max = def.max;
						input.value = BE.settings.get(key);
						input.style.width = '90%';
						input.addEventListener('input', () => {
							valSpan.textContent = ` [${input.value}]`;
							BE.settings.set(key, Number(input.value));
						});
						row.appendChild(input);
						row.appendChild(valSpan);
						panel.appendChild(row);
						continue;
					} else if (def.type === 'textarea') {
						input = document.createElement('textarea');
						input.value = BE.settings.get(key);
						input.style.width = '100%';
						input.style.height = '60px';
						input.addEventListener('change', () => BE.settings.set(key, input.value));
					} else {
						input = document.createElement('input');
						input.type = 'text';
						input.value = BE.settings.get(key);
						input.style.width = '100%';
						input.addEventListener('change', () => BE.settings.set(key, input.value));
					}
					row.appendChild(input);
					panel.appendChild(row);
				}
			}

			document.body.appendChild(panel);
		}

		function invalidateCurrentPost() { currentPostCache = null; }

		return {
			injectStyles, createToolbar, removeToolbar, createPostActionBar,
			createSettingsPanel, getCurrentPost, invalidateCurrentPost,
		};
	})();

	/* ============================================================ *
	 *  MENU COMMANDS
	 * ============================================================ */
	BE.modules.menu = (() => {
		let registered = false;
		function register() {
			if (registered) return; // avoid duplicate entries on re-init (SPA nav)
	registered = true;
			try {
				_GM.registerMenuCommand('Booru Enhancer: Settings', () => BE.modules.ui.createSettingsPanel());
				_GM.registerMenuCommand('Booru Enhancer: Enable/Disable', () => {
					const cur = BE.settings.get('general.enabled');
					BE.settings.set('general.enabled', !cur);
					BE.modules.toast.show(`Booru Enhancer ${!cur ? 'enabled' : 'disabled'} — reload the page to apply`, 'info', 4000);
				});
				_GM.registerMenuCommand('Booru Enhancer: Reset Settings', () => {
					if (confirm('Reset all Booru Enhancer settings to defaults?')) {
						BE.settings.resetAll();
						BE.modules.toast.show('Settings reset', 'success');
					}
				});
				_GM.registerMenuCommand('Booru Enhancer: Toggle Debug Mode', () => {
					const cur = BE.settings.get('debug.verboseLogging');
					BE.settings.set('debug.verboseLogging', !cur);
					BE.modules.toast.show(`Debug mode ${!cur ? 'ON' : 'OFF'}`, 'info');
				});
			} catch (err) {
				// Some managers (or contexts without menu support) may not have
				// GM_registerMenuCommand available at all — this must not be fatal.
				BE.log.warn('[Menu] registerMenuCommand unavailable', err);
			}
		}
		return { register };
	})();

	/* ============================================================ *
	 *  INITIALIZATION  (resilient bootstrap)
	 * ============================================================ *
	 *  Every stage is wrapped so that one broken module can never
	 *  take down the rest of the script, and every failure/skip is
	 *  logged with a reason instead of a silent `return`.
	 * ============================================================ */
	let _beInitialized = false;

	function safeStage(name, fn) {
		try {
			fn();
			BE.log.info(`[Init] ${name}: ok`);
			return true;
		} catch (err) {
			BE.log.error(`[Init] ${name}: FAILED`, err);
			return false;
		}
	}

	BE.modules.init = function init() {
		// Idempotent: calling BE.init()/BE.modules.init() twice (e.g. after an
		// SPA navigation) must never create duplicate toolbars/listeners.
		if (_beInitialized) {
			BE.log.debug('[Init] already initialized, re-applying page-specific UI only');
			refreshForCurrentPage();
			return;
		}
		_beInitialized = true;

		BE.log.info('[Init] Starting...');
		BE.log.info(`[Init] Host: ${location.hostname}`);

		BE.store.whenReady(() => {
			let settingsOk = safeStage('Settings', () => BE.settings._load());
			if (!settingsOk) {
				BE.log.warn('[Init] Settings failed to load — continuing with schema defaults.');
			}
			BE.log.info('[Init] Settings loaded');

			let adapter = null;
			const adapterOk = safeStage('Adapter detection', () => {
				adapter = BE.core.detectAdapter();
			});
			if (!adapterOk || !adapter) {
				BE.log.error('[Init] Adapter detection failed: falling back to generic adapter.');
				adapter = BE.adapters.registry.find((a) => a.id === 'generic') || null;
			}
			BE.adapters.active = adapter;
			BE.log.info(`[Init] Adapter: ${adapter ? adapter.id : 'NONE'}`);

			if (!adapter) {
				BE.log.error('[Init] FATAL: no adapter available (not even generic). Booru Enhancer cannot run on this page.');
				return;
			}

			// general.enabled may be undefined/corrupt in storage — treat
			// anything except an explicit `false` as enabled.
			const enabled = BE.settings.get('general.enabled') !== false;
			if (!enabled) {
				BE.log.info('[Init] Booru Enhancer is disabled in settings for this site. Registering menu command only.');
				safeStage('Menu commands', () => BE.modules.menu.register());
				return;
			}

			// 1. IMMEDIATE UI INIT — must appear regardless of storage/API state.
			safeStage('UI styles', () => BE.modules.ui.injectStyles());
			safeStage('Toolbar', () => BE.modules.ui.createToolbar());
			safeStage('Post action bar', () => BE.modules.ui.createPostActionBar());
			safeStage('Menu commands', () => BE.modules.menu.register());
			BE.log.info('[Init] UI initialized');

			// 2. IMMEDIATE GALLERY BINDING & LAYOUT
			let container = null;
			safeStage('Gallery init', () => {
				container = adapter.getGalleryContainer();
				if (container) BE.modules.gallery.init(container);
			});
				BE.log.info(`[Init] Gallery initialized (container: ${container ? 'found' : 'none on this page'})`);

				// 3. ASYNC METADATA ENRICHMENT — never blocks the UI above.
				setTimeout(() => safeStage('Metadata enrichment', () => BE.modules.gallery.enrichThumbnails()), 0);

				// 4. ASYNC INFINITE SCROLL — set up as soon as the gallery exists,
				// no arbitrary fixed delay beyond letting the container settle.
				if (BE.settings.get('gallery.infiniteScroll') && container) {
					safeStage('Infinite scroll', () => BE.modules.gallery.setupInfiniteScroll());
				}

				// 5. Viewer/hover/favorites are lazily initialized on first use,
				// so a failure there can never block startup.
		});
	};

	function refreshForCurrentPage() {
		safeStage('Toolbar refresh', () => BE.modules.ui.createToolbar());
		safeStage('Post action bar refresh', () => BE.modules.ui.createPostActionBar());
		BE.modules.ui.invalidateCurrentPost();
	}

	/* ============================================================ *
	 *  SPA / AJAX NAVIGATION HANDLING
	 * ============================================================ *
	 *  Sites like Danbooru/e621 can swap content via pushState/AJAX
	 *  without a full reload. Re-detect state and refresh page-scoped
	 *  UI (toolbar buttons, post action bar) without re-creating
	 *  duplicate global UI (toolbar root/listeners are idempotent).
	 * ============================================================ */
	(function watchSpaNavigation() {
		let lastUrl = location.href;
		const onUrlChange = BE.dom.debounce(() => {
			if (location.href === lastUrl) return;
			lastUrl = location.href;
			BE.log.debug('[Nav] URL changed, refreshing page-scoped UI');
			safeStage('Adapter re-detect', () => { BE.adapters.active = BE.core.detectAdapter() || BE.adapters.active; });
			refreshForCurrentPage();
		}, 150);

		for (const fnName of ['pushState', 'replaceState']) {
			const orig = history[fnName];
			history[fnName] = function patched(...args) {
				const ret = orig.apply(this, args);
				onUrlChange();
				return ret;
			};
		}
		window.addEventListener('popstate', onUrlChange);

		// Fallback for sites that replace DOM content without touching the
		// URL/history API at all. We only re-initialize when the container
		// found by the adapter is genuinely a DIFFERENT element than the one
		// the gallery module already owns — that distinguishes a true SPA
		// content swap from unrelated mutations inside the same gallery,
		// which must never reset pagination/loadedPostIds state.
		const bodyObserver = new MutationObserver(BE.dom.debounce(() => {
			if (!BE.adapters.active) return;
			const container = BE.adapters.active.getGalleryContainer();
			if (container && !container.dataset.beGalleryInit) {
				BE.log.debug('[Nav] gallery container replaced, re-initializing gallery module');
				safeStage('Gallery re-init', () => {
					BE.modules.gallery.init(container);
					if (BE.settings.get('gallery.infiniteScroll')) {
						BE.modules.gallery.setupInfiniteScroll();
					}
					setTimeout(() => safeStage('Metadata enrichment (re-init)', () => BE.modules.gallery.enrichThumbnails()), 0);
				});
			}
		}, 400));
		BE.dom.ready(() => bodyObserver.observe(document.body, { childList: true, subtree: true }));
	})();

	BE.dom.ready(BE.modules.init);

	// Public, idempotent entry point mentioned in the architecture spec —
	// calling this more than once must never duplicate UI/listeners.
	BE.init = BE.modules.init;
})();