Sleazy Fork is available in English.

Rule34.xxx Comments Feed

Turn the global comments page into a live feed. Includes advanced filtering, highlighting and user notes!

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         Rule34.xxx Comments Feed
// @namespace    861ddd094884eac5bea7a3b12e074f34
// @version      1.30.2
// @author       Anonymous, Claude Opus 5
// @description  Turn the global comments page into a live feed. Includes advanced filtering, highlighting and user notes!
// @license      0BSD
// @icon         https://external-content.duckduckgo.com/ip3/rule34.xxx.ico
// @homepage     https://gitlab.com/aelithe/r34-comments-feed
// @homepageURL  https://gitlab.com/aelithe/r34-comments-feed
// @supportURL   https://gitlab.com/aelithe/r34-comments-feed/-/issues
// @match        https://rule34.xxx/index.php?page=comment&s=list*
// @match        https://rule34.xxx/index.php?page=account&s=profile*
// @require      https://update.greasyfork.org/scripts/588114/1893327/Logging%20Handler%20%20UI%20Overlay.js
// @require      https://update.greasyfork.org/scripts/589409/1890547/Gaze%20Gesture%20Library.js
// @require      https://update.greasyfork.org/scripts/589318/1890407/Request%20Queue%20%28backoff%29%20Library.js
// @connect      api.rule34.xxx
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_unregisterMenuCommand
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function() {
	"use strict";
	var entityDecoder = document.createElement("textarea");
	function decodeEntities(token) {
		if (!token.includes("&")) return token;
		entityDecoder.innerHTML = token;
		return entityDecoder.value;
	}
	var MIN_POLL_INTERVAL_MS = 15e3;
	var POLL_INTERVAL_KEY = "poll_interval";
	var CONFIG = {
		pollIntervalMs: MIN_POLL_INTERVAL_MS,
		postCacheLimit: 100,
		bottomThresholdPx: 120,
		followMarginPx: 12,
		warmupMs: 3e4,
		scrollGatePx: 50,
		topRearmPx: 24,
		maxConsecutiveFailures: 3,
		backoffFactor: 4,
		backfillMaxAgeMs: 18e5,
		pollHoldHeadPosts: 5,
		pollHoldMoveSettleMs: 3e3,
		menuScrollDismissHoldMs: 400,
		tagListMaxHeightPx: 150,
		gazeDwellMs: 500,
		gazeCooldownMs: 1e3,
		gazeTarget: {
			x: .1,
			xRadius: .15,
			yMin: -.6,
			yMax: 1.6
		},
		autoScrollCommentOverheadMs: 600,
		autoScrollThumbnailMs: 300,
		autoScrollMinDwellMs: 2e3,
		autoScrollMaxDwellMs: 6e4,
		autoScrollMouseSettleMs: 3e3,
		autoScrollScrollSettleMs: 250,
		autoScrollRecheckMs: 500,
		toastDurationMs: 4e3,
		toastMaxVisible: 5,
		consoleLog: true,
		panel: true
	};
	var API_BASE = "https://api.rule34.xxx/index.php?page=dapi&q=index";
	var feedPageUrl = (cursor) => `/index.php?page=comment&s=list${cursor !== void 0 ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
	var OPTIONS_URL = "/index.php?page=account&s=options";
	var FEED_CACHE_KEY = "feed_cache";
	var LOG_PREFS_KEY = "log_prefs";
	var GAZE_PREFS_KEY = "gaze_calibration";
	var LOG_NAME = "Rule34.xxx Global Comments Revamp";
	var IS_PROFILE = (() => {
		const params = new URLSearchParams(location.search);
		return params.get("page") === "account" && params.get("s") === "profile";
	})();
	(() => {
		const stored = Number(GM_getValue(POLL_INTERVAL_KEY, null));
		if (Number.isFinite(stored) && stored >= 15e3) CONFIG.pollIntervalMs = Math.round(stored);
	})();
	function setPollIntervalMs(ms) {
		CONFIG.pollIntervalMs = ms;
	}
	function redactTerm(term) {
		if (term.length <= 2) return term.slice(0, 1) + "___";
		return term[0] + "___" + term[term.length - 1];
	}
	var redactQuoted = (message) => message.replace(/"([^"]*)"/g, (_m, term) => `"${redactTerm(term)}"`);
	var quoteTerm = (term) => `"${term.replace(/"/g, "″")}"`;
	var logger = LoggingUI.create({
		name: LOG_NAME,
		tag: "gcr",
		console: CONFIG.consoleLog,
		panel: CONFIG.panel,
		panelSink: !IS_PROFILE,
		prefsKey: LOG_PREFS_KEY
	});
	var log = (...args) => logger.info(...args);
	var warn = (...args) => logger.warn(...args);
	function splitEmit(level, message, rest) {
		const redacted = redactQuoted(message);
		if (redacted === message) {
			logger[level](message, ...rest);
			return;
		}
		logger.consoleOnly[level](message, ...rest);
		const wasOn = logger.consoleEnabled;
		if (wasOn) logger.consoleEnabled = false;
		try {
			logger[level](redacted, ...rest);
		} finally {
			if (wasOn) logger.consoleEnabled = true;
		}
	}
	var logSensitive = (message, ...rest) => splitEmit("info", message, rest);
	var warnSensitive = (message, ...rest) => splitEmit("warn", message, rest);
	function readJson(key, fallback, label) {
		try {
			const raw = GM_getValue(key, null);
			if (!raw) return fallback;
			const parsed = JSON.parse(raw);
			return parsed && typeof parsed === "object" ? parsed : fallback;
		} catch (e) {
			if (label) warn(`${label} read error:`, e);
			return fallback;
		}
	}
	function writeJson(key, value, label) {
		try {
			GM_setValue(key, JSON.stringify(value));
			return true;
		} catch (e) {
			if (label) warn(`${label} write error:`, e);
			return false;
		}
	}
	var OVERFLOW_BLACKLIST_KEY = "blacklist_overflow";
	function normalizeTags(tags) {
		const normalized = tags.filter((tag) => typeof tag === "string").map((tag) => decodeEntities(tag.trim().toLowerCase())).filter(Boolean);
		return [...new Set(normalized)];
	}
	function loadOverflowBlacklist() {
		const parsed = readJson(OVERFLOW_BLACKLIST_KEY, [], "overflow blacklist");
		return Array.isArray(parsed) ? normalizeTags(parsed) : [];
	}
	function saveOverflowBlacklist(tags) {
		if (writeJson("blacklist_overflow", tags, "overflow blacklist")) log(`overflow blacklist saved: ${tags.length} tag(s)`);
	}
	var overflowTags = new Set(loadOverflowBlacklist());
	var getOverflowBlacklist = () => overflowTags;
	function setOverflowBlacklist(tags) {
		const next = normalizeTags(tags);
		saveOverflowBlacklist(next);
		overflowTags = new Set(next);
	}
	function addOverflowTag(tag) {
		const target = (tag || "").trim().toLowerCase();
		if (!target || overflowTags.has(target)) return false;
		setOverflowBlacklist([...overflowTags, target]);
		return true;
	}
	function removeOverflowTag(tag) {
		const target = (tag || "").trim().toLowerCase();
		if (!overflowTags.has(target)) return false;
		setOverflowBlacklist([...overflowTags].filter((entry) => entry !== target));
		return true;
	}
	var state = {
		renderedComments: new Set(),
		highWaterMark: 0,
		auth: "",
		consecutiveFailures: 0,
		polling: false,
		listEl: null,
		seedCursor: null,
		pollTimer: null,
		suppressed: {
			comments: [],
			posts: []
		},
		readerScrolled: false,
		pollHeld: false,
		seatHold: null,
		seatBaseline: 0,
		warmupTicks: Math.max(0, Math.round(CONFIG.warmupMs / CONFIG.pollIntervalMs) - 1)
	};
	function parseRetryAfter(value) {
		if (!value) return null;
		const v = value.trim();
		if (/^\d+$/.test(v)) return parseInt(v, 10) * 1e3;
		const when = Date.parse(v);
		return Number.isNaN(when) ? null : Math.max(0, when - Date.now());
	}
	function rateError(retryAfter) {
		const e = new Error("HTTP 429");
		e.rateLimited = true;
		e.retryAfter = retryAfter;
		return e;
	}
	var netQueue = RequestQueue.create({
		name: `${LOG_NAME}-net`,
		timeout: 15e3,
		perform: (job) => job.run()
	});
	function rawHttpGet(url) {
		return new Promise((resolve, reject) => {
			GM_xmlhttpRequest({
				method: "GET",
				url,
				onload: (res) => {
					if (res.status === 429) {
						const m = /^retry-after:[ \t]*(.+?)[ \t]*$/im.exec(res.responseHeaders || "");
						reject(rateError(parseRetryAfter(m ? m[1] : null)));
						return;
					}
					res.status >= 200 && res.status < 300 ? resolve(res.responseText) : reject(new Error(`HTTP ${res.status}`));
				},
				onerror: () => reject(new Error("transport error")),
				ontimeout: () => reject(new Error("timeout")),
				timeout: 15e3
			});
		});
	}
	function httpGet(url) {
		return netQueue.submit({
			label: url,
			run: () => rawHttpGet(url)
		});
	}
	function rawFetchDocument(url, body) {
		return new Promise((resolve, reject) => {
			const xhr = new XMLHttpRequest();
			xhr.responseType = "document";
			xhr.open(body === void 0 ? "GET" : "POST", url, true);
			xhr.withCredentials = true;
			xhr.timeout = 15e3;
			if (body !== void 0) xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
			xhr.onload = () => {
				if (xhr.status === 429) {
					reject(rateError(parseRetryAfter(xhr.getResponseHeader("Retry-After"))));
					return;
				}
				xhr.status >= 200 && xhr.status < 300 ? resolve(xhr.response) : reject(new Error(`HTTP ${xhr.status}`));
			};
			xhr.onerror = () => reject(new Error("transport error"));
			xhr.ontimeout = () => reject(new Error("timeout"));
			xhr.send(body);
		});
	}
	function fetchDocument(url, body, priority = false) {
		return netQueue.submit({
			label: url,
			priority: priority || body !== void 0,
			run: () => rawFetchDocument(url, body)
		});
	}
	function resolveAuth() {
		return new Promise((resolve) => {
			const cached = GM_getValue("api_auth", null);
			if (cached !== null) {
				resolve(cached);
				return;
			}
			const xhr = new XMLHttpRequest();
			xhr.responseType = "document";
			xhr.open("GET", OPTIONS_URL, true);
			xhr.onload = () => {
				let value = "";
				try {
					const areas = xhr.response.getElementsByTagName("TEXTAREA");
					const apiString = areas[2] && areas[2].defaultValue;
					if (apiString && apiString !== "&api_key=&user_id=2") value = apiString;
				} catch (e) {
					warn("failed to scrape api auth:", e);
				}
				GM_setValue("api_auth", value);
				resolve(value);
			};
			xhr.onerror = () => resolve("");
			xhr.send();
		});
	}
	async function fetchCommentFeed() {
		const xml = await httpGet(`${API_BASE}&s=comment${state.auth}`);
		const doc = new DOMParser().parseFromString(xml, "text/xml");
		if (doc.querySelector("parsererror")) throw new Error("malformed comment feed");
		return Array.from(doc.getElementsByTagName("comment")).map((c) => ({
			id: c.getAttribute("id"),
			postId: c.getAttribute("post_id")
		}));
	}
	function fetchFeedPage(cursor) {
		return fetchDocument(feedPageUrl(cursor));
	}
	function nextFeedCursor(doc) {
		const href = doc.querySelector("#paginator a[href*=\"cursor=\"]")?.getAttribute("href");
		if (!href) return null;
		try {
			return new URL(href, location.origin).searchParams.get("cursor");
		} catch {
			return null;
		}
	}
	var SITE_BLACKLIST_BUDGET = 3900;
	function htmlEntities(value) {
		return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
	}
	function urlEncode(value, componentLayer) {
		let encoded = "";
		for (const byte of new TextEncoder().encode(value)) if (byte >= 65 && byte <= 90 || byte >= 97 && byte <= 122 || byte >= 48 && byte <= 57 || byte === 45 || byte === 46 || byte === 95 || byte === 126 || componentLayer && (byte === 33 || byte === 39 || byte === 40 || byte === 41 || byte === 42)) encoded += String.fromCharCode(byte);
		else encoded += "%" + byte.toString(16).toUpperCase().padStart(2, "0");
		return encoded;
	}
	function storedBlacklistLength(tags) {
		return urlEncode(urlEncode(htmlEntities(tags.join(" ")), true), false).length;
	}
	var TAG_TYPES_KEY = "tag_types";
	function loadTagTypes() {
		const parsed = readJson(TAG_TYPES_KEY, {}, "tag type cache");
		if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
		const cache = {};
		for (const [tag, type] of Object.entries(parsed)) if (typeof type === "number" && Number.isInteger(type)) cache[tag] = type;
		return cache;
	}
	var tagTypes = loadTagTypes();
	var isPseudoOrWildcard = (tag) => tag.includes("*") || tag.startsWith("rating:") || tag.startsWith("user:");
	function cachedTagType(tag) {
		const target = tag.trim().toLowerCase();
		if (!target || isPseudoOrWildcard(target)) return null;
		return Object.prototype.hasOwnProperty.call(tagTypes, target) ? tagTypes[target] : void 0;
	}
	async function lookupTagType(tag) {
		const target = tag.trim().toLowerCase();
		const cached = cachedTagType(target);
		if (cached !== void 0) return cached;
		try {
			const auth = await resolveAuth();
			const xml = await httpGet(`${API_BASE}&s=tag&name=${encodeURIComponent(target)}${auth}`);
			const doc = new DOMParser().parseFromString(xml, "text/xml");
			if (doc.querySelector("parsererror")) return null;
			const value = doc.getElementsByTagName("tag")[0]?.getAttribute("type");
			if (value === null || value === void 0 || !/^\d+$/.test(value)) return null;
			const type = Number(value);
			tagTypes = {
				...tagTypes,
				[target]: type
			};
			writeJson(TAG_TYPES_KEY, tagTypes, "tag type cache");
			return type;
		} catch (e) {
			warnSensitive(`failed to look up tag type for "${target}":`, e);
			return null;
		}
	}
	var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
	async function lookupTagTypes(tags) {
		const found = new Map();
		let requested = false;
		for (const tag of tags) {
			const target = tag.trim().toLowerCase();
			if (found.has(target)) continue;
			const cached = cachedTagType(target);
			if (cached === void 0 && requested) await delay(250);
			found.set(target, cached === void 0 ? await lookupTagType(target) : cached);
			if (cached === void 0) requested = true;
		}
		return found;
	}
	var isGeneralType = (type) => type === null || type === void 0 || type === 0;
	var written = new Set();
	var removed = new Set();
	var writtenBlacklistTags = () => [...written];
	var removedBlacklistTags = () => removed;
	var optionsForm = (doc) => doc.querySelector("form[action*=\"s=options\"]");
	var blacklistField = (form) => form.querySelector("textarea[name=\"tags\"]");
	function optionsBody(form, tags) {
		const body = new URLSearchParams();
		new FormData(form).forEach((value, key) => {
			if (typeof value === "string") body.append(key, value);
		});
		body.set("tags", tags);
		body.delete("generate_api_key");
		body.set("submit", "Save");
		return body.toString();
	}
	function sameTags(left, right) {
		const a = new Set(left);
		const b = new Set(right);
		return a.size === b.size && [...a].every((tag) => b.has(tag));
	}
	async function postAndRead(form, tags) {
		const nextForm = optionsForm(await fetchDocument(OPTIONS_URL, optionsBody(form, tags.join(" "))));
		const field = nextForm && blacklistField(nextForm);
		return {
			form: nextForm,
			tags: field ? splitBlacklist(field.value) : null
		};
	}
	async function writeSiteListResult(form, tags) {
		const previousField = blacklistField(form);
		if (!previousField) return "failed";
		const previous = splitBlacklist(previousField.value);
		try {
			const result = await postAndRead(form, tags);
			if (result.tags && sameTags(result.tags, tags)) return "saved";
			if (!result.tags) return "failed";
			warn("site truncated the blacklist write; restoring the previous list");
			let restoreForm = result.form;
			if (!restoreForm) restoreForm = optionsForm(await fetchDocument(OPTIONS_URL, void 0, true));
			if (!restoreForm) {
				warn("site options form not found while restoring the blacklist");
				return "truncated";
			}
			const restored = await postAndRead(restoreForm, previous);
			if (!restored.tags || !sameTags(restored.tags, previous)) warn("failed to restore the complete previous site blacklist");
			return "truncated";
		} catch (e) {
			warn("failed to write the site blacklist:", e);
			return "failed";
		}
	}
	function recordSiteWrite(previous, next) {
		const before = new Set(previous);
		const after = new Set(next);
		for (const tag of after) if (!before.has(tag)) {
			written.add(tag);
			removed.delete(tag);
		}
		for (const tag of before) if (!after.has(tag)) {
			written.delete(tag);
			removed.add(tag);
		}
	}
	async function fetchSiteForm() {
		const form = optionsForm(await fetchDocument(OPTIONS_URL, void 0, true));
		const field = form && blacklistField(form);
		if (!form || !field) {
			warn("site options form not found; blacklist unchanged");
			return null;
		}
		return {
			form,
			tags: splitBlacklist(field.value)
		};
	}
	var writeChain = Promise.resolve();
	function serial(operation) {
		const result = writeChain.then(operation, operation);
		writeChain = result.then(() => void 0, () => void 0);
		return result;
	}
	function overflowResult(target) {
		addOverflowTag(target);
		return "overflow";
	}
	var undoSeq = 0;
	var undoPoint = null;
	function armUndo(site, local) {
		undoPoint = {
			seq: ++undoSeq,
			site: site.slice(),
			local
		};
	}
	var armedUndoSeq = () => undoPoint ? undoPoint.seq : null;
	function undoBlacklistChange(seq) {
		return serial(async () => {
			if (!undoPoint || undoPoint.seq !== seq) return false;
			const snap = undoPoint;
			try {
				const fetched = await fetchSiteForm();
				if (!fetched) return false;
				if (!sameTags(fetched.tags, snap.site)) {
					if (await writeSiteListResult(fetched.form, snap.site) !== "saved") return false;
					recordSiteWrite(fetched.tags, snap.site);
				}
				setOverflowBlacklist(snap.local);
				undoPoint = null;
				return true;
			} catch (e) {
				warn("failed to undo the blacklist change:", e);
				return false;
			}
		});
	}
	async function directWrite(state, target) {
		const desired = state.current.concat(target);
		if (storedBlacklistLength(desired) > 3900) return state;
		const outcome = await writeSiteListResult(state.form, desired);
		if (outcome === "saved") {
			recordSiteWrite(state.current, desired);
			return "saved";
		}
		if (outcome === "failed") return "failed";
		const restored = await fetchSiteForm();
		if (!restored) return "failed";
		return {
			form: restored.form,
			current: restored.tags
		};
	}
	async function evictFor(form, current, target) {
		const next = current.slice();
		const evicted = [];
		for (let i = next.length - 1; i >= 0; i--) {
			const tag = next[i];
			if (isGeneralType(await lookupTagType(tag))) continue;
			evicted.unshift(tag);
			next.splice(i, 1);
			if (storedBlacklistLength(next.concat(target)) <= 3900) break;
		}
		const desired = next.concat(target);
		if (storedBlacklistLength(desired) > 3900) return overflowResult(target);
		const outcome = await writeSiteListResult(form, desired);
		if (outcome === "failed") return "failed";
		if (outcome === "truncated") return overflowResult(target);
		recordSiteWrite(current, desired);
		for (const tag of evicted) addOverflowTag(tag);
		removeOverflowTag(target);
		return "saved";
	}
	async function routeAdd(state, target) {
		const placed = await directWrite(state, target);
		if (placed === "saved" || placed === "failed") return placed;
		if (!isGeneralType(await lookupTagType(target))) return overflowResult(target);
		return evictFor(placed.form, placed.current, target);
	}
	async function addTag(target) {
		try {
			const fetched = await fetchSiteForm();
			if (!fetched) return "failed";
			const { form, tags: current } = fetched;
			if (current.includes(target)) {
				written.add(target);
				removed.delete(target);
				return "present";
			}
			if (getOverflowBlacklist().has(target)) return "present";
			const preSite = current.slice();
			const preLocal = [...getOverflowBlacklist()];
			const outcome = await routeAdd({
				form,
				current
			}, target);
			if (outcome === "saved" || outcome === "overflow") armUndo(preSite, preLocal);
			return outcome;
		} catch (e) {
			warn("failed to route the blacklist tag:", e);
			return "failed";
		}
	}
	function addTagToSiteBlacklist(tag) {
		const target = tag.trim().toLowerCase();
		if (!target) return Promise.resolve("failed");
		return serial(() => addTag(target));
	}
	async function promoteGeneralOverflow(form, current) {
		const local = [...getOverflowBlacklist()];
		const types = await lookupTagTypes(local);
		const promoted = [];
		const desired = current.slice();
		for (const tag of local) {
			if (!isGeneralType(types.get(tag))) continue;
			if (storedBlacklistLength(desired.concat(tag)) > 3900) continue;
			desired.push(tag);
			promoted.push(tag);
		}
		if (!promoted.length) return;
		if (await writeSiteListResult(form, desired) !== "saved") {
			warn("could not promote local blacklist tags; they remain local");
			return;
		}
		recordSiteWrite(current, desired);
		for (const tag of promoted) removeOverflowTag(tag);
	}
	function removeTagFromBlacklist(tag) {
		const target = tag.trim().toLowerCase();
		if (!target) return Promise.resolve(false);
		return serial(async () => {
			const preLocal = [...getOverflowBlacklist()];
			const droppedLocal = removeOverflowTag(target);
			try {
				const fetched = await fetchSiteForm();
				if (!fetched) return droppedLocal;
				const { form, tags: current } = fetched;
				if (!current.includes(target)) {
					if (droppedLocal) armUndo(current, preLocal);
					return droppedLocal;
				}
				const desired = current.filter((entry) => entry !== target);
				if (await writeSiteListResult(form, desired) !== "saved") {
					if (droppedLocal) armUndo(current, preLocal);
					return droppedLocal;
				}
				recordSiteWrite(current, desired);
				armUndo(current, preLocal);
				try {
					const refreshed = await fetchSiteForm();
					if (refreshed) await promoteGeneralOverflow(refreshed.form, refreshed.tags);
				} catch (e) {
					warn("could not promote local blacklist tags; they remain local:", e);
				}
				return true;
			} catch (e) {
				warnSensitive(`failed to remove "${target}" from the blacklist:`, e);
				return droppedLocal;
			}
		});
	}
	async function moveToLocal(target) {
		const preLocal = [...getOverflowBlacklist()];
		const fetched = await fetchSiteForm();
		if (!fetched) return false;
		const { form, tags: current } = fetched;
		if (current.includes(target)) {
			const desired = current.filter((tag) => tag !== target);
			if (await writeSiteListResult(form, desired) !== "saved") return false;
			recordSiteWrite(current, desired);
		}
		addOverflowTag(target);
		armUndo(current, preLocal);
		return true;
	}
	async function moveToSite(target) {
		const preLocal = [...getOverflowBlacklist()];
		const fetched = await fetchSiteForm();
		if (!fetched) return false;
		const { form, tags: current } = fetched;
		if (current.includes(target)) {
			if (removeOverflowTag(target)) armUndo(current, preLocal);
			return true;
		}
		const placed = await directWrite({
			form,
			current
		}, target);
		if (placed === "saved") {
			removeOverflowTag(target);
			armUndo(current, preLocal);
			return true;
		}
		if (placed === "failed") return false;
		if (await evictFor(placed.form, placed.current, target) !== "saved") {
			warnSensitive(`could not move "${target}" to the site blacklist; it cannot fit`);
			return false;
		}
		armUndo(current, preLocal);
		return true;
	}
	function moveTag(tag, to) {
		const target = tag.trim().toLowerCase();
		if (!target) return Promise.resolve(false);
		return serial(async () => {
			try {
				return to === "site" ? await moveToSite(target) : await moveToLocal(target);
			} catch (e) {
				warnSensitive(`failed to move "${target}" to the ${to} blacklist:`, e);
				return false;
			}
		});
	}
	function getBlacklistLists() {
		return serial(async () => {
			try {
				const fetched = await fetchSiteForm();
				return fetched ? {
					site: fetched.tags,
					local: [...getOverflowBlacklist()]
				} : null;
			} catch (e) {
				warn("failed to read the site blacklist:", e);
				return null;
			}
		});
	}
	var cookieRes = new Map();
	function readCookie(name) {
		let re = cookieRes.get(name);
		if (!re) {
			re = new RegExp("(?:^|;\\s*)" + name + "=([^;]*)");
			cookieRes.set(name, re);
		}
		const m = document.cookie.match(re);
		return m ? decodeURIComponent(m[1]) : "";
	}
	var cookieInt = (name, fallback) => parseInt(readCookie(name), 10) || fallback;
	function decodePercents(token) {
		if (!token.includes("%")) return token;
		try {
			return decodeURIComponent(token);
		} catch {
			return token;
		}
	}
	var splitBlacklist = (raw) => raw.toLowerCase().split(/(?:[\s,]|%20)+/).filter(Boolean).map(decodePercents).map(decodeEntities);
	function getBlacklist() {
		const removed = removedBlacklistTags();
		return [...new Set(splitBlacklist(readCookie("tag_blacklist")).filter((tag) => !removed.has(tag)).concat(writtenBlacklistTags(), [...getOverflowBlacklist()]))];
	}
	function wildcardPattern(token) {
		const source = token.split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
		return new RegExp("^" + source + "$");
	}
	function filterContext() {
		const blacklist = getBlacklist();
		const isWildcard = (token) => token.includes("*") && !token.startsWith("rating:") && !token.startsWith("user:");
		const wildcardTokens = blacklist.filter(isWildcard);
		return {
			blacklist,
			blacklistExact: new Set(blacklist.filter((token) => !isWildcard(token))),
			blacklistWildcards: wildcardTokens.map((token) => ({
				token,
				pattern: wildcardPattern(token)
			})),
			postThreshold: cookieInt("post_threshold", 0),
			commentThreshold: cookieInt("comment_threshold", -5),
			aiFilter: cookieInt("filter_ai", 0) === 1,
			postVerdicts: new Map(),
			uploaders: new Map(),
			announcedPosts: new Set()
		};
	}
	function extractPostMeta(block, id) {
		const img = block.querySelector("img.preview");
		const tags = (img && img.getAttribute("title") ? img.getAttribute("title") : "").toLowerCase().split(/\s+/).filter(Boolean);
		const header = block.querySelector(".header");
		const ratingMatch = ((header ? header.textContent : "") || "").match(/Rating\s+(\w+)/i);
		const scoreEl = block.querySelector(`#psc${CSS.escape(id)}`);
		return {
			tags,
			rating: ratingMatch ? ratingMatch[1].toLowerCase() : "",
			score: scoreEl ? parseInt(scoreEl.textContent || "", 10) || 0 : 0
		};
	}
	function postHiddenReason(meta, ctx) {
		if (ctx.aiFilter && meta.tags.includes("ai_generated")) return "AI-generated post";
		if (meta.score < ctx.postThreshold) return `post score ${meta.score} below ${ctx.postThreshold}`;
		if (!ctx.blacklist.length) return null;
		if (ctx.blacklistExact.has("rating:" + meta.rating)) return `blacklisted rating:${meta.rating}`;
		const exact = meta.tags.find((tag) => ctx.blacklistExact.has(tag));
		if (exact) return `blacklisted tag "${exact}"`;
		for (const { token, pattern } of ctx.blacklistWildcards) if (meta.tags.some((tag) => pattern.test(tag))) return `blacklisted tag "${token}"`;
		return null;
	}
	function isCommentHidden(commentEl, ctx) {
		const author = commentEl.querySelector(".author a");
		const user = author ? (author.textContent || "").trim().toLowerCase() : "";
		if (user && ctx.blacklistExact.has("user:" + user)) {
			logSensitive(`removing c${commentEl.id.slice(1)}: author "${user}" is blacklisted (user:)`);
			return true;
		}
		const scoreEl = commentEl.querySelector("[id^=\"csc\"]");
		if (scoreEl) {
			const score = parseInt(scoreEl.textContent || "", 10);
			if (!Number.isNaN(score) && score < ctx.commentThreshold) return true;
		}
		return false;
	}
	function atBottom() {
		return window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - CONFIG.bottomThresholdPx;
	}
	function feedHeadOffset() {
		const first = postBlocks()[0];
		return first ? first.getBoundingClientRect().top : null;
	}
	function atFeedHead(tolerancePx = CONFIG.scrollGatePx) {
		const top = feedHeadOffset();
		return top !== null && Math.abs(top) <= tolerancePx;
	}
	function feedHeadAhead(tolerancePx = CONFIG.scrollGatePx) {
		const top = feedHeadOffset();
		return top !== null && top >= -tolerancePx;
	}
	function captureSeats() {
		const measured = postBlocks().map((block) => ({
			id: block.id,
			top: block.getBoundingClientRect().top
		}));
		let bestIndex = -1;
		let bestDistance = Infinity;
		measured.forEach((entry, i) => {
			const distance = Math.abs(entry.top);
			if (distance < bestDistance) {
				bestDistance = distance;
				bestIndex = i;
			}
		});
		if (bestIndex < 0) return [];
		return measured.slice(bestIndex);
	}
	function seatedOn(seats, block) {
		return !!block && !!seats.length && seats[0].id === block.id;
	}
	function survivingSeats(seats, moved, headBefore) {
		const surviving = seats.filter((seat) => !moved.has(seat.id) && document.getElementById(seat.id));
		if (!surviving.length) return surviving;
		const first = postBlocks()[0];
		const headNow = !!first && first.id === surviving[0].id;
		if (surviving[0].id === seats[0].id) {
			if (headNow && headBefore !== seats[0].id && (seats[0].top ?? 0) > 0) return [{ id: surviving[0].id }, ...surviving.slice(1)];
			return surviving;
		}
		if (headNow) return [{ id: surviving[0].id }, ...surviving.slice(1)];
		return [{
			...surviving[0],
			top: seats[0].top
		}, ...surviving.slice(1)];
	}
	function topmostBlockId() {
		const seats = captureSeats();
		return seats.length ? seats[0].id : null;
	}
	function scrollWindowTo(y) {
		window.scrollTo(0, y);
		state.seatBaseline = window.scrollY;
	}
	function scrollWindowBy(delta) {
		window.scrollBy(0, delta);
		state.seatBaseline = window.scrollY;
	}
	function seatBlock(block) {
		if (!block) return;
		const top = block.getBoundingClientRect().top + window.scrollY - CONFIG.followMarginPx;
		const bottom = document.documentElement.scrollHeight;
		scrollWindowTo(Math.max(0, Math.min(top, bottom)));
	}
	var seatFirstPost = () => seatBlock(postBlocks()[0]);
	var seatLastPost = () => {
		const blocks = postBlocks();
		seatBlock(blocks[blocks.length - 1]);
	};
	var seatBottom = () => scrollWindowTo(document.documentElement.scrollHeight);
	function nextPostBlock() {
		const blocks = postBlocks();
		if (!blocks.length) return null;
		const current = topmostBlockId();
		return blocks[(current ? blocks.findIndex((block) => block.id === current) : -1) + 1] || null;
	}
	function advanceOnePost() {
		const next = nextPostBlock();
		if (!next) {
			if (postBlocks().length) seatBottom();
			return false;
		}
		seatBlock(next);
		return true;
	}
	function seatFrom(candidates) {
		for (const candidate of candidates) {
			if (!candidate || !candidate.id) continue;
			const block = document.getElementById(candidate.id);
			if (!block) continue;
			if (typeof candidate.top === "number") {
				const drift = block.getBoundingClientRect().top - candidate.top;
				if (window.scrollY + drift < 0) seatBlock(block);
				else if (drift) scrollWindowBy(drift);
			} else seatBlock(block);
			return true;
		}
		return false;
	}
	function applySeat(candidates, { hold = false, fallback = seatFirstPost } = {}) {
		const resolve = () => {
			if (!seatFrom(candidates)) fallback();
		};
		resolve();
		if (hold || state.seatHold) holdSeat(resolve);
	}
	function holdSeat(resolve) {
		if (state.seatHold) state.seatHold.retarget(resolve);
		else state.seatHold = installSeatHold(resolve);
	}
	function installSeatHold(resolve) {
		let plan = resolve;
		state.seatBaseline = window.scrollY;
		let queued = false;
		const observer = new ResizeObserver(() => {
			if (queued) return;
			queued = true;
			requestAnimationFrame(() => {
				queued = false;
				plan();
			});
		});
		observer.observe(document.documentElement);
		function onScroll() {
			if (Math.abs(window.scrollY - state.seatBaseline) < CONFIG.scrollGatePx) return;
			state.readerScrolled = true;
			observer.disconnect();
			window.removeEventListener("scroll", onScroll);
			state.seatHold = null;
			log("reader scrolled; feed anchor released");
		}
		window.addEventListener("scroll", onScroll, { passive: true });
		return { retarget(next) {
			plan = next;
		} };
	}
	function installTopRearm() {
		window.addEventListener("scroll", () => {
			if (state.seatHold) return;
			if (window.scrollY > CONFIG.topRearmPx && !atFeedHead(CONFIG.topRearmPx)) return;
			log("reader returned to the feed top; anchor re-armed");
			holdSeat(seatFirstPost);
		}, { passive: true });
	}
	function withPreservedSeat(mutate) {
		const following = atBottom();
		const seats = captureSeats();
		const headBefore = postBlocks()[0]?.id ?? null;
		const plan = mutate({
			following,
			seats
		}) || {};
		if (plan.skip) return;
		const jump = (plan.jump || []).filter((block) => Boolean(block)).map((block) => ({ id: block.id }));
		const keep = survivingSeats(seats, plan.moved ?? new Set(), headBefore);
		const jumpFirst = seatedOn(seats, plan.actedOn);
		const candidates = jumpFirst ? jump.concat(keep) : keep.concat(jump);
		const seat = seats.length ? seats[0].id : null;
		const tipped = keep.length > 0 && keep[0].top === void 0;
		const departure = !seat ? "" : keep.length > 0 && keep[0].id === seat ? tipped ? " (left as the feed head; seated at its tip)" : "" : ` (${plan.moved && plan.moved.has(seat) ? "relocated" : "trimmed"}; inherited by ${keep.length ? keep[0].id + (tipped ? " at its tip" : "") : "nothing, fell back"})`;
		if (jump.length || plan.actedOn || departure) log(`seat: on ${seat || "nothing"}${departure}, acted on ${plan.actedOn ? plan.actedOn.id : "nothing"}; ${jump.length} jump / ${keep.length} keep candidate(s), ${jumpFirst ? "jump" : "keep"} first, following ${following}, hold ${state.seatHold ? "live" : "released"}`);
		applySeat(candidates, {
			...plan,
			fallback: plan.fallback ?? (following ? seatBottom : seatFirstPost)
		});
	}
	var dialogs = new Set();
	function anyDialogOpen() {
		for (const dialog of dialogs) if (!dialog.isConnected) dialogs.delete(dialog);
		else if (dialog.open) return true;
		return false;
	}
	function createShadowDialog(hostId, css, html) {
		const host = document.createElement("div");
		host.id = hostId;
		const root = host.attachShadow({ mode: "open" });
		const style = document.createElement("style");
		style.textContent = css;
		const dialog = document.createElement("dialog");
		dialog.className = "card";
		dialog.innerHTML = html;
		root.append(style, dialog);
		document.body.appendChild(host);
		dialogs.add(dialog);
		return {
			host,
			root,
			dialog,
			$: (sel) => dialog.querySelector(sel)
		};
	}
	var NOTES_KEY = "user_notes";
	var notesDb = null;
	function loadNotesDb() {
		if (!notesDb) notesDb = readJson(NOTES_KEY, {}, "notes db");
		return notesDb;
	}
	function saveNotesDb(db) {
		notesDb = db;
		writeJson(NOTES_KEY, db, "notes db");
	}
	var noteKeyFor = (username) => username.trim().toLowerCase();
	var getNote = (username) => loadNotesDb()[noteKeyFor(username)] || null;
	function setNote(username, note) {
		const db = loadNotesDb();
		const key = noteKeyFor(username);
		if (!note) delete db[key];
		else {
			const prev = db[key];
			db[key] = {
				note,
				color: prev && typeof prev.color === "string" ? prev.color : null,
				updated: new Date().toISOString()
			};
		}
		saveNotesDb(db);
	}
	var NOTE_MODAL_CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
dialog.card {
  border: 1px solid #3a3c40; border-radius: 8px; color: #e6e6e6;
  background: #1f2023; width: min(480px, 90vw);
  padding: 14px 16px; margin: auto;
}
dialog.card::backdrop { background: rgba(0,0,0,.6); }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
h1 { font-size: 15px; margin: 0; }
h1 .uname { color: #f08000; }
.x { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; line-height: 1; }
.x:hover { color: #fff; }
textarea {
  width: 100%; min-height: 110px; resize: vertical;
  border: 1px solid #3a3c40; border-radius: 6px;
  background: #141517; color: #e6e6e6; font-size: 13px; padding: 6px 8px;
}
.hint { color: #888; font-size: 11px; margin: 8px 0 10px; }
.actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn { font-size: 12px; padding: 5px 14px; border-radius: 6px; cursor: pointer;
  border: 1px solid #3a3c40; background: #2a2c30; color: #e6e6e6; }
.btn:hover { background: #34373c; }
.btn.primary { background: #3b6ea5; border-color: #3b6ea5; }
.btn.primary:hover { background: #4279b8; }
.btn.danger { background: #8a2f2f; border-color: #8a2f2f; margin-right: auto; }
.btn.danger:hover { background: #a53b3b; }
`;
	var NOTE_MODAL_HTML = `
      <div class="row">
        <h1>Note for <span class="uname"></span></h1>
        <button class="x" title="Cancel" type="button">&times;</button>
      </div>
      <textarea placeholder="Note text…"></textarea>
      <p class="hint">Saving with an empty note deletes the entry. Esc cancels.</p>
      <div class="actions">
        <button class="btn danger" type="button">Delete</button>
        <button class="btn primary" type="button">Save</button>
      </div>`;
	var noteModal = null;
	function buildNoteModal() {
		if (noteModal) return noteModal;
		const { dialog } = createShadowDialog("gcr-note-modal-host", NOTE_MODAL_CSS, NOTE_MODAL_HTML);
		const modal = {
			dialog,
			uname: dialog.querySelector(".uname"),
			textarea: dialog.querySelector("textarea"),
			username: null
		};
		noteModal = modal;
		dialog.querySelector(".x").addEventListener("click", () => dialog.close());
		dialog.querySelector(".btn.danger").addEventListener("click", () => {
			setNote(modal.username, "");
			dialog.close();
			refreshNoteDecorations();
		});
		const save = () => {
			setNote(modal.username, modal.textarea.value.trim());
			dialog.close();
			refreshNoteDecorations();
		};
		dialog.querySelector(".btn.primary").addEventListener("click", save);
		document.addEventListener("keydown", (e) => {
			if (!dialog.open) return;
			if (e.key === "Enter" && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
				e.preventDefault();
				e.stopImmediatePropagation();
				save();
			}
		}, true);
		return modal;
	}
	var isNoteEditorOpen = () => Boolean(noteModal && noteModal.dialog.open);
	function openNoteEditor(username) {
		hideNoteTooltip();
		const m = buildNoteModal();
		m.username = username;
		m.uname.textContent = username;
		const entry = getNote(username);
		m.textarea.value = entry ? entry.note : "";
		m.dialog.showModal();
		m.textarea.focus();
	}
	var noteTooltip = null;
	function showNoteTooltip(anchor, text) {
		hideNoteTooltip();
		noteTooltip = document.createElement("div");
		noteTooltip.className = "gcr-note-tooltip";
		noteTooltip.textContent = text;
		document.body.appendChild(noteTooltip);
		const r = anchor.getBoundingClientRect();
		noteTooltip.style.left = `${window.scrollX + r.left}px`;
		noteTooltip.style.top = `${window.scrollY + r.bottom + 4}px`;
	}
	function hideNoteTooltip() {
		if (noteTooltip) noteTooltip.remove();
		noteTooltip = null;
	}
	function makeNoteIcon(username) {
		const icon = document.createElement("span");
		icon.className = "gcr-note-icon";
		icon.textContent = "📝";
		icon.addEventListener("click", (e) => {
			e.preventDefault();
			openNoteEditor(username);
		});
		return icon;
	}
	var NOTE_HOVER_MARK = "gcrNoteHover";
	function decorateNote(rec) {
		const user = (rec.author.textContent || "").trim();
		if (!user) return;
		const entry = getNote(user);
		const noted = !!(entry && entry.note);
		rec.author.classList.toggle("gcr-noted", noted);
		if (!rec.author.dataset["gcrNoteHover"]) {
			rec.author.dataset[NOTE_HOVER_MARK] = "1";
			rec.author.addEventListener("mouseenter", () => {
				const cur = getNote(user);
				if (cur && cur.note) showNoteTooltip(rec.author, cur.note);
			});
			rec.author.addEventListener("mouseleave", hideNoteTooltip);
		}
		const holder = rec.author.closest("h6") || rec.author.parentElement;
		let icon = holder.querySelector(".gcr-note-icon");
		if (!icon) {
			icon = makeNoteIcon(user);
			holder.appendChild(icon);
		}
		icon.classList.toggle("gcr-note-ghost", !noted);
	}
	function refreshNoteDecorations() {
		if (IS_PROFILE) {
			decorateProfile();
			return;
		}
		if (!state.listEl) return;
		for (const block of postBlocks()) for (const node of commentNodes(block)) {
			const rec = commentRecord(node);
			if (rec) decorateNote(rec);
		}
	}
	function profileUsername() {
		const h2 = document.querySelector("#content h2");
		return h2 ? (h2.textContent || "").trim() : null;
	}
	function decorateProfile() {
		const username = profileUsername();
		const table = document.querySelector("#content table.highlightable");
		if (!username || !table) return;
		let row = table.querySelector("tr[data-gcr-note]");
		if (!row) {
			row = table.insertRow();
			row.dataset.gcrNote = "1";
			row.innerHTML = "<td><strong>Note</strong></td><td></td>";
		}
		const cell = row.cells[1];
		cell.textContent = "";
		const entry = getNote(username);
		if (entry && entry.note) {
			cell.appendChild(makeNoteIcon(username));
			cell.appendChild(document.createTextNode(` ${entry.note}`));
		} else {
			const span = document.createElement("span");
			const anchor = document.createElement("a");
			anchor.href = "#";
			anchor.textContent = "add";
			anchor.addEventListener("click", (e) => {
				e.preventDefault();
				openNoteEditor(username);
			});
			span.append("(", anchor, ")");
			cell.appendChild(span);
		}
	}
	var MENU_CSS = `
:host { all: initial; }
.menu {
  position: fixed; z-index: 2147483647; display: none;
  margin: 0; padding: 4px; list-style: none;
  border: 1px solid #3a3c40; border-radius: 6px; background: #1f2023;
  box-shadow: 0 8px 24px rgba(0,0,0,.5);
  font-family: system-ui, sans-serif; min-width: 150px;
}
.menu.open { display: block; }
.uname {
  padding: 4px 8px 6px; font-size: 11px; color: #9a9a9a;
  border-bottom: 1px solid #3a3c40; margin-bottom: 4px;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px;
}
.item {
  display: block; padding: 5px 8px; border-radius: 4px;
  font-size: 13px; color: #e6e6e6; text-decoration: none; cursor: pointer;
  white-space: nowrap;
}
.item:hover { background: #2a2c30; }
.item.danger { color: #e88; }
.item.danger:hover { background: #3a2a2c; }
.sep { height: 1px; background: #3a3c40; margin: 4px 0; }
`;
	var openCount = 0;
	function anyMenuOpen() {
		return openCount > 0;
	}
	var openSubjects = new Set();
	function isMenuSubjectBlock(block) {
		return openSubjects.has(block);
	}
	var scrollDismissHeldUntil = 0;
	function holdMenuScrollDismiss(ms) {
		scrollDismissHeldUntil = performance.now() + ms;
	}
	function appendDangerRow(menu, label, onPick) {
		const sep = document.createElement("div");
		sep.className = "sep";
		const el = document.createElement("a");
		el.className = "item danger";
		el.textContent = label;
		el.href = "#";
		el.addEventListener("click", (e) => {
			e.preventDefault();
			onPick();
		});
		menu.append(sep, el);
	}
	function installFeedLinkMenu(flag, selector, open) {
		const host = state.listEl;
		if (!host || host.dataset[flag]) return;
		host.dataset[flag] = "1";
		host.addEventListener("click", (e) => {
			if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
			const target = e.target;
			const link = target && target.closest(selector);
			if (!link || !open(link)) return;
			e.preventDefault();
			e.stopPropagation();
		}, true);
	}
	function createActionMenu(hostId, onClose) {
		const host = document.createElement("div");
		host.id = hostId;
		document.body.appendChild(host);
		const root = host.attachShadow({ mode: "open" });
		const style = document.createElement("style");
		style.textContent = MENU_CSS;
		const menu = document.createElement("div");
		menu.className = "menu";
		const nameEl = document.createElement("div");
		nameEl.className = "uname";
		menu.appendChild(nameEl);
		root.append(style, menu);
		const onMouseDown = (e) => {
			if (e.target !== host) close();
		};
		const onKeyDown = (e) => {
			if (e.key === "Escape") close();
		};
		const onScroll = () => {
			if (performance.now() < scrollDismissHeldUntil) return;
			close();
		};
		let subject = null;
		function close() {
			if (!menu.classList.contains("open")) return;
			menu.classList.remove("open");
			openCount--;
			if (subject) {
				openSubjects.delete(subject);
				subject = null;
			}
			document.removeEventListener("mousedown", onMouseDown, true);
			document.removeEventListener("keydown", onKeyDown, true);
			window.removeEventListener("scroll", onScroll);
			if (onClose) onClose();
		}
		function place(r) {
			menu.style.left = "0px";
			menu.style.top = "0px";
			const { offsetWidth: w, offsetHeight: h } = menu;
			const left = Math.max(4, Math.min(r.left, window.innerWidth - w - 4));
			const below = window.innerHeight - r.bottom;
			const top = below < h + 8 && r.top > below ? r.top - h - 4 : r.bottom + 4;
			menu.style.left = `${left}px`;
			menu.style.top = `${Math.max(4, top)}px`;
		}
		function open(r, subjectEl) {
			document.addEventListener("mousedown", onMouseDown, true);
			document.addEventListener("keydown", onKeyDown, true);
			window.addEventListener("scroll", onScroll, { passive: true });
			if (!menu.classList.contains("open")) openCount++;
			if (subject) openSubjects.delete(subject);
			subject = subjectEl;
			if (subject) openSubjects.add(subject);
			menu.classList.add("open");
			place(r);
		}
		return {
			menu,
			nameEl,
			show(anchorEl) {
				open(anchorEl.getBoundingClientRect(), anchorEl.closest(POST_SEL));
			},
			showAt(x, y, subjectEl) {
				open({
					left: x,
					top: y,
					bottom: y
				}, subjectEl ?? null);
			},
			close
		};
	}
	async function openFavorites(user) {
		const tab = window.open("about:blank", "_blank");
		try {
			const link = (await fetchDocument(profileHref(user))).querySelector("a[href*=\"page=favorites\"][href*=\"s=view\"]");
			if (!link) throw new Error("no favorites link on the profile");
			const url = new URL(link.getAttribute("href"), location.href).href;
			if (tab) tab.location.href = url;
			else window.open(url, "_blank", "noopener");
		} catch (e) {
			if (tab) tab.close();
			warnSensitive(`favorites lookup for "${user}" failed: ${e instanceof Error ? e.message : e}`);
		}
	}
	var normalizeNames = (raw) => Array.from(new Set(raw.filter((s) => typeof s === "string").map((s) => s.trim().toLowerCase()))).filter(Boolean);
	function flag(on) {
		return on ? "✅" : "❌";
	}
	var entries = [];
	var painted = false;
	var idsHonoured = null;
	function addMenuCommand(id, caption, run, opts = {}) {
		entries.push({
			id,
			caption,
			run,
			keepOpen: opts.keepOpen === true,
			shown: null,
			handle: null
		});
	}
	function paint(entry) {
		const caption = entry.caption();
		entry.handle = GM_registerMenuCommand(caption, () => {
			entry.run();
			refreshMenuCommands();
		}, {
			id: entry.id,
			autoClose: !entry.keepOpen
		});
		entry.shown = caption;
		if (idsHonoured === null) idsHonoured = entry.handle === entry.id;
	}
	function repaintAll() {
		for (const entry of entries) {
			if (entry.handle !== null) GM_unregisterMenuCommand(entry.handle);
			entry.handle = null;
		}
		for (const entry of entries) paint(entry);
	}
	function paintMenuCommands() {
		painted = true;
		for (const entry of entries) paint(entry);
	}
	function refreshMenuCommands() {
		if (!painted) return;
		const stale = entries.filter((entry) => entry.shown !== entry.caption());
		if (!stale.length) return;
		if (idsHonoured) {
			for (const entry of stale) paint(entry);
			return;
		}
		if (typeof GM_unregisterMenuCommand !== "function") return;
		repaintAll();
	}
	var TOASTS_KEY = "toasts_enabled";
	var TOAST_CSS = `
:host { all: initial; }
.stack {
  position: fixed; left: 16px; bottom: 16px; z-index: 2147483646;
  display: flex; flex-direction: column; gap: 8px; align-items: flex-start;
  pointer-events: none; font-family: system-ui, sans-serif;
}
.toast {
  max-width: 340px; padding: 8px 12px;
  border: 1px solid #3a3c40; border-radius: 6px;
  background: #1f2023; color: #e6e6e6;
  font-size: 12px; line-height: 1.4;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
  overflow-wrap: break-word;
  opacity: 0; transform: translateY(6px);
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.toast.shown { opacity: 1; transform: none; }
.toast.actionable { pointer-events: auto; }
.act {
  margin-left: 8px; padding: 0; border: 0; background: none;
  color: #6ea8fe; font: inherit; text-decoration: none; cursor: pointer;
}
.act:hover { text-decoration: underline; }
/* Deep-paging indicator: a member of the toast stack pinned visually last —
   flex order, not DOM order — so toasts flow above it, never over it. */
.gcr-paging-indicator {
  order: 1;
  display: flex; align-items: center; gap: 8px;
  padding: 7px 14px;
  border: 1px solid #3a3c40; border-radius: 999px;
  background: #1f2023; color: #e6e6e6;
  font: 12px/1 system-ui, sans-serif;
  pointer-events: none;
}
.gcr-paging-indicator .gcr-spinner {
  width: 12px; height: 12px;
  border: 2px solid #3a3c40; border-top-color: #e6e6e6;
  border-radius: 50%;
  animation: gcr-spin 0.8s linear infinite;
}
@keyframes gcr-spin {
  to { transform: rotate(360deg); }
}
`;
	var stack = null;
	function isToastsEnabled() {
		return GM_getValue(TOASTS_KEY, true) !== false;
	}
	function ensureStack() {
		if (stack && stack.isConnected) return stack;
		const host = document.createElement("div");
		host.id = "gcr-toast-host";
		const root = host.attachShadow({ mode: "open" });
		const style = document.createElement("style");
		style.textContent = TOAST_CSS;
		stack = document.createElement("div");
		stack.className = "stack";
		root.append(style, stack);
		document.body.appendChild(host);
		return stack;
	}
	function showToast(message, action) {
		const parent = ensureStack();
		let live = parent.querySelectorAll(".toast");
		while (live.length >= CONFIG.toastMaxVisible) {
			live[0].remove();
			live = parent.querySelectorAll(".toast");
		}
		const el = document.createElement("div");
		el.className = action ? "toast actionable" : "toast";
		if (action) {
			const text = document.createElement("span");
			text.textContent = message;
			const button = document.createElement("button");
			button.type = "button";
			button.className = "act";
			button.textContent = action.label;
			let ran = false;
			button.addEventListener("click", () => {
				if (ran) return;
				ran = true;
				try {
					action.run();
				} finally {
					el.remove();
				}
			});
			el.append(text, button);
		} else el.textContent = message;
		parent.appendChild(el);
		requestAnimationFrame(() => el.classList.add("shown"));
		window.setTimeout(() => {
			el.classList.remove("shown");
			el.addEventListener("transitionend", () => el.remove(), { once: true });
			window.setTimeout(() => el.remove(), 1e3);
		}, CONFIG.toastDurationMs * (action ? 2 : 1));
	}
	function toast(message, action) {
		if (!isToastsEnabled()) return;
		showToast(message, action);
	}
	function toggleToasts() {
		const next = !isToastsEnabled();
		GM_setValue(TOASTS_KEY, next);
		logSensitive(`toasts ${next ? "on" : "off"}`);
		showToast(`toasts ${next ? "on" : "off"}`, {
			label: "undo",
			run: toggleToasts
		});
		refreshMenuCommands();
	}
	function announce(message, action) {
		logSensitive(message);
		toast(message, action);
	}
	var NAMED_LIMIT = 3;
	function describe(verb, entries, show) {
		if (entries.length > NAMED_LIMIT) return `${verb} ${entries.length} entries`;
		return `${verb} ${entries.map((e) => `"${show(e)}"`).join(", ")}`;
	}
	function announceListChange(label, before, after, show = (entry) => entry, undo) {
		const prev = new Set(before);
		const next = new Set(after);
		const added = [...next].filter((e) => !prev.has(e));
		const removed = [...prev].filter((e) => !next.has(e));
		if (!added.length && !removed.length) return;
		const parts = [];
		if (added.length) parts.push(describe("added", added, show));
		if (removed.length) parts.push(describe("removed", removed, show));
		announce(`${label}: ${parts.join("; ")} (${next.size} on the list)`, undo ? {
			label: "undo",
			run: undo
		} : void 0);
	}
	var indicator = null;
	function showPagingIndicator(label) {
		if (!indicator || !indicator.isConnected) {
			indicator = document.createElement("div");
			indicator.className = "gcr-paging-indicator";
			const spinner = document.createElement("span");
			spinner.className = "gcr-spinner";
			indicator.append(spinner, document.createElement("span"));
			ensureStack().appendChild(indicator);
		}
		indicator.lastElementChild.textContent = label;
		indicator.style.display = "";
	}
	function hidePagingIndicator() {
		if (indicator) indicator.style.display = "none";
	}
	var USERS_KEY = "user_filter";
	function loadFilteredUsers() {
		const parsed = readJson(USERS_KEY, [], "user filter");
		return Array.isArray(parsed) ? normalizeNames(parsed) : [];
	}
	var filteredUsers = new Set(loadFilteredUsers());
	var getFilteredUsers = () => filteredUsers;
	function setFilteredUsers(users) {
		const prev = Array.from(filteredUsers);
		const next = normalizeNames(users);
		if (writeJson("user_filter", next, "user filter")) announceListChange("user filter", filteredUsers, next, void 0, () => {
			setFilteredUsers(prev);
			reapplySeatedFilters();
		});
		filteredUsers = new Set(next);
	}
	function addFilteredUser(name) {
		const user = (name || "").trim().toLowerCase();
		if (!user || filteredUsers.has(user)) return false;
		setFilteredUsers(Array.from(filteredUsers).concat(user));
		return true;
	}
	function filterAuthorFromComment(node) {
		if (!node) return;
		const rec = commentRecord(node);
		if (!rec) return;
		const user = (rec.author.textContent || "").trim();
		if (!user) return;
		const target = user.toLowerCase();
		const acted = node.closest(POST_SEL);
		withPreservedSeat(() => {
			const soleAuthor = !!acted && commentNodes(acted).every((el) => {
				const r = commentRecord(el);
				return (r && r.author.textContent || "").trim().toLowerCase() === target;
			});
			const blocks = postBlocks();
			const i = acted ? blocks.indexOf(acted) : -1;
			const jump = soleAuthor && i >= 0 ? blocks.slice(i + 1).concat(acted) : [];
			if (!addFilteredUser(user)) return { skip: true };
			if (state.listEl) reapplyFilters();
			if (!jump.length) return {};
			return {
				jump,
				actedOn: acted,
				fallback: seatLastPost
			};
		});
	}
	var MENU_MARK = "gcrMenu";
	var profileHref = (u) => `/index.php?page=account&s=profile&uname=${encodeURIComponent(u)}`;
	var USER_ACTIONS = [
		{
			label: "Comments",
			href: (u) => `/index.php?page=comment&s=user&user=${encodeURIComponent(u)}`
		},
		{ label: "Favorites" },
		{
			label: "Profile",
			href: profileHref
		},
		{
			label: "Message",
			href: (u) => `/index.php?page=gmail&s=send_pm&pm_user=${encodeURIComponent(u)}`
		}
	];
	function appendUserRows(menu, close, getUser) {
		const rows = USER_ACTIONS.map((action) => {
			const a = document.createElement("a");
			a.className = "item";
			a.textContent = action.label;
			if (action.href) {
				a.target = "_blank";
				a.rel = "noopener";
				a.addEventListener("click", () => close());
			} else {
				a.href = "#";
				a.addEventListener("click", (e) => {
					e.preventDefault();
					const user = getUser();
					close();
					if (user) openFavorites(user);
				});
			}
			menu.appendChild(a);
			return {
				action,
				el: a
			};
		});
		const noteEl = document.createElement("a");
		noteEl.className = "item";
		noteEl.textContent = "Note";
		noteEl.href = "#";
		noteEl.addEventListener("click", (e) => {
			e.preventDefault();
			const user = getUser();
			close();
			if (user) openNoteEditor(user);
		});
		menu.appendChild(noteEl);
		return (user) => {
			for (const row of rows) if (row.action.href) row.el.href = row.action.href(user);
		};
	}
	var _menu$3 = null;
	function buildUserMenu() {
		if (_menu$3) return _menu$3;
		let currentNode = null;
		let currentUser = null;
		const shell = createActionMenu("gcr-user-menu-host", () => {
			currentNode = null;
			currentUser = null;
		});
		const { menu, nameEl } = shell;
		const close = () => shell.close();
		const setUser = appendUserRows(menu, close, () => currentUser);
		appendDangerRow(menu, "Filter", () => {
			const node = currentNode;
			close();
			filterAuthorFromComment(node);
		});
		_menu$3 = { open(user, anchorEl, node) {
			currentNode = node || null;
			currentUser = user;
			nameEl.textContent = user;
			setUser(user);
			shell.show(anchorEl);
		} };
		return _menu$3;
	}
	function bindUserMenu(rec) {
		if (rec.author.dataset["gcrMenu"]) return;
		const node = rec.node;
		rec.author.dataset[MENU_MARK] = "1";
		rec.author.removeAttribute("href");
		rec.author.setAttribute("role", "button");
		rec.author.setAttribute("tabindex", "0");
		rec.author.addEventListener("click", (e) => {
			e.preventDefault();
			e.stopPropagation();
			const user = (rec.author.textContent || "").trim();
			hideNoteTooltip();
			if (user) buildUserMenu().open(user, rec.author, node);
		});
		rec.author.addEventListener("mousedown", (e) => {
			if (e.button === 1) e.preventDefault();
		});
		rec.author.addEventListener("auxclick", (e) => {
			if (e.button !== 1) return;
			e.preventDefault();
			const user = (rec.author.textContent || "").trim();
			if (user) window.open(profileHref(user), "_blank", "noopener");
		});
	}
	function entriesOfShape(value, strings, numbers) {
		if (!Array.isArray(value)) return [];
		return value.filter((entry) => {
			if (!entry || typeof entry !== "object") return false;
			const rec = entry;
			return strings.every((key) => typeof rec[key] === "string") && numbers.every((key) => typeof rec[key] === "number" && Number.isFinite(rec[key]));
		});
	}
	function loadFeedCache() {
		const parsed = readJson(FEED_CACHE_KEY, null, "feed cache");
		if (!parsed || !Array.isArray(parsed.posts)) return null;
		const posts = entriesOfShape(parsed.posts, ["id", "html"], ["latestTs"]);
		const suppressedComments = entriesOfShape(parsed.suppressedComments, ["postId", "html"], ["ts", "at"]);
		const suppressedPosts = entriesOfShape(parsed.suppressedPosts, ["id", "html"], ["latestTs", "at"]);
		const anchorId = typeof parsed.anchorId === "string" ? parsed.anchorId : null;
		return {
			savedAt: typeof parsed.savedAt === "number" && Number.isFinite(parsed.savedAt) ? parsed.savedAt : 0,
			anchorId,
			posts,
			suppressedComments,
			suppressedPosts
		};
	}
	function persistFeedCache() {
		pruneSuppressed();
		const posts = postBlocks().map((block) => ({
			id: postId(block),
			latestTs: latestCommentTs(block),
			html: block.outerHTML
		}));
		if (writeJson("feed_cache", {
			savedAt: Date.now(),
			anchorId: topmostBlockId(),
			posts,
			suppressedComments: state.suppressed.comments,
			suppressedPosts: state.suppressed.posts
		}, "feed cache")) log(`persisted ${posts.length} post(s) to feed cache`);
	}
	function installFeedCachePersist() {
		const persist = () => {
			if (state.listEl) persistFeedCache();
		};
		window.addEventListener("pagehide", persist);
		document.addEventListener("visibilitychange", () => {
			if (document.hidden) persist();
		});
	}
	function parseCachedBlock(html) {
		const tpl = document.createElement("template");
		tpl.innerHTML = html;
		const el = tpl.content.firstElementChild;
		if (!el || !el.classList.contains("post") || !el.id.startsWith("p")) return null;
		const block = document.importNode(el, true);
		resetCachedCommentDecorations(block);
		invalidateBlockTs(block);
		resetThumbnails(block);
		return block;
	}
	function resetCachedCommentDecorations(root) {
		for (const a of root.querySelectorAll(".author h6 a")) {
			delete a.dataset[MENU_MARK];
			delete a.dataset[HL_MARK];
			delete a.dataset[NOTE_HOVER_MARK];
			a.style.removeProperty("color");
		}
		for (const stale of root.querySelectorAll(".gcr-note-icon")) stale.remove();
		if (root instanceof Element) root.classList.remove("gcr-today");
		for (const marked of root.querySelectorAll(".gcr-today")) marked.classList.remove("gcr-today");
	}
	function resetThumbnails(block) {
		for (const img of block.querySelectorAll("img")) for (const key of Object.keys(img.dataset)) {
			if (/thumb.?src/i.test(key) && img.dataset[key]) img.src = img.dataset[key];
			delete img.dataset[key];
		}
	}
	function insertCommentNodeOrdered(list, node) {
		const id = Number(node.id.slice(1));
		for (const child of Array.from(list.children)) if (child.id && child.id.startsWith("c") && Number(child.id.slice(1)) < id) {
			list.insertBefore(node, child);
			return;
		}
		list.appendChild(node);
	}
	function insertBlockByTime(block, latestTs) {
		for (const existing of postBlocks()) if (latestCommentTs(existing) > latestTs) {
			state.listEl.insertBefore(block, existing);
			return;
		}
		state.listEl.appendChild(block);
	}
	function restoreCachedPost(cached, budget, ctx, tally) {
		const block = parseCachedBlock(cached.html);
		if (!block) {
			tally.malformed++;
			return {
				comments: 0,
				newPost: false
			};
		}
		return mergePostBlock(block, cached.id, budget, ctx, tally, {
			archiveRefused: true,
			latestTs: cached.latestTs
		});
	}
	function newPostBudget() {
		return { n: CONFIG.postCacheLimit - postBlocks().length };
	}
	function mergePostBlock(block, id, budget, ctx, tally, opts = {}) {
		const existing = findPostBlock(id);
		if (!existing && budget.n <= 0) return {
			comments: 0,
			newPost: false
		};
		const targetList = existing ? existing.querySelector(RESPONSES_SEL) : block.querySelector(RESPONSES_SEL);
		if (!targetList) {
			tally.malformed++;
			return {
				comments: 0,
				newPost: false
			};
		}
		const measured = opts.latestTs === void 0 ? latestCommentTs(block) : null;
		const offered = commentNodes(block);
		if (opts.archiveRefused && !existing && offered.length) {
			const reason = postRefusalReason(block, id, ctx);
			if (reason) {
				tally[reason] += offered.length;
				archivePost(block, opts.latestTs);
				return {
					comments: 0,
					newPost: false
				};
			}
		}
		const accepted = [];
		const refused = [];
		let removed = 0;
		for (const node of offered) {
			const reason = admitComment(node, block, id, ctx);
			if (reason) {
				tally[reason]++;
				if (opts.archiveRefused && archivedRefusal(reason)) archiveComment(node, id, refused);
				if (reason === "removed") removed++;
				continue;
			}
			accepted.push(node);
		}
		if (!accepted.length) {
			archiveCommentEntries(refused);
			return {
				comments: 0,
				newPost: false
			};
		}
		if (existing) {
			archiveCommentEntries(refused);
			for (const node of accepted) {
				insertCommentNodeOrdered(targetList, node);
				decorateComment(node);
			}
			invalidateBlockTs(existing);
			return {
				comments: accepted.length,
				newPost: false
			};
		}
		const keep = new Set(accepted);
		for (const node of commentNodes(block)) if (!keep.has(node)) node.remove();
		if (opts.staleGate && dropStaleBlock(block, removed)) return {
			comments: 0,
			newPost: false,
			stale: true
		};
		archiveCommentEntries(refused);
		replayInlineScripts(block);
		insertBlockByTime(block, opts.latestTs ?? measured);
		if (measured !== null) invalidateBlockTs(block);
		for (const node of accepted) decorateComment(node);
		budget.n--;
		return {
			comments: accepted.length,
			newPost: true
		};
	}
	function archivedRefusal(reason) {
		return [
			"blocked",
			"uploader",
			"post",
			"comment",
			"removed"
		].includes(reason);
	}
	function restoreBackfill(freshMinTs, cache) {
		if (!cache) return {
			anchorId: null,
			anchorAtStart: false
		};
		const seat = {
			anchorId: cache.anchorId,
			anchorAtStart: Boolean(cache.anchorId) && cache.anchorId === cache.posts[0]?.id
		};
		const budget = newPostBudget();
		const floor = freshMinTs - CONFIG.backfillMaxAgeMs;
		const ctx = filterContext();
		const candidates = cache.posts.filter((p) => p.latestTs >= floor).sort((a, b) => b.latestTs - a.latestTs);
		let comments = 0;
		let posts = 0;
		const tally = newTally();
		for (const cached of candidates) {
			const res = restoreCachedPost(cached, budget, ctx, tally);
			comments += res.comments;
			if (res.newPost) posts++;
		}
		log(`backfill: restored ${comments} comment(s), ${posts} new post(s) from ${candidates.length} candidate(s); refused ${tallyText(tally)}; ${state.renderedComments.size} cached`);
		return seat;
	}
	var COMMENT_LIMIT = 300;
	var POST_LIMIT = 60;
	var MAX_AGE_MS = 864e5;
	function loadSuppressed(cache) {
		state.suppressed = {
			comments: cache ? cache.suppressedComments.slice() : [],
			posts: cache ? cache.suppressedPosts.slice() : []
		};
	}
	function archiveComment(node, id, target = state.suppressed.comments) {
		const rec = commentRecord(node);
		const parsed = rec && parseSiteTimestamp(rec.dateText);
		const measured = parsed ? parsed.getTime() : 0;
		const ts = Number.isFinite(measured) ? measured : 0;
		target.push({
			postId: id,
			html: node.outerHTML,
			ts,
			at: Date.now()
		});
	}
	function snapshotSuppressedPost(block) {
		const html = block.outerHTML;
		return {
			id: postId(block),
			latestTs: latestCommentTs(block),
			html,
			at: Date.now()
		};
	}
	function archivePostEntry(entry) {
		state.suppressed.posts.push(entry);
	}
	function archivePost(block, latestTs) {
		const entry = snapshotSuppressedPost(block);
		if (latestTs !== void 0) entry.latestTs = latestTs;
		archivePostEntry(entry);
	}
	function archiveCommentEntries(entries) {
		state.suppressed.comments.push(...entries);
	}
	function keepNewest(entries, limit, now) {
		const floor = now - MAX_AGE_MS;
		const kept = entries.filter((entry) => entry.at >= floor);
		kept.sort((a, b) => b.at - a.at);
		return kept.slice(0, limit);
	}
	function pruneSuppressed(now = Date.now()) {
		state.suppressed.comments = keepNewest(state.suppressed.comments, COMMENT_LIMIT, now);
		state.suppressed.posts = keepNewest(state.suppressed.posts, POST_LIMIT, now);
	}
	function parseSuppressedComment(html) {
		const tpl = document.createElement("template");
		tpl.innerHTML = html;
		const el = tpl.content.firstElementChild;
		if (!el || !el.classList.contains("post") || !el.id.startsWith("c")) return null;
		const node = document.importNode(el, true);
		resetCachedCommentDecorations(node);
		return node;
	}
	function restoreSuppressedComments(comments, ctx, tally) {
		const byPost = new Map();
		for (const entry of comments) {
			const group = byPost.get(entry.postId);
			if (group) group.push(entry);
			else byPost.set(entry.postId, [entry]);
		}
		let restored = 0;
		for (const [id, entries] of byPost) {
			const block = findPostBlock(id);
			const list = block && block.querySelector(".response-list");
			if (!block || !list) {
				state.suppressed.comments.push(...entries);
				continue;
			}
			for (const entry of entries) {
				const node = parseSuppressedComment(entry.html);
				if (!node) {
					tally.malformed++;
					continue;
				}
				const reason = admitComment(node, block, id, ctx);
				if (!reason) {
					insertCommentNodeOrdered(list, node);
					invalidateBlockTs(block);
					decorateComment(node);
					logSensitive(`restoring c${node.id.slice(1)} on p${id}`);
					restored++;
					continue;
				}
				tally[reason]++;
				if (reason !== "duplicate" && reason !== "malformed") state.suppressed.comments.push(entry);
			}
		}
		return restored;
	}
	function restoreSuppressed() {
		pruneSuppressed();
		const ctx = filterContext();
		const budget = newPostBudget();
		const tally = newTally();
		let restoredComments = 0;
		let restoredPosts = 0;
		const pending = state.suppressed.comments;
		state.suppressed.comments = [];
		const posts = state.suppressed.posts;
		state.suppressed.posts = [];
		for (const entry of posts) {
			const block = parseCachedBlock(entry.html);
			if (!block || postId(block) !== entry.id) {
				tally.malformed++;
				continue;
			}
			const reason = postRefusalReason(block, entry.id, ctx);
			if (reason) {
				tally[reason]++;
				state.suppressed.posts.push(entry);
				continue;
			}
			if (!findPostBlock(entry.id) && budget.n <= 0) {
				state.suppressed.posts.push(entry);
				continue;
			}
			const result = mergePostBlock(block, entry.id, budget, ctx, tally, {
				archiveRefused: true,
				latestTs: entry.latestTs,
				staleGate: true
			});
			if (result.stale) {
				state.suppressed.posts.push(entry);
				continue;
			}
			restoredComments += result.comments;
			if (result.newPost) {
				logSensitive(`restoring post p${entry.id}: ${result.comments} comment(s)`);
				restoredPosts++;
			}
		}
		restoredComments += restoreSuppressedComments(pending, ctx, tally);
		pruneSuppressed();
		const retained = state.suppressed.comments.length + state.suppressed.posts.length;
		log(`suppressed: restored ${restoredComments} comment(s), ${restoredPosts} post(s); ${retained} entries retained`);
	}
	function reapplyFilters() {
		let removedComments = 0;
		let removedPosts = 0;
		const ctx = filterContext();
		for (const block of postBlocks()) {
			if (postRefusalReason(block, postId(block), ctx)) {
				archivePost(block);
				dropBlock(block);
				removedPosts++;
				continue;
			}
			const pruned = pruneBlock(block, ctx);
			removedComments += pruned.removed;
			if (pruned.blockRemoved) {
				removedPosts++;
				continue;
			}
			for (const node of commentNodes(block)) decorateComment(node);
		}
		log(`re-applied filters: ${removedComments} comment(s) and ${removedPosts} post(s) removed; ${state.renderedComments.size} cached`);
		restoreSuppressed();
	}
	function reapplySeatedFilters() {
		if (!state.listEl) return;
		withPreservedSeat(() => void reapplyFilters());
	}
	var HL_KEY = "highlight";
	var HL_MARK = "gcrHl";
	var COLOR_CHOICES = {
		orange: "#f08000",
		green: "#00a000",
		darkRed: "#a00000",
		darkPurple: "#a000a0",
		darkBlue: "#000090",
		lightYellow: "#f0f0a0",
		lightGreen: "#b0e0b0",
		lightRed: "#f0a0a0",
		lightBlue: "#90d9ed",
		lightPurple: "#f0a0f0",
		purple: "#9c64a6"
	};
	var COLOR_BY_HEX = new Map(Object.entries(COLOR_CHOICES).map(([name, hex]) => [hex.toLowerCase(), name]));
	var HL_DEFAULTS = {
		filters: {
			positive: {
				names: [],
				content: []
			},
			negative: {
				names: [],
				content: []
			}
		},
		colors: { positive: COLOR_CHOICES.green },
		recent: false
	};
	var HEX_RE = /^#[0-9a-f]{6}$/i;
	var strArr = (v) => Array.isArray(v) ? v.filter((s) => typeof s === "string") : [];
	var colorOr = (v, d) => typeof v === "string" && HEX_RE.test(v) ? v : d;
	function sanitizeConfig(s) {
		const src = s && typeof s === "object" ? s : {};
		const f = src.filters || {};
		const pick = (side) => {
			const entry = f[side];
			return {
				names: strArr(entry && entry.names),
				content: strArr(entry && entry.content)
			};
		};
		const colors = src.colors;
		return {
			filters: {
				positive: pick("positive"),
				negative: pick("negative")
			},
			colors: { positive: colorOr(colors && colors.positive, HL_DEFAULTS.colors.positive) },
			recent: typeof src.recent === "boolean" ? src.recent : HL_DEFAULTS.recent
		};
	}
	function loadConfig() {
		return sanitizeConfig(readJson(HL_KEY, {}, "filter config"));
	}
	function saveConfig(cfg) {
		const prev = hlConfig;
		if (writeJson("highlight", cfg, "filter config")) {
			const { positive, negative } = cfg.filters;
			announce(`comment filter saved: ${positive.names.length + positive.content.length} highlight, ${negative.names.length + negative.content.length} removal pattern(s)`, {
				label: "undo",
				run: () => {
					saveConfig(prev);
					setLiveFilters(prev, compileFilters(prev).compiled);
					reapplySeatedFilters();
				}
			});
		}
	}
	function compileOne(src) {
		const m = /^\/(.*)\/([a-z]*)$/is.exec(src);
		const pattern = m ? m[1] : src;
		const flags = (m ? m[2] : "").replace(/[gy]/g, "");
		return new RegExp(pattern, flags);
	}
	function compileFilters(cfg) {
		const errors = [];
		const list = (lines, label) => lines.map((src) => {
			try {
				return compileOne(src);
			} catch (e) {
				errors.push({
					label,
					src,
					message: e instanceof Error ? e.message : String(e)
				});
				return null;
			}
		}).filter((re) => Boolean(re));
		return {
			compiled: {
				positive: {
					names: list(cfg.filters.positive.names, "positive names"),
					content: list(cfg.filters.positive.content, "positive content")
				},
				negative: {
					names: list(cfg.filters.negative.names, "remove names"),
					content: list(cfg.filters.negative.content, "remove content")
				}
			},
			errors
		};
	}
	var anyMatch = (regexes, text) => regexes.some((re) => re.test(text));
	var firstMatch = (regexes, text) => regexes.find((re) => re.test(text)) ?? null;
	var hlConfig = loadConfig();
	var hlCompiled = compileFilters(hlConfig).compiled;
	var getHlConfig = () => hlConfig;
	var getHlCompiled = () => hlCompiled;
	function setLiveFilters(cfg, compiled) {
		hlConfig = cfg;
		hlCompiled = compiled;
	}
	function parseSiteTimestamp(text) {
		const m = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(text || "");
		if (!m) return null;
		const [, y, mo, d, h, mi, s] = m;
		return new Date(Date.UTC(+y, +mo - 1, +d, +h - 1, +mi, +(s || 0)));
	}
	function isToday(d) {
		const now = new Date();
		return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
	}
	function isTodayComment(rec) {
		if (!rec) return false;
		const d = parseSiteTimestamp(rec.dateText);
		return !!(d && isToday(d));
	}
	function markTodayComment(rec) {
		rec.node.classList.toggle("gcr-today", getHlConfig().recent && isTodayComment(rec));
	}
	var postId = (el) => el.id.slice(1);
	var POST_SEL = "div.post[id^=\"p\"]";
	var RESPONSES_SEL = ".response-list";
	var THUMB_SEL = "img.preview";
	var TAG_LINK_SEL = "div.tags span[class*=\"tag-type-\"] a[href]";
	var UPLOADER_LINK_SEL = "div.header span.info a[href*=\"tags=user:\"]";
	function uploaderOf(block) {
		const link = block.querySelector(UPLOADER_LINK_SEL);
		return link ? (link.textContent || "").trim() : "";
	}
	function postBlocks() {
		return Array.from(state.listEl.children).filter((el) => el.classList.contains("post") && el.id.startsWith("p"));
	}
	function blocksBelow(block) {
		const blocks = postBlocks();
		const i = blocks.indexOf(block);
		return i >= 0 ? blocks.slice(i + 1) : [];
	}
	function findPostBlock(id) {
		return state.listEl.querySelector(`:scope > div.post#p${CSS.escape(id)}`) || null;
	}
	function commentNodes(block) {
		const list = block.querySelector(RESPONSES_SEL);
		if (!list) return [];
		return Array.from(list.children).filter((el) => el.id.startsWith("c"));
	}
	function latestCommentTs(block) {
		const memo = Number(block.dataset.gcrLatestTs);
		if (Number.isFinite(memo) && block.dataset.gcrLatestTs) return memo;
		let best = 0;
		for (const node of commentNodes(block)) {
			const rec = commentRecord(node);
			const d = rec && parseSiteTimestamp(rec.dateText);
			if (d) best = Math.max(best, d.getTime());
		}
		block.dataset.gcrLatestTs = String(best);
		return best;
	}
	function invalidateBlockTs(block) {
		if (block) delete block.dataset.gcrLatestTs;
	}
	function dropComment(node) {
		state.renderedComments.delete(node.id.slice(1));
		invalidateBlockTs(node.closest(POST_SEL));
		node.remove();
	}
	function dropStaleBlock(block, removed) {
		const survivors = commentNodes(block);
		const stale = removed > 0 && !survivors.some((node) => isTodayComment(commentRecord(node)));
		if (survivors.length && !stale) return false;
		log(`removing post p${postId(block)}: ${removed} comment(s) removed, ${survivors.length} left, none from today`);
		dropBlock(block);
		return true;
	}
	function dropBlock(block) {
		for (const node of commentNodes(block)) dropComment(node);
		block.remove();
	}
	function pruneBlock(block, ctx) {
		let removed = 0;
		let snapshot = null;
		const suppressed = [];
		const capture = (node) => {
			if (!snapshot) snapshot = snapshotSuppressedPost(block);
			archiveComment(node, postId(block), suppressed);
		};
		for (const node of commentNodes(block)) {
			if (ctx && isCommentHidden(node, ctx)) {
				capture(node);
				dropComment(node);
				continue;
			}
			if (!isCommentRemoved(commentRecord(node), postId(block))) continue;
			capture(node);
			dropComment(node);
			removed++;
		}
		const blockRemoved = dropStaleBlock(block, removed);
		if (blockRemoved) {
			if (snapshot) archivePostEntry(snapshot);
		} else archiveCommentEntries(suppressed);
		return {
			removed,
			blockRemoved
		};
	}
	function replayInlineScripts(block) {
		for (const stale of block.querySelectorAll("script")) {
			const code = stale.textContent || "";
			stale.remove();
			if (!code.trim()) continue;
			const fresh = document.createElement("script");
			fresh.textContent = code;
			document.head.appendChild(fresh);
			fresh.remove();
		}
	}
	function getDeepActiveElement() {
		let el = document.activeElement;
		while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement;
		return el;
	}
	function isTypingTarget() {
		const active = getDeepActiveElement();
		return !!(active && (active.isContentEditable || [
			"INPUT",
			"TEXTAREA",
			"SELECT"
		].includes(active.tagName)));
	}
	function lastHovered(selector) {
		if (!state.listEl) return null;
		const hovered = state.listEl.querySelectorAll(selector);
		return hovered.length ? hovered[hovered.length - 1] : null;
	}
	function hoveredComment() {
		return lastHovered(`${POST_SEL} [id^="c"]:hover`);
	}
	function hoveredThumbBlock() {
		const img = lastHovered(`${THUMB_SEL}:hover`);
		return img ? img.closest(POST_SEL) : null;
	}
	function hoveredWithinFirstPosts(count) {
		const block = lastHovered(`:scope > ${POST_SEL}:hover`);
		return !!block && postBlocks().indexOf(block) < count;
	}
	function hoveredCommentOrSole() {
		const node = hoveredComment();
		if (node) return node;
		const block = lastHovered(`:scope > ${POST_SEL}:hover`);
		if (!block) return null;
		const nodes = commentNodes(block);
		return nodes.length === 1 ? nodes[0] : null;
	}
	var POSTS_KEY = "post_blacklist";
	function toPostId(raw) {
		if (typeof raw !== "string" && typeof raw !== "number") return null;
		const id = String(raw).trim().replace(/^p/i, "");
		return /^\d+$/.test(id) ? id : null;
	}
	var postHref = (id) => `/index.php?page=post&s=view&id=${encodeURIComponent(id)}`;
	var toPostIds = (raw) => Array.from(new Set(raw.map(toPostId).filter((id) => id !== null)));
	function loadBlockedPosts() {
		const parsed = readJson(POSTS_KEY, [], "post blacklist");
		return Array.isArray(parsed) ? toPostIds(parsed) : [];
	}
	var blockedPosts = new Set(loadBlockedPosts());
	var getBlockedPosts = () => blockedPosts;
	var isPostBlocked = (id) => blockedPosts.has(id);
	function setBlockedPosts(ids) {
		const prev = Array.from(blockedPosts);
		const next = toPostIds(ids);
		if (writeJson("post_blacklist", next, "post blacklist")) announceListChange("post blacklist", blockedPosts, next, (id) => `p${id}`, () => {
			setBlockedPosts(prev);
			reapplySeatedFilters();
		});
		blockedPosts = new Set(next);
	}
	function addBlockedPost(id) {
		const post = toPostId(id);
		if (!post || blockedPosts.has(post)) return false;
		setBlockedPosts(Array.from(blockedPosts).concat(post));
		return true;
	}
	var UPLOADERS_KEY = "uploader_blacklist";
	function loadBlockedUploaders() {
		const parsed = readJson(UPLOADERS_KEY, [], "uploader blacklist");
		return Array.isArray(parsed) ? normalizeNames(parsed) : [];
	}
	var blockedUploaders = new Set(loadBlockedUploaders());
	var getBlockedUploaders = () => blockedUploaders;
	var isUploaderBlocked = (name) => blockedUploaders.has(name.trim().toLowerCase());
	function setBlockedUploaders(users) {
		const prev = Array.from(blockedUploaders);
		const next = normalizeNames(users);
		if (writeJson("uploader_blacklist", next, "uploader blacklist")) announceListChange("uploader blacklist", blockedUploaders, next, void 0, () => {
			setBlockedUploaders(prev);
			reapplySeatedFilters();
		});
		blockedUploaders = new Set(next);
	}
	function addBlockedUploader(name) {
		const user = (name || "").trim().toLowerCase();
		if (!user || blockedUploaders.has(user)) return false;
		setBlockedUploaders(Array.from(blockedUploaders).concat(user));
		return true;
	}
	function commentRecord(node) {
		const author = node.querySelector(".author h6 a");
		const body = node.querySelector(".content .body");
		if (!author || !body) return null;
		const dateEl = node.querySelector(".author > span.date");
		return {
			node,
			author,
			body,
			dateText: dateEl ? dateEl.textContent || "" : ""
		};
	}
	function isCommentRemoved(rec, pid) {
		if (!rec) return false;
		const node = rec.node;
		const name = rec.author.textContent || "";
		const text = rec.body.textContent || "";
		if (pid === void 0) {
			const block = node.closest(POST_SEL);
			pid = block ? block.id.slice(1) : "?";
		}
		const who = name.trim();
		if (getFilteredUsers().has(who.toLowerCase())) {
			logSensitive(`removing c${node.id.slice(1)} on p${pid}: author "${who}" is filtered`);
			return true;
		}
		const nameHit = firstMatch(getHlCompiled().negative.names, name);
		if (nameHit) {
			logSensitive(`removing c${node.id.slice(1)} on p${pid}: author "${who}" matched remove-names regex ${quoteTerm(nameHit.source)}`);
			return true;
		}
		const contentHit = firstMatch(getHlCompiled().negative.content, text);
		if (contentHit) {
			logSensitive(`removing c${node.id.slice(1)} on p${pid}: body by "${who}" matched remove-content regex ${quoteTerm(contentHit.source)}`);
			return true;
		}
		return false;
	}
	var REFUSALS = [
		"missing",
		"duplicate",
		"blocked",
		"uploader",
		"post",
		"comment",
		"removed",
		"malformed"
	];
	var newTally = () => Object.fromEntries(REFUSALS.map((reason) => [reason, 0]));
	var tallyText = (tally) => REFUSALS.filter((reason) => tally[reason]).map((reason) => `${tally[reason]} ${reason}`).join(", ") || "none";
	function announcePostRefusal(id, reason, ctx) {
		if (ctx.announcedPosts.has(id)) return;
		ctx.announcedPosts.add(id);
		logSensitive(`removing p${id}: ${reason}`);
	}
	function isPostRefused(block, id, ctx) {
		let verdict = ctx.postVerdicts.get(id);
		if (verdict === void 0) {
			verdict = postHiddenReason(extractPostMeta(block, id), ctx);
			ctx.postVerdicts.set(id, verdict);
		}
		if (verdict) announcePostRefusal(id, verdict, ctx);
		return verdict !== null;
	}
	function postRefusalReason(block, id, ctx) {
		if (isPostBlocked(id)) {
			announcePostRefusal(id, "blocked post", ctx);
			return "blocked";
		}
		if (getBlockedUploaders().size) {
			let uploader = ctx.uploaders.get(id);
			if (uploader === void 0) {
				uploader = uploaderOf(block);
				ctx.uploaders.set(id, uploader);
			}
			if (uploader && isUploaderBlocked(uploader)) {
				announcePostRefusal(id, `blocked uploader "${uploader}"`, ctx);
				return "uploader";
			}
		}
		return isPostRefused(block, id, ctx) ? "post" : null;
	}
	function admitComment(node, block, id, ctx) {
		if (!node || !block) return "missing";
		const commentId = node.id.slice(1);
		if (!commentId) return "malformed";
		if (state.renderedComments.has(commentId)) return "duplicate";
		const postReason = postRefusalReason(block, id, ctx);
		if (postReason) return postReason;
		if (isCommentHidden(node, ctx)) return "comment";
		const rec = commentRecord(node);
		if (!rec) return "malformed";
		if (isCommentRemoved(rec, id)) return "removed";
		state.renderedComments.add(commentId);
		return null;
	}
	function highlightComment(rec) {
		if (rec.author.dataset["gcrHl"]) {
			rec.author.style.removeProperty("color");
			delete rec.author.dataset[HL_MARK];
		}
		const name = rec.author.textContent || "";
		const text = rec.body.textContent || "";
		if (anyMatch(getHlCompiled().positive.names, name) || anyMatch(getHlCompiled().positive.content, text)) {
			rec.author.style.setProperty("color", getHlConfig().colors.positive, "important");
			rec.author.dataset[HL_MARK] = "1";
		}
	}
	function decorateComment(node) {
		const rec = commentRecord(node);
		if (!rec) return;
		highlightComment(rec);
		markTodayComment(rec);
		bindUserMenu(rec);
		decorateNote(rec);
		fixReportLink(rec);
	}
	function fixReportLink(rec) {
		const link = rec.node.querySelector("a[id^=\"rcl\"]");
		if (!link) return;
		const id = link.id.slice(3);
		if (!/^\d+$/.test(id)) return;
		link.setAttribute("onclick", `cflag('${id}'); return false;`);
	}
	var gaze = null;
	function getGaze() {
		return gaze;
	}
	function promptCalibration() {
		const { host, dialog, $ } = createShadowDialog("gcr-gaze-calibrate-host", NOTE_MODAL_CSS, `
<div class="row"><h1>Gaze scrolling</h1><button class="x" title="Close">✕</button></div>
<p class="hint">Gaze scrolling needs a one-off calibration before it can trigger reliably.</p>
<div class="actions">
  <button class="btn" id="g-cancel">Cancel</button>
  <button class="btn primary" id="g-calibrate">Calibrate</button>
</div>`);
		dialog.addEventListener("close", () => host.remove());
		$(".x").addEventListener("click", () => dialog.close());
		$("#g-cancel").addEventListener("click", () => dialog.close());
		$("#g-calibrate").addEventListener("click", async () => {
			dialog.close();
			if (await calibrateGaze()) startGaze();
		});
		dialog.showModal();
	}
	async function calibrateGaze() {
		const ok = await gaze.calibrate();
		if (ok) announce("gaze calibration saved");
		return ok;
	}
	function toggleGaze() {
		if (gaze.isRunning()) {
			gaze.stop();
			announce("gaze scrolling disabled", {
				label: "undo",
				run: toggleGaze
			});
			refreshMenuCommands();
			return;
		}
		if (!gaze.isCalibrated()) {
			promptCalibration();
			return;
		}
		startGaze();
	}
	function startGaze() {
		if (isAutoScrollEnabled()) disable("gaze scrolling took over");
		gaze.start().then((ok) => {
			if (ok) announce("gaze scrolling enabled", {
				label: "undo",
				run: toggleGaze
			});
			else announce("gaze scrolling failed to start");
			refreshMenuCommands();
		});
		refreshMenuCommands();
	}
	function installGazeKeybind() {
		gaze = IS_PROFILE || typeof GazeGesture === "undefined" ? null : GazeGesture.create({
			logger,
			prefsKey: GAZE_PREFS_KEY,
			dwellMs: CONFIG.gazeDwellMs,
			cooldownMs: CONFIG.gazeCooldownMs,
			target: CONFIG.gazeTarget,
			onTrigger: advanceOnePost
		});
		if (gaze) document.addEventListener("keydown", (e) => {
			if (e.key !== "Pause" || e.ctrlKey || e.altKey || e.metaKey) return;
			if (isTypingTarget()) return;
			e.preventDefault();
			if (e.shiftKey) calibrateGaze();
			else toggleGaze();
		});
	}
	var AUTOSCROLL_SPEED_KEY = "autoscroll_speed";
	var SPEED_PRESETS = [
		{
			key: "below average",
			wpm: 180
		},
		{
			key: "average",
			wpm: 260
		},
		{
			key: "above average",
			wpm: 350
		},
		{
			key: "speed reader",
			wpm: 400
		}
	];
	var speed = "below average";
	function loadSpeed() {
		const stored = GM_getValue(AUTOSCROLL_SPEED_KEY, null);
		const match = SPEED_PRESETS.find((preset) => preset.key === stored);
		if (match) speed = match.key;
	}
	function getSpeed() {
		return speed;
	}
	function setSpeed(next) {
		const prev = speed;
		speed = next;
		GM_setValue(AUTOSCROLL_SPEED_KEY, next);
		announce(`auto-scroll reading speed set to ${next} (${wpm()} wpm)`, {
			label: "undo",
			run: () => setSpeed(prev)
		});
		if (enabled) armFromElapsed();
		refreshMenuCommands();
	}
	function wpm() {
		return SPEED_PRESETS.find((preset) => preset.key === speed).wpm;
	}
	function dwellComments(block) {
		const all = commentNodes(block).map(commentRecord).filter((rec) => !!rec);
		if (!getHlConfig().recent) return all;
		const today = all.filter(isTodayComment);
		return today.length ? today : all;
	}
	function wordCount(text) {
		const trimmed = text.trim();
		return trimmed ? trimmed.split(/\s+/).length : 0;
	}
	function dwellFor(block) {
		const records = dwellComments(block);
		let words = 0;
		for (const rec of records) words += wordCount(rec.body.textContent || "");
		const reading = words / wpm() * 6e4;
		const overhead = records.length * CONFIG.autoScrollCommentOverheadMs + CONFIG.autoScrollThumbnailMs;
		return Math.min(CONFIG.autoScrollMaxDwellMs, Math.max(CONFIG.autoScrollMinDwellMs, reading + overhead));
	}
	var enabled = false;
	var timer = null;
	var elapsedMs = 0;
	var segmentStart = 0;
	var dwellMs = 0;
	var seatedId = null;
	var mouseIdleTimer = null;
	var scrollIdleTimer = null;
	var lastScrollAt = 0;
	var sweepTimer = null;
	function now() {
		return Date.now();
	}
	function clearTimer() {
		if (timer !== null) clearTimeout(timer);
		timer = null;
	}
	function paused() {
		if (document.hidden || !document.hasFocus()) return true;
		if (mouseIdleTimer !== null) return true;
		if (scrollIdleTimer !== null) return true;
		if (anyDialogOpen()) return true;
		const selection = window.getSelection();
		if (selection && !selection.isCollapsed && String(selection).trim()) return true;
		return false;
	}
	function currentBlock() {
		const id = topmostBlockId();
		return id ? document.getElementById(id) : null;
	}
	function freeze() {
		if (segmentStart) {
			elapsedMs += now() - segmentStart;
			segmentStart = 0;
		}
		clearTimer();
	}
	function armFromElapsed() {
		clearTimer();
		if (!enabled) return;
		const block = currentBlock();
		if (!block) {
			segmentStart = 0;
			return;
		}
		if (block.id !== seatedId) {
			seatedId = block.id;
			elapsedMs = 0;
			dwellMs = dwellFor(block);
		}
		if (paused()) {
			segmentStart = 0;
			return;
		}
		segmentStart = now();
		timer = window.setTimeout(fire, Math.max(0, dwellMs - elapsedMs));
	}
	function fire() {
		timer = null;
		freeze();
		if (!enabled) return;
		if (paused()) return;
		if (!nextPostBlock()) {
			disable("end of feed reached");
			return;
		}
		advanceOnePost();
		seatedId = null;
		elapsedMs = 0;
		armFromElapsed();
	}
	function reevaluate() {
		if (!enabled) return;
		if (paused()) {
			freeze();
			return;
		}
		const block = currentBlock();
		if (timer === null || block && block.id !== seatedId) armFromElapsed();
	}
	function isAutoScrollEnabled() {
		return enabled;
	}
	function enable() {
		if (enabled || IS_PROFILE || !state.listEl) return;
		const gaze = getGaze();
		if (gaze && gaze.isRunning()) {
			gaze.stop();
			announce("gaze scrolling stopped: auto-scroll took over");
		}
		enabled = true;
		seatedId = null;
		elapsedMs = 0;
		sweepTimer = window.setInterval(reevaluate, CONFIG.autoScrollRecheckMs);
		announce(`auto-scroll enabled at ${speed} reading speed (${wpm()} wpm)`, {
			label: "undo",
			run: toggleAutoScroll
		});
		refreshMenuCommands();
		armFromElapsed();
	}
	function disable(reason) {
		if (!enabled) return;
		enabled = false;
		freeze();
		if (sweepTimer !== null) clearInterval(sweepTimer);
		sweepTimer = null;
		if (mouseIdleTimer !== null) clearTimeout(mouseIdleTimer);
		mouseIdleTimer = null;
		if (scrollIdleTimer !== null) clearTimeout(scrollIdleTimer);
		scrollIdleTimer = null;
		lastScrollAt = 0;
		seatedId = null;
		elapsedMs = 0;
		announce(`auto-scroll disabled${reason ? `: ${reason}` : ""}`, {
			label: "undo",
			run: toggleAutoScroll
		});
		refreshMenuCommands();
	}
	function toggleAutoScroll() {
		if (enabled) disable();
		else enable();
	}
	var SCROLL_KEYS = new Set([
		"PageUp",
		"PageDown",
		"Home",
		"End",
		"ArrowUp",
		"ArrowDown",
		"ArrowLeft",
		"ArrowRight",
		" ",
		"Spacebar"
	]);
	function noteReaderScroll() {
		lastScrollAt = now();
		if (scrollIdleTimer === null) freeze();
		else clearTimeout(scrollIdleTimer);
		scrollIdleTimer = window.setTimeout(() => {
			scrollIdleTimer = null;
			armFromElapsed();
		}, CONFIG.autoScrollScrollSettleMs);
	}
	function installAutoScroll() {
		if (IS_PROFILE) return;
		loadSpeed();
		document.addEventListener("mousemove", () => {
			if (!enabled) return;
			if (scrollIdleTimer !== null || now() - lastScrollAt < CONFIG.autoScrollScrollSettleMs) {
				noteReaderScroll();
				return;
			}
			if (mouseIdleTimer === null) freeze();
			else clearTimeout(mouseIdleTimer);
			mouseIdleTimer = window.setTimeout(() => {
				mouseIdleTimer = null;
				armFromElapsed();
			}, CONFIG.autoScrollMouseSettleMs);
		}, { passive: true });
		document.addEventListener("wheel", (e) => {
			if (!enabled || !e.isTrusted) return;
			noteReaderScroll();
		}, {
			passive: true,
			capture: true
		});
		document.addEventListener("keydown", (e) => {
			if (!enabled || !e.isTrusted) return;
			if (!SCROLL_KEYS.has(e.key) || isTypingTarget()) return;
			noteReaderScroll();
		}, true);
		document.addEventListener("keydown", (e) => {
			if (!e.altKey || e.ctrlKey || e.metaKey || e.key !== "\\") return;
			if (isTypingTarget()) return;
			e.preventDefault();
			toggleAutoScroll();
		});
		document.addEventListener("visibilitychange", reevaluate);
		window.addEventListener("blur", reevaluate);
		window.addEventListener("focus", reevaluate);
		document.addEventListener("selectionchange", reevaluate);
	}
	var PAUSE_AT_TOP_KEY = "pause_at_top";
	var PAUSE_UNFOCUSED_KEY = "pause_unfocused";
	function isPauseAtTopEnabled() {
		return GM_getValue(PAUSE_AT_TOP_KEY, true) !== false;
	}
	function isPauseUnfocusedEnabled() {
		return GM_getValue(PAUSE_UNFOCUSED_KEY, true) !== false;
	}
	function togglePauseAtTop() {
		const next = !isPauseAtTopEnabled();
		GM_setValue(PAUSE_AT_TOP_KEY, next);
		announce(`pause feed at top ${next ? "on" : "off"}`, {
			label: "undo",
			run: togglePauseAtTop
		});
		refreshMenuCommands();
	}
	function togglePauseUnfocused() {
		const next = !isPauseUnfocusedEnabled();
		GM_setValue(PAUSE_UNFOCUSED_KEY, next);
		announce(`pause feed when unfocused ${next ? "on" : "off"}`, {
			label: "undo",
			run: togglePauseUnfocused
		});
		refreshMenuCommands();
	}
	function insertComment(sourceBlock, commentId, id) {
		const block = document.importNode(sourceBlock, true);
		const commentEl = block.querySelector(`#c${CSS.escape(commentId)}`);
		if (!commentEl) return null;
		const existing = findPostBlock(id);
		if (existing) {
			const list = existing.querySelector(RESPONSES_SEL);
			if (!list) return null;
			list.insertBefore(commentEl, list.firstChild);
			invalidateBlockTs(existing);
			state.listEl.appendChild(existing);
			decorateComment(commentEl);
			log(`c${commentId} appended to existing post p${id}`);
			return existing;
		}
		if (!block.querySelector(".response-list")) return null;
		for (const node of commentNodes(block)) if (node !== commentEl) node.remove();
		replayInlineScripts(block);
		state.listEl.appendChild(block);
		decorateComment(commentEl);
		log(`c${commentId} appended as new post p${id}`);
		return block;
	}
	function trimToLimit() {
		let trimmedPosts = 0;
		let trimmedComments = 0;
		const blocks = postBlocks();
		let i = 0;
		while (blocks.length - trimmedPosts > CONFIG.postCacheLimit && i < blocks.length) {
			const oldest = blocks[i];
			if (isMenuSubjectBlock(oldest)) {
				log(`trim deferred: ${oldest.id} hosts an open menu's subject`);
				break;
			}
			for (const node of commentNodes(oldest)) {
				dropComment(node);
				trimmedComments++;
			}
			oldest.remove();
			trimmedPosts++;
			i++;
		}
		if (trimmedPosts) log(`trimmed ${trimmedPosts} post(s) (${trimmedComments} comment(s)) to stay at ${CONFIG.postCacheLimit} posts`);
	}
	async function resolveArrivals(fresh) {
		const found = new Map();
		const outstanding = new Set(fresh.map((c) => c.id));
		let extra = 0;
		let cursor = null;
		try {
			for (;;) {
				if (extra) showPagingIndicator(`Resolving new comments… page ${extra + 1}, ${outstanding.size} left`);
				const fetchedCursor = cursor;
				const cursorLabel = fetchedCursor ?? "start";
				let doc;
				try {
					doc = await fetchFeedPage(fetchedCursor ?? void 0);
				} catch (e) {
					if (!extra) throw e;
					warn(`poll: cursor=${cursorLabel} failed:`, e instanceof Error ? e.message : String(e));
					break;
				}
				cursor = nextFeedCursor(doc);
				if (!doc.querySelector("div.post[id^=\"p\"]")) {
					log(`poll: cursor=${cursorLabel} carried no posts; end of listing with ${outstanding.size} arrival(s) unresolved`);
					break;
				}
				let placed = 0;
				for (const id of outstanding) {
					const node = doc.getElementById(`c${id}`);
					if (!node) continue;
					found.set(id, node);
					outstanding.delete(id);
					placed++;
				}
				if (!outstanding.size) break;
				if (cursor === null) {
					log(`poll: cursor=${cursorLabel} ended the listing with ${outstanding.size} arrival(s) unresolved`);
					break;
				}
				if (extra && !placed) {
					log(`poll: cursor=${cursorLabel} carried none of the ${outstanding.size} outstanding arrival(s); taking them for deleted`);
					break;
				}
				extra++;
			}
		} finally {
			hidePagingIndicator();
		}
		if (extra) log(`poll: resolved ${found.size} arrival(s) across ${extra + 1} listing page(s)`);
		return found;
	}
	var lastHeadMoveAt = 0;
	function headPointerMoving() {
		return Date.now() - lastHeadMoveAt < CONFIG.pollHoldMoveSettleMs;
	}
	function installHeadMoveHold() {
		let queued = false;
		document.addEventListener("mousemove", () => {
			if (queued) return;
			queued = true;
			requestAnimationFrame(() => {
				queued = false;
				if (hoveredWithinFirstPosts(CONFIG.pollHoldHeadPosts)) lastHeadMoveAt = Date.now();
			});
		}, { passive: true });
	}
	async function poll(force = false) {
		if (state.polling) return;
		if (document.hidden) return;
		if (!force && state.warmupTicks > 0) {
			state.warmupTicks--;
			schedule();
			return;
		}
		const menuOpen = anyMenuOpen();
		const dialogOpen = anyDialogOpen();
		const moving = headPointerMoving();
		const logHovered = logger.element?.matches(":hover") ?? false;
		const atHead = isPauseAtTopEnabled() && (!state.readerScrolled || feedHeadAhead() && !atBottom() || moving);
		const unfocused = isPauseUnfocusedEnabled() && !document.hasFocus();
		if (!force && (menuOpen || dialogOpen || logHovered || unfocused || atHead)) {
			if (!state.pollHeld) announce(menuOpen ? "action menu open; polling held until it closes" : dialogOpen ? "dialog open; polling held until it closes" : logHovered ? "pointer over the log panel; polling held until it leaves" : unfocused ? "window unfocused; polling held until it regains focus" : moving ? "pointer moving over the feed head; polling held until it settles" : "reader at the feed head; polling held until they move down");
			state.pollHeld = true;
			schedule();
			return;
		}
		if (state.pollHeld) announce("polling resumed");
		state.pollHeld = false;
		state.polling = true;
		try {
			const feed = await fetchCommentFeed();
			state.consecutiveFailures = 0;
			const fresh = feed.filter((c) => Number(c.id) > state.highWaterMark);
			log(`poll: ${feed.length} comment(s) offered, ${fresh.length} newer than #${state.highWaterMark}`);
			if (!fresh.length) return;
			state.highWaterMark = Math.max(...feed.map((c) => Number(c.id)));
			const found = await resolveArrivals(fresh);
			const ctx = filterContext();
			let added = 0;
			holdMenuScrollDismiss(CONFIG.menuScrollDismissHoldMs);
			withPreservedSeat(({ following }) => {
				const tally = newTally();
				const arrivals = [];
				for (const comment of fresh.reverse()) {
					const commentEl = found.get(comment.id) ?? null;
					const sourceBlock = commentEl && commentEl.closest("div.post[id^=\"p\"]");
					const reason = admitComment(commentEl, sourceBlock, comment.postId, ctx);
					if (reason) {
						tally[reason]++;
						continue;
					}
					const block = insertComment(sourceBlock, comment.id, comment.postId);
					if (!block) {
						state.renderedComments.delete(comment.id);
						tally.malformed++;
						continue;
					}
					if (arrivals[arrivals.length - 1] !== block) arrivals.push(block);
					added++;
				}
				log(`poll: merged ${added}; refused ${tallyText(tally)}`);
				if (added) toast(`poll: merged ${added} new comment(s)`);
				if (!added) return { skip: true };
				trimToLimit();
				log(`${state.renderedComments.size} comment(s) cached across ${postBlocks().length} post(s)`);
				return {
					jump: arrivals,
					moved: new Set(arrivals.map((b) => b.id)),
					fallback: following ? seatBottom : seatFirstPost,
					hold: following
				};
			});
			if (added) persistFeedCache();
		} catch (e) {
			state.consecutiveFailures++;
			warn("poll failed:", e instanceof Error ? e.message : String(e));
		} finally {
			state.polling = false;
			schedule();
		}
	}
	function schedule() {
		const backedOff = state.consecutiveFailures >= CONFIG.maxConsecutiveFailures;
		const delay = CONFIG.pollIntervalMs * (backedOff ? CONFIG.backoffFactor : 1);
		if (backedOff) log(`backed off after ${state.consecutiveFailures} failure(s); next poll in ${delay}ms`);
		if (state.pollTimer) clearTimeout(state.pollTimer);
		state.pollTimer = setTimeout(poll, delay);
	}
	function installBottomTick() {
		let armed = true;
		let lastTickAt = 0;
		let queued = false;
		const check = () => {
			queued = false;
			if (!atBottom()) {
				armed = true;
				return;
			}
			if (!armed) return;
			armed = false;
			if (Date.now() - lastTickAt < CONFIG.pollIntervalMs) return;
			lastTickAt = Date.now();
			log("reached bottom; firing an immediate poll");
			poll();
		};
		window.addEventListener("scroll", () => {
			if (queued) return;
			queued = true;
			requestAnimationFrame(check);
		}, { passive: true });
	}
	function installVisibilitySuspend() {
		document.addEventListener("visibilitychange", () => {
			if (!state.listEl) return;
			if (document.hidden) {
				if (state.pollTimer) clearTimeout(state.pollTimer);
				state.pollTimer = null;
				log("tab hidden; polling suspended");
				return;
			}
			announce("tab visible; polling resumed");
			poll();
		});
	}
	function blockPostFromBlock(block) {
		if (!block) return;
		const id = postId(block);
		if (!id) return;
		withPreservedSeat(() => {
			const jump = blocksBelow(block);
			if (!addBlockedPost(id)) return { skip: true };
			reapplyFilters();
			if (!jump.length) return {};
			return {
				jump,
				actedOn: block,
				fallback: seatLastPost
			};
		});
	}
	var BTN_CLASS = "gcr-block-btn";
	var button = null;
	var target = null;
	function hidePostControl() {
		target = null;
		if (button) button.style.display = "none";
	}
	function showOver(img) {
		const host = state.listEl;
		if (!host || !button) return;
		const block = img.closest(POST_SEL);
		if (!block) return hidePostControl();
		if (block === target) return;
		target = block;
		const hostRect = host.getBoundingClientRect();
		const rect = img.getBoundingClientRect();
		button.style.left = `${rect.left - hostRect.left + 4}px`;
		button.style.top = `${rect.top - hostRect.top + 4}px`;
		button.style.display = "block";
	}
	function blockTargetUnderPointer() {
		return hoveredThumbBlock() || (button && button.matches(":hover") ? target : null);
	}
	function installPostControls() {
		const host = state.listEl;
		if (!host || button) return;
		if (getComputedStyle(host).position === "static") host.style.position = "relative";
		button = document.createElement("span");
		button.className = BTN_CLASS;
		button.textContent = "✕";
		button.title = "Block this post";
		button.setAttribute("role", "button");
		button.style.display = "none";
		button.addEventListener("click", (e) => {
			e.preventDefault();
			e.stopPropagation();
			const block = target;
			hidePostControl();
			blockPostFromBlock(block);
		});
		host.appendChild(button);
		host.addEventListener("mouseover", (e) => {
			const el = e.target;
			if (!el || typeof el.closest !== "function") return;
			if (el === button) return;
			const img = el.closest(THUMB_SEL);
			if (img) showOver(img);
			else hidePostControl();
		});
		host.addEventListener("mouseleave", hidePostControl);
	}
	var LOCK_CSS = `
:host { all: initial; }
.veil {
  position: fixed; inset: 0; z-index: 2147483647;
  display: flex; align-items: center; justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  font-family: system-ui, sans-serif;
}
.panel {
  display: flex; align-items: center; gap: 10px;
  padding: 12px 18px;
  border: 1px solid #3a3c40; border-radius: 8px;
  background: #1f2023; color: #e6e6e6;
  font-size: 13px; line-height: 1;
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
}
.spinner {
  width: 16px; height: 16px;
  border: 2px solid #3a3c40; border-top-color: #e6e6e6;
  border-radius: 50%;
  animation: gcr-lock-spin 0.8s linear infinite;
}
@keyframes gcr-lock-spin {
  to { transform: rotate(360deg); }
}
`;
	var host = null;
	var labelEl = null;
	var savedOverflow = null;
	var swallow = (e) => {
		e.preventDefault();
		e.stopPropagation();
	};
	function lockPage(label) {
		if (!host || !host.isConnected) {
			host = document.createElement("div");
			host.id = "gcr-page-lock";
			const root = host.attachShadow({ mode: "open" });
			const style = document.createElement("style");
			style.textContent = LOCK_CSS;
			const veil = document.createElement("div");
			veil.className = "veil";
			const panel = document.createElement("div");
			panel.className = "panel";
			const spinner = document.createElement("span");
			spinner.className = "spinner";
			labelEl = document.createElement("span");
			panel.append(spinner, labelEl);
			veil.appendChild(panel);
			root.append(style, veil);
			veil.addEventListener("wheel", swallow, { passive: false });
			veil.addEventListener("touchmove", swallow, { passive: false });
			document.body.appendChild(host);
		}
		if (savedOverflow === null) {
			savedOverflow = document.documentElement.style.overflow;
			document.documentElement.style.overflow = "hidden";
		}
		labelEl.textContent = label;
		host.style.display = "";
	}
	function unlockPage() {
		if (host) host.style.display = "none";
		if (savedOverflow !== null) {
			document.documentElement.style.overflow = savedOverflow;
			savedOverflow = null;
		}
	}
	async function prefillOlderPages() {
		const started = postBlocks().length;
		let onPage = started;
		if (onPage >= CONFIG.postCacheLimit) return 0;
		log(`prefill: ${onPage} post(s) on page, filling to ${CONFIG.postCacheLimit}`);
		const ctx = filterContext();
		const tally = newTally();
		const budget = newPostBudget();
		let pages = 0;
		let cursor = state.seedCursor;
		try {
			while (onPage < CONFIG.postCacheLimit && cursor !== null) {
				lockPage(`Loading older comments… page ${pages + 1}`);
				const fetchedCursor = cursor;
				let doc;
				try {
					doc = await fetchFeedPage(fetchedCursor);
				} catch (e) {
					warn(`prefill: cursor=${fetchedCursor} failed:`, e instanceof Error ? e.message : String(e));
					break;
				}
				cursor = nextFeedCursor(doc);
				pages++;
				const sources = doc.querySelectorAll(POST_SEL);
				if (!sources.length) {
					log(`prefill: cursor=${fetchedCursor} carried no posts; end of listing`);
					break;
				}
				for (const source of sources) if (mergePostBlock(document.importNode(source, true), postId(source), budget, ctx, tally, { staleGate: true }).newPost) onPage++;
			}
		} finally {
			unlockPage();
		}
		const added = onPage - started;
		log(`prefill: added ${added} post(s) from ${pages} page(s); refused ${tallyText(tally)}; ${onPage} post(s), ${state.renderedComments.size} comment(s) on page`);
		return added;
	}
	function undoAction(target) {
		const seq = armedUndoSeq();
		if (seq === null) return void 0;
		return {
			label: "undo",
			run: () => {
				undoBlacklistChange(seq).then((undone) => {
					if (undone) reapplySeatedFilters();
					announce(undone ? `site blacklist: removed "${target}" again` : "site blacklist: nothing to undo — the list has changed since");
				});
			}
		};
	}
	async function blacklistTagFromFeed(tag, block) {
		const target = tag.trim().toLowerCase();
		if (!target || !state.listEl) return;
		announce(`site blacklist: adding "${target}"…`);
		const outcome = await addTagToSiteBlacklist(target);
		if (outcome === "failed") {
			announce(`site blacklist: could not add "${target}"; feed unchanged`);
			return;
		}
		if (outcome === "present") announce(`site blacklist: "${target}" already present`);
		else if (outcome === "overflow") announce(`site blacklist: "${target}" stored locally (site list full)`, undoAction(target));
		else announce(`site blacklist: added "${target}"`, undoAction(target));
		withPreservedSeat(() => {
			const below = block ? blocksBelow(block) : [];
			reapplyFilters();
			if (block && block.isConnected) return {};
			const jump = below.filter((el) => el.isConnected);
			if (!jump.length) return {};
			return {
				jump,
				actedOn: block,
				fallback: seatLastPost
			};
		});
	}
	var TAG_HL_KEY = "tag_highlights";
	var tagSearchHref = (tag) => `/index.php?page=post&s=list&tags=${encodeURIComponent(tag)}`;
	var STYLE_ID = "gcr-tag-hl-css";
	function loadHighlightedTags() {
		const parsed = readJson(TAG_HL_KEY, [], "highlighted tags");
		return Array.isArray(parsed) ? normalizeNames(parsed) : [];
	}
	function saveHighlightedTags(tags) {
		if (writeJson("tag_highlights", tags, "highlighted tags")) log(`highlighted tags saved: ${tags.length} tag(s)`);
	}
	var highlightedTags = new Set(loadHighlightedTags());
	var getHighlightedTags = () => highlightedTags;
	var isTagHighlighted = (tag) => highlightedTags.has(tag.trim().toLowerCase());
	function selectorsFor(tag, spanSel) {
		const sels = [];
		for (const form of new Set([tag, encodeURIComponent(tag)])) {
			const esc = form.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
			sels.push(`div.tags ${spanSel} a[href$="tags=${esc}" i]`, `div.tags ${spanSel} a[href*="tags=${esc}&" i]`);
		}
		return sels;
	}
	var CATEGORY_COLORS = [
		["artist", "var(--tag-artist, #a00)"],
		["character", "var(--tag-character, #0a0)"],
		["copyright", "var(--tag-copyright, #a0a)"],
		["metadata", "var(--tag-metadata, #f80)"]
	];
	var ACCENT = `  font-weight: bold !important;
  filter: brightness(1.3) saturate(1.25) !important;
  text-shadow: 0 0 6px currentColor, 0 0 14px currentColor !important;`;
	function applyTagHighlightCss() {
		let el = document.getElementById(STYLE_ID);
		if (!el) {
			el = document.createElement("style");
			el.id = STYLE_ID;
			document.head.appendChild(el);
		}
		const tags = Array.from(highlightedTags);
		if (!tags.length) {
			el.textContent = "";
			return;
		}
		const rule = (spanSel, color) => `${tags.flatMap((t) => selectorsFor(t, spanSel)).join(",\n")} {
  color: ${color} !important;
${ACCENT}
}`;
		el.textContent = [rule("span[class*=\"tag-type-\"]", "#daa520"), ...CATEGORY_COLORS.map(([cat, color]) => rule(`span.tag-type-${cat}`, color))].join("\n");
	}
	function setHighlightedTags(tags) {
		const next = normalizeNames(tags);
		saveHighlightedTags(next);
		highlightedTags = new Set(next);
		applyTagHighlightCss();
	}
	function toggleTagHighlight(name) {
		const tag = (name || "").trim().toLowerCase();
		if (!tag) return false;
		const nowOn = !highlightedTags.has(tag);
		const next = new Set(highlightedTags);
		if (nowOn) next.add(tag);
		else next.delete(tag);
		setHighlightedTags(Array.from(next));
		logSensitive(`tag highlight ${nowOn ? "added" : "removed"}: "${tag}"`);
		return nowOn;
	}
	function tagOf(link) {
		try {
			const tags = new URL(link.href, location.href).searchParams.get("tags");
			if (tags) return decodeEntities(tags.trim().toLowerCase());
		} catch {}
		return (link.textContent || "").trim().toLowerCase();
	}
	var _menu$2 = null;
	function buildTagMenu() {
		if (_menu$2) return _menu$2;
		let currentTag = null;
		let currentBlock = null;
		const shell = createActionMenu("gcr-tag-menu-host", () => {
			currentTag = null;
			currentBlock = null;
		});
		const { menu, nameEl } = shell;
		const close = () => shell.close();
		const viewEl = document.createElement("a");
		viewEl.className = "item";
		viewEl.textContent = "View tagged posts";
		viewEl.target = "_blank";
		viewEl.rel = "noopener";
		viewEl.addEventListener("click", () => close());
		const hlEl = document.createElement("a");
		hlEl.className = "item";
		hlEl.href = "#";
		hlEl.addEventListener("click", (e) => {
			e.preventDefault();
			const tag = currentTag;
			close();
			if (tag) toggleTagHighlight(tag);
		});
		menu.appendChild(viewEl);
		menu.appendChild(hlEl);
		appendDangerRow(menu, "Add to blacklist", () => {
			const tag = currentTag;
			const block = currentBlock;
			close();
			if (tag) blacklistTagFromFeed(tag, block);
		});
		_menu$2 = { open(tag, anchorEl, block) {
			currentTag = tag;
			currentBlock = block;
			nameEl.textContent = tag;
			viewEl.href = anchorEl.href;
			hlEl.textContent = isTagHighlighted(tag) ? "Remove highlight" : "Add highlight";
			shell.show(anchorEl);
		} };
		return _menu$2;
	}
	function installTagMenu() {
		installFeedLinkMenu("gcrTagMenu", TAG_LINK_SEL, (link) => {
			const tag = tagOf(link);
			if (!tag) return false;
			buildTagMenu().open(tag, link, link.closest(POST_SEL));
			return true;
		});
	}
	var _menu$1 = null;
	function buildThumbMenu() {
		if (_menu$1) return _menu$1;
		let currentBlock = null;
		const shell = createActionMenu("gcr-thumb-menu-host", () => {
			currentBlock = null;
		});
		const { menu, nameEl } = shell;
		const close = () => shell.close();
		const viewEl = document.createElement("a");
		viewEl.className = "item";
		viewEl.textContent = "View post";
		viewEl.target = "_blank";
		viewEl.rel = "noopener";
		viewEl.addEventListener("click", () => close());
		const sep = document.createElement("div");
		sep.className = "sep";
		const blockEl = document.createElement("a");
		blockEl.className = "item danger";
		blockEl.textContent = "Block post";
		blockEl.href = "#";
		blockEl.addEventListener("click", (e) => {
			e.preventDefault();
			const block = currentBlock;
			close();
			hidePostControl();
			blockPostFromBlock(block);
		});
		menu.append(viewEl, sep, blockEl);
		_menu$1 = { open(block, x, y) {
			currentBlock = block;
			const id = postId(block);
			nameEl.textContent = `p${id}`;
			viewEl.href = postHref(id);
			shell.showAt(x, y, block);
		} };
		return _menu$1;
	}
	function installThumbMenu() {
		const host = state.listEl;
		if (!host || host.dataset.gcrThumbMenu) return;
		host.dataset.gcrThumbMenu = "1";
		host.addEventListener("click", (e) => {
			if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return;
			const target = e.target;
			const img = target && target.closest("img.preview");
			if (!img) return;
			const block = img.closest(POST_SEL);
			if (!block) return;
			e.preventDefault();
			e.stopPropagation();
			buildThumbMenu().open(block, e.clientX, e.clientY);
		}, true);
	}
	function blockUploaderFromBlock(block) {
		if (!block) return;
		const uploader = uploaderOf(block);
		if (!uploader) return;
		withPreservedSeat(() => {
			const jump = blocksBelow(block);
			if (!addBlockedUploader(uploader)) return { skip: true };
			reapplyFilters();
			if (!jump.length) return {};
			return {
				jump,
				actedOn: block,
				fallback: seatLastPost
			};
		});
	}
	var _menu = null;
	function buildUploaderMenu() {
		if (_menu) return _menu;
		let currentBlock = null;
		const shell = createActionMenu("gcr-uploader-menu-host", () => {
			currentBlock = null;
		});
		const { menu, nameEl } = shell;
		const close = () => shell.close();
		const setUser = appendUserRows(menu, close, () => currentBlock ? uploaderOf(currentBlock) : null);
		appendDangerRow(menu, "Block uploader", () => {
			const block = currentBlock;
			close();
			blockUploaderFromBlock(block);
		});
		_menu = { open(user, anchorEl, block) {
			currentBlock = block;
			nameEl.textContent = user;
			setUser(user);
			shell.show(anchorEl);
		} };
		return _menu;
	}
	function installUploaderMenu() {
		installFeedLinkMenu("gcrUploaderMenu", UPLOADER_LINK_SEL, (link) => {
			const user = (link.textContent || "").trim();
			if (!user) return false;
			buildUploaderMenu().open(user, link, link.closest(POST_SEL));
			return true;
		});
	}
	async function init() {
		state.listEl = document.getElementById("comment-list");
		if (!state.listEl) return;
		const paginator = document.getElementById("paginator");
		state.seedCursor = nextFeedCursor(document);
		if (paginator) paginator.remove();
		installPostControls();
		installTagMenu();
		installThumbMenu();
		installUploaderMenu();
		const feedCache = loadFeedCache();
		loadSuppressed(feedCache);
		const ctx = filterContext();
		const tally = newTally();
		let droppedPosts = 0;
		for (const block of postBlocks()) {
			const id = postId(block);
			let removed = 0;
			for (const node of commentNodes(block)) {
				const reason = admitComment(node, block, id, ctx);
				if (!reason) {
					state.highWaterMark = Math.max(state.highWaterMark, Number(node.id.slice(1)));
					continue;
				}
				tally[reason]++;
				if (reason === "removed") removed++;
				node.remove();
			}
			if (dropStaleBlock(block, removed)) droppedPosts++;
		}
		log(`seed: admitted ${state.renderedComments.size} comment(s); refused ${tallyText(tally)}; ${droppedPosts} post(s) dropped`);
		const blocks = postBlocks();
		for (let i = blocks.length - 1; i >= 0; i--) state.listEl.appendChild(blocks[i]);
		for (const block of blocks) for (const node of commentNodes(block)) decorateComment(node);
		let freshMinTs = Infinity;
		for (const block of blocks) {
			const ts = latestCommentTs(block);
			if (ts && ts < freshMinTs) freshMinTs = ts;
		}
		if (!Number.isFinite(freshMinTs)) freshMinTs = Date.now();
		const { anchorId, anchorAtStart } = restoreBackfill(freshMinTs, feedCache);
		await prefillOlderPages();
		restoreSuppressed();
		if (anchorId) {
			let seatState = "at feed start";
			if (!anchorAtStart) seatState = document.getElementById(anchorId) ? "restored" : "gone";
			log(`reader seat ${anchorId}: ${seatState}`);
		}
		applySeat(anchorAtStart || !anchorId ? [] : [{ id: anchorId }], { hold: true });
		installBottomTick();
		installHeadMoveHold();
		installTopRearm();
		installFeedCachePersist();
		state.auth = await resolveAuth();
		log(`seeded with ${state.renderedComments.size} comment(s) across ${postBlocks().length} post(s); high water mark #${state.highWaterMark}, auth ${state.auth ? "resolved" : "anonymous"}`);
		schedule();
	}
	var TAG_CLAMP_KEY = "tag_list_clamp";
	var CLAMP_CLASS = "gcr-tags-clamped";
	function isTagListClamped() {
		return GM_getValue(TAG_CLAMP_KEY, true) !== false;
	}
	function applyTagListClamp() {
		document.body.classList.toggle(CLAMP_CLASS, isTagListClamped());
	}
	function toggleTagListClamp() {
		const next = !isTagListClamped();
		GM_setValue(TAG_CLAMP_KEY, next);
		if (state.listEl) withPreservedSeat(() => applyTagListClamp());
		else applyTagListClamp();
		announce(`tag list height restriction ${next ? "on" : "off"}`, {
			label: "undo",
			run: toggleTagListClamp
		});
		refreshMenuCommands();
	}
	var HL_CSS = `
:host { all: initial; }
* { box-sizing: border-box; font-family: system-ui, sans-serif; }
dialog.card {
  border: none; border-radius: 10px; padding: 0; color: #e6e6e6;
  background: #1f2023; width: min(700px, 42vw);
  box-shadow: 0 10px 40px rgba(0,0,0,.5);
}
dialog.card::backdrop { background: rgba(0,0,0,.6); }
.wrap { padding: 18px 20px 16px; margin: 0; }
header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
h1 { font-size: 16px; font-weight: 600; margin: 0; }
.x { background: none; border: none; color: #aaa; font-size: 18px; cursor: pointer; line-height: 1; }
.x:hover { color: #fff; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; }
label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #b8b8b8; }
label.pos { color: #7bd88f; }
label.neg { color: #e88; }
textarea {
  resize: vertical; padding: 6px 8px; min-height: 52px;
  border: 1px solid #3a3c40; border-radius: 6px;
  background: #141517; color: #e6e6e6;
  font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 12px;
}
textarea:focus { outline: none; border-color: #6ea8fe; }
.note { font-size: 11px; color: #9a9a9a; margin: 10px 0 0; }
.opts { display: flex; align-items: center; gap: 18px; margin: 14px 0 4px; flex-wrap: wrap; }
.colors { display: flex; align-items: center; gap: 18px; margin: 8px 0 4px; flex-wrap: wrap; }
.chk, .col { flex-direction: row; align-items: center; gap: 6px; color: #e6e6e6; }
.clabel { color: #b8b8b8; }
input.hex {
  border: 1px solid #3a3c40; border-radius: 6px;
  background: #141517; color: #e6e6e6; font-size: 12px; padding: 4px 6px;
  width: 84px; font-family: ui-monospace, Menlo, Consolas, monospace;
  text-transform: lowercase;
}
input.hex:focus { outline: none; border-color: #6ea8fe; }
.cpick { position: relative; }
.cpick .head {
  display: flex; align-items: center; gap: 6px; cursor: pointer;
  border: 1px solid #3a3c40; border-radius: 6px; min-width: 116px;
  background: #141517; color: #e6e6e6; font-size: 12px; padding: 4px 8px;
}
.cpick .head .caret { margin-left: auto; color: #9a9a9a; font-size: 10px; }
.cpick.open .head { border-color: #6ea8fe; }
.cpick .menu {
  position: fixed; z-index: 2147483647;
  margin: 0; padding: 4px; list-style: none; display: none;
  border: 1px solid #3a3c40; border-radius: 6px; background: #1f2023;
  box-shadow: 0 8px 24px rgba(0,0,0,.5);
  max-height: 220px; overflow: auto; min-width: 140px;
}
.cpick.open .menu { display: block; }
.cpick .opt {
  display: flex; align-items: center; gap: 8px; cursor: pointer;
  padding: 4px 6px; border-radius: 4px; font-size: 12px; color: #e6e6e6;
  white-space: nowrap;
}
.cpick .opt:hover { background: #2a2c30; }
.cpick .opt[aria-selected="true"] { background: #34373c; }
.swatch {
  width: 12px; height: 12px; border-radius: 3px; flex: none;
  border: 1px solid rgba(255,255,255,.25);
}
.swatch.none {
  background: repeating-linear-gradient(45deg,#555,#555 3px,#222 3px,#222 6px);
}
.errors { color: #ffb4b4; font-size: 11px; white-space: pre-wrap; margin: 8px 0 0;
  font-family: ui-monospace, monospace; max-height: 120px; overflow: auto; }
.errors:empty { display: none; }
footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 14px; }
.btn { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
  border: 1px solid #3a3c40; background: #2a2c30; color: #e6e6e6; }
.btn:hover { background: #34373c; }
.btn.primary { background: #3b6ea5; border-color: #3b6ea5; }
.btn.primary:hover { background: #4279b8; }
`;
	var HL_DIALOG_HTML = `
<form method="dialog" class="wrap">
  <header>
    <h1>Comment filter</h1>
    <button type="button" id="f-close" class="x" title="Close">&#10005;</button>
  </header>
  <div class="grid">
    <label class="pos">Highlight &mdash; names
      <textarea id="f-pos-names" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
    <label class="pos">Highlight &mdash; content
      <textarea id="f-pos-content" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
    <label class="neg">Remove &mdash; names
      <textarea id="f-neg-names" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
    <label class="neg">Remove &mdash; content
      <textarea id="f-neg-content" rows="5" spellcheck="false" placeholder="one regex per line"></textarea></label>
  </div>
  <p class="note">Matching comments are deleted outright. A post loses its block once
    none of today's comments survive.</p>
  <div class="opts">
    <label class="chk"><input type="checkbox" id="f-recent"> Highlight comments from today</label>
  </div>
  <div class="colors">
    <div class="col"><span class="clabel">Highlight</span>
      <div class="cpick" id="f-color-pos-pick"></div>
      <input type="text" id="f-color-pos" class="hex" spellcheck="false"
        placeholder="#rrggbb" maxlength="7" autocomplete="off"></div>
  </div>
  <pre id="f-errors" class="errors"></pre>
  <footer>
    <button type="button" id="f-cancel" class="btn">Cancel</button>
    <button type="button" id="f-save" class="btn primary">Save</button>
  </footer>
</form>
`;
	var _ui$1 = null;
	function buildSettingsUI() {
		if (_ui$1) return _ui$1;
		const { dialog, $ } = createShadowDialog("gcr-settings-host", HL_CSS, HL_DIALOG_HTML);
		const fields = {
			posNames: $("#f-pos-names"),
			posContent: $("#f-pos-content"),
			negNames: $("#f-neg-names"),
			negContent: $("#f-neg-content"),
			recent: $("#f-recent"),
			colorPos: $("#f-color-pos"),
			errors: $("#f-errors")
		};
		function attachColorPicker(pickHost, hex) {
			const head = document.createElement("div");
			head.className = "head";
			const headSw = document.createElement("span");
			headSw.className = "swatch";
			const headName = document.createElement("span");
			headName.className = "name";
			const caret = document.createElement("span");
			caret.className = "caret";
			caret.textContent = "▾";
			head.append(headSw, headName, caret);
			const menu = document.createElement("ul");
			menu.className = "menu";
			const rows = Object.entries(COLOR_CHOICES).map(([name, h]) => ({
				name,
				hex: h
			})).concat([{
				name: "Custom…",
				hex: ""
			}]).map((e) => {
				const li = document.createElement("li");
				li.className = "opt";
				li.dataset.hex = e.hex;
				const sw = document.createElement("span");
				sw.className = e.hex ? "swatch" : "swatch none";
				if (e.hex) sw.style.background = e.hex;
				const nm = document.createElement("span");
				nm.textContent = e.name;
				li.append(sw, nm);
				li.addEventListener("click", () => {
					close();
					if (e.hex) hex.value = e.hex;
					else hex.focus();
					syncFromHex();
				});
				menu.appendChild(li);
				return li;
			});
			pickHost.append(head, menu);
			const close = () => pickHost.classList.remove("open");
			function positionMenu() {
				const r = head.getBoundingClientRect();
				menu.style.left = r.left + "px";
				menu.style.minWidth = r.width + "px";
				const mh = menu.offsetHeight;
				const below = window.innerHeight - r.bottom;
				menu.style.top = below < mh + 8 && r.top > below ? r.top - mh - 4 + "px" : r.bottom + 4 + "px";
			}
			head.addEventListener("click", () => {
				const opening = !pickHost.classList.contains("open");
				pickHost.classList.toggle("open");
				if (opening) positionMenu();
			});
			function syncFromHex() {
				const v = (hex.value || "").trim().toLowerCase();
				const valid = HEX_RE.test(v);
				const name = valid ? COLOR_BY_HEX.get(v) : void 0;
				headSw.className = valid ? "swatch" : "swatch none";
				headSw.style.background = valid ? v : "";
				headName.textContent = name || (valid ? v : "Custom…");
				rows.forEach((li) => {
					const sel = name ? li.dataset.hex.toLowerCase() === v : li.dataset.hex === "";
					li.setAttribute("aria-selected", sel ? "true" : "false");
				});
			}
			hex.addEventListener("input", syncFromHex);
			function set(value) {
				hex.value = value;
				syncFromHex();
			}
			return {
				host: pickHost,
				close,
				set
			};
		}
		const colorPick = attachColorPicker($("#f-color-pos-pick"), fields.colorPos);
		dialog.addEventListener("click", (e) => {
			if (!colorPick.host.contains(e.target)) colorPick.close();
		});
		const lines = (v) => v.split("\n").map((s) => s.trim()).filter(Boolean);
		function fill(cfg) {
			fields.posNames.value = cfg.filters.positive.names.join("\n");
			fields.posContent.value = cfg.filters.positive.content.join("\n");
			fields.negNames.value = cfg.filters.negative.names.join("\n");
			fields.negContent.value = cfg.filters.negative.content.join("\n");
			fields.recent.checked = cfg.recent;
			colorPick.set(cfg.colors.positive);
			fields.errors.textContent = "";
		}
		function onSave() {
			const hexErrors = [];
			const v = fields.colorPos.value.trim();
			if (!HEX_RE.test(v)) hexErrors.push(`• [highlight color] ${v || "(empty)"} — expected #rrggbb`);
			const cfg = {
				filters: {
					positive: {
						names: lines(fields.posNames.value),
						content: lines(fields.posContent.value)
					},
					negative: {
						names: lines(fields.negNames.value),
						content: lines(fields.negContent.value)
					}
				},
				colors: { positive: v.toLowerCase() },
				recent: fields.recent.checked
			};
			const { compiled, errors } = compileFilters(cfg);
			const allErrors = hexErrors.concat(errors.map((e) => `• [${e.label}] ${e.src} — ${e.message}`));
			if (allErrors.length) {
				fields.errors.textContent = "Invalid input — fix or remove:\n" + allErrors.join("\n");
				return;
			}
			saveConfig(cfg);
			setLiveFilters(cfg, compiled);
			dialog.close();
			reapplySeatedFilters();
		}
		$("#f-close").addEventListener("click", () => dialog.close());
		$("#f-cancel").addEventListener("click", () => dialog.close());
		$("#f-save").addEventListener("click", onSave);
		document.addEventListener("keydown", (e) => {
			if (!dialog.open) return;
			if (e.key === "Enter" && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {
				e.preventDefault();
				e.stopImmediatePropagation();
				onSave();
			}
		}, true);
		_ui$1 = { open() {
			fill(loadConfig());
			dialog.showModal();
		} };
		return _ui$1;
	}
	function openSettings() {
		buildSettingsUI().open();
	}
	var LIST_CSS = HL_CSS + `
dialog.card { width: min(420px, 32vw); }
.list { margin: 0; padding: 0; list-style: none; max-height: 300px; overflow: auto; }
.row {
  display: flex; align-items: center; gap: 8px;
  padding: 5px 8px; border-radius: 6px; font-size: 13px;
}
.row:nth-child(odd) { background: #26282c; }
.row .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row .name a { color: #6ea8fe; text-decoration: none; }
.row .name a:hover { text-decoration: underline; }
.row .rm {
  background: none; border: none; color: #aaa; cursor: pointer;
  font-size: 14px; line-height: 1; padding: 2px 4px;
}
.row .rm:hover { color: #e88; }
.empty { color: #9a9a9a; font-size: 12px; padding: 6px 2px; }
.add { display: flex; gap: 8px; margin-top: 12px; }
.add input {
  flex: 1; border: 1px solid #3a3c40; border-radius: 6px;
  background: #141517; color: #e6e6e6; font-size: 12px; padding: 5px 8px;
}
.add input:focus { outline: none; border-color: #6ea8fe; }
`;
	function buildListDialog(spec) {
		const { dialog, $ } = createShadowDialog(spec.hostId, LIST_CSS, `
<form method="dialog" class="wrap">
  <header>
    <h1>${spec.title}</h1>
    <button type="button" id="l-close" class="x" title="Close">&#10005;</button>
  </header>
  <ul id="l-list" class="list"></ul>
  <div class="add">
    <input type="text" id="l-entry" spellcheck="false" autocomplete="off" placeholder="${spec.placeholder}">
    <button type="button" id="l-add" class="btn">Add</button>
  </div>
  <p class="note">${spec.note}</p>
  <footer>
    <button type="button" id="l-cancel" class="btn">Cancel</button>
    <button type="button" id="l-save" class="btn primary">Save</button>
  </footer>
</form>
`);
		const listEl_ = $("#l-list");
		const entryEl = $("#l-entry");
		let working = [];
		function render() {
			listEl_.textContent = "";
			if (!working.length) {
				const li = document.createElement("li");
				li.className = "empty";
				li.textContent = spec.emptyText;
				listEl_.appendChild(li);
				return;
			}
			working.forEach((entry, i) => {
				const li = document.createElement("li");
				li.className = "row";
				const name = document.createElement("span");
				name.className = "name";
				if (spec.href) {
					const link = document.createElement("a");
					link.href = spec.href(entry);
					link.target = "_blank";
					link.rel = "noreferrer";
					link.textContent = entry;
					name.appendChild(link);
				} else name.textContent = entry;
				const rm = document.createElement("button");
				rm.type = "button";
				rm.className = "rm";
				rm.title = `${spec.removeVerb} ${entry}`;
				rm.textContent = "✕";
				rm.addEventListener("click", () => {
					working.splice(i, 1);
					render();
				});
				li.append(name, rm);
				listEl_.appendChild(li);
			});
		}
		function add() {
			const entry = spec.normalize(entryEl.value);
			entryEl.value = "";
			if (!entry || working.includes(entry)) return;
			working.push(entry);
			working.sort(spec.compare);
			render();
		}
		$("#l-add").addEventListener("click", add);
		entryEl.addEventListener("keydown", (e) => {
			if (e.key !== "Enter") return;
			e.preventDefault();
			add();
		});
		$("#l-close").addEventListener("click", () => dialog.close());
		$("#l-cancel").addEventListener("click", () => dialog.close());
		$("#l-save").addEventListener("click", () => {
			spec.save(working);
			dialog.close();
			reapplySeatedFilters();
		});
		return { open() {
			working = spec.load().sort(spec.compare);
			entryEl.value = "";
			render();
			dialog.showModal();
		} };
	}
	var _postsUI = null;
	function openBlockedPosts() {
		_postsUI ??= buildListDialog({
			hostId: "gcr-posts-host",
			title: "Blocked posts",
			placeholder: "post id",
			emptyText: "No posts blocked.",
			note: `Comments on these posts are refused on sight, and blocking one takes
    its whole post off the feed. Unblocking here re-applies the filters and restores
    archived material the current rules now allow.`,
			removeVerb: "Unblock",
			load: loadBlockedPosts,
			save: setBlockedPosts,
			normalize: toPostId,
			compare: (a, b) => Number(a) - Number(b),
			href: postHref
		});
		_postsUI.open();
	}
	var _uploadersUI = null;
	function openBlockedUploaders() {
		_uploadersUI ??= buildListDialog({
			hostId: "gcr-uploaders-host",
			title: "Blocked uploaders",
			placeholder: "username",
			emptyText: "No uploaders blocked.",
			note: `Comments on posts these users uploaded are refused on sight, and
    blocking an uploader takes every post of theirs off the feed. Unblocking here lets
    their archived posts back in immediately when the remaining rules allow them.`,
			removeVerb: "Unblock",
			load: loadBlockedUploaders,
			save: setBlockedUploaders,
			normalize: (raw) => raw.trim().toLowerCase() || null,
			href: profileHref
		});
		_uploadersUI.open();
	}
	var BLACKLIST_CSS = LIST_CSS + `
dialog.card { width: min(560px, 42vw); }
.advanced { flex-direction: row; align-items: center; gap: 6px; margin-top: 12px; color: #ddd; }
.section { margin-top: 12px; }
.section h2 { color: #bbb; font-size: 12px; font-weight: 600; margin: 0 0 5px; }
.badge { color: #8ec5ff; border: 1px solid #45617e; border-radius: 8px; padding: 1px 5px;
  font-size: 10px; white-space: nowrap; }
.move { min-width: 88px; padding: 3px 7px; }
.status { min-height: 16px; color: #e4b86a; font-size: 11px; margin: 7px 0 0; }
button:disabled, input:disabled { cursor: wait; opacity: .55; }
`;
	var _ui = null;
	var tagHref = (tag) => `/index.php?page=post&s=list&tags=${encodeURIComponent(tag)}`;
	function buildBlacklistDialog() {
		if (_ui) return _ui;
		const { dialog, $ } = createShadowDialog("gcr-blacklist-host", BLACKLIST_CSS, `
<form method="dialog" class="wrap">
  <header>
    <h1>Blacklisted tags</h1>
    <button type="button" id="b-close" class="x" title="Close">&#10005;</button>
  </header>
  <div id="b-lists"></div>
  <div class="add">
    <input type="text" id="b-entry" spellcheck="false" autocomplete="off" placeholder="tag">
    <button type="button" id="b-add" class="btn">Add</button>
  </div>
  <label class="advanced"><input type="checkbox" id="b-advanced"> Advanced: show site/local storage</label>
  <p id="b-status" class="status"></p>
  <p class="note">Local overflow tags filter this comments feed just like account tags,
    but do not affect the site's native post views.</p>
  <footer><button type="button" id="b-done" class="btn primary">Done</button></footer>
</form>
`);
		const listsEl = $("#b-lists");
		const entryEl = $("#b-entry");
		const addEl = $("#b-add");
		const advancedEl = $("#b-advanced");
		const statusEl = $("#b-status");
		let lists = null;
		let localTypes = new Map();
		let busy = false;
		function setBusy(value) {
			busy = value;
			entryEl.disabled = value;
			addEl.disabled = value;
			advancedEl.disabled = value;
			listsEl.querySelectorAll("button").forEach((button) => {
				button.disabled = value;
			});
		}
		function nameNode(tag) {
			const name = document.createElement("span");
			name.className = "name";
			const link = document.createElement("a");
			link.href = tagHref(tag);
			link.target = "_blank";
			link.rel = "noreferrer";
			link.textContent = tag;
			name.appendChild(link);
			return name;
		}
		function actionButton(label, title, action) {
			const button = document.createElement("button");
			button.type = "button";
			button.className = label === "✕" ? "rm" : "btn move";
			button.textContent = label;
			button.title = title;
			button.disabled = busy;
			button.addEventListener("click", () => void action());
			return button;
		}
		function row(tag, residence) {
			const item = document.createElement("li");
			item.className = "row";
			item.appendChild(nameNode(tag));
			if (residence === "local" && isGeneralType(localTypes.get(tag))) {
				const badge = document.createElement("span");
				badge.className = "badge";
				badge.textContent = "promotable";
				item.appendChild(badge);
			}
			if (residence) {
				const destination = residence === "site" ? "local" : "site";
				item.appendChild(actionButton(`Move ${destination}`, `Move ${tag} to ${destination}`, async () => {
					await runAction(async () => {
						return await moveTag(tag, destination) ? "" : `Could not move "${tag}" to ${destination}.`;
					});
				}));
			}
			item.appendChild(actionButton("✕", `Remove ${tag}`, async () => {
				await runAction(async () => {
					const removed = await removeTagFromBlacklist(tag);
					if (removed) reapplySeatedFilters();
					return removed ? "" : `Could not remove "${tag}".`;
				});
			}));
			return item;
		}
		function section(title, tags, residence) {
			const container = document.createElement("section");
			container.className = "section";
			const heading = document.createElement("h2");
			heading.textContent = title;
			const list = document.createElement("ul");
			list.className = "list";
			if (!tags.length) {
				const empty = document.createElement("li");
				empty.className = "empty";
				empty.textContent = "No tags in this list.";
				list.appendChild(empty);
			} else for (const tag of tags) list.appendChild(row(tag, residence));
			container.append(heading, list);
			return container;
		}
		function render() {
			listsEl.textContent = "";
			if (!lists) {
				listsEl.appendChild(section("Blacklist unavailable", []));
				setBusy(busy);
				return;
			}
			if (advancedEl.checked) listsEl.append(section(`Site (${storedBlacklistLength(lists.site)}/${SITE_BLACKLIST_BUDGET} chars)`, lists.site, "site"), section("Local", lists.local, "local"));
			else listsEl.appendChild(section("All tags", [...new Set(lists.site.concat(lists.local))]));
			setBusy(busy);
		}
		async function refresh() {
			lists = await getBlacklistLists();
			localTypes = lists && advancedEl.checked ? await lookupTagTypes(lists.local) : new Map();
			render();
		}
		async function runAction(action) {
			if (busy) return;
			setBusy(true);
			statusEl.textContent = "";
			try {
				statusEl.textContent = await action();
				await refresh();
			} catch (e) {
				statusEl.textContent = e instanceof Error ? e.message : String(e);
			} finally {
				setBusy(false);
			}
		}
		async function add() {
			const tag = entryEl.value.trim().toLowerCase();
			if (!tag || /[\s,]/.test(tag)) {
				statusEl.textContent = "Enter one tag without spaces or commas.";
				return;
			}
			entryEl.value = "";
			await runAction(async () => {
				const outcome = await addTagToSiteBlacklist(tag);
				if (outcome === "failed") return `Could not blacklist "${tag}".`;
				reapplySeatedFilters();
				return outcome === "overflow" ? `Stored "${tag}" locally; the site list is full.` : "";
			});
		}
		addEl.addEventListener("click", () => void add());
		entryEl.addEventListener("keydown", (event) => {
			if (event.key !== "Enter") return;
			event.preventDefault();
			add();
		});
		advancedEl.addEventListener("change", () => {
			runAction(async () => "");
		});
		$("#b-close").addEventListener("click", () => dialog.close());
		$("#b-done").addEventListener("click", () => dialog.close());
		_ui = { open() {
			statusEl.textContent = "";
			entryEl.value = "";
			dialog.showModal();
			runAction(async () => "");
		} };
		return _ui;
	}
	function openBlacklistDialog() {
		buildBlacklistDialog().open();
	}
	var _tagsUI = null;
	function openHighlightedTags() {
		_tagsUI ??= buildListDialog({
			hostId: "gcr-tag-hl-host",
			title: "Highlighted tags",
			placeholder: "tag",
			emptyText: "No tags highlighted.",
			note: `Tags on this list are painted bold and glowing in every post's
    tag list — general tags in gold, other categories in an accentuated shade of
    their usual colour — so the material you follow stands out as the feed
    scrolls past. Purely cosmetic: highlighting neither admits nor removes
    anything.`,
			removeVerb: "Remove",
			load: loadHighlightedTags,
			save: setHighlightedTags,
			normalize: (raw) => raw.trim().toLowerCase() || null,
			href: tagSearchHref
		});
		_tagsUI.open();
	}
	var _usersUI = null;
	function openFilteredUsers() {
		_usersUI ??= buildListDialog({
			hostId: "gcr-users-host",
			title: "Filtered users",
			placeholder: "username",
			emptyText: "No users filtered.",
			note: `Comments by these users are deleted on sight. Removing a user here
    lets their future comments through; comments already deleted return only on reload.`,
			removeVerb: "Remove",
			load: loadFilteredUsers,
			save: setFilteredUsers,
			normalize: (raw) => raw.trim().toLowerCase() || null
		});
		_usersUI.open();
	}
	var ALL_GM_KEYS = [
		POLL_INTERVAL_KEY,
		FEED_CACHE_KEY,
		LOG_PREFS_KEY,
		GAZE_PREFS_KEY,
		HL_KEY,
		USERS_KEY,
		NOTES_KEY,
		"api_auth",
		AUTOSCROLL_SPEED_KEY,
		POSTS_KEY,
		UPLOADERS_KEY,
		TAG_CLAMP_KEY,
		TAG_HL_KEY,
		PAUSE_AT_TOP_KEY,
		PAUSE_UNFOCUSED_KEY,
		TOASTS_KEY,
		OVERFLOW_BLACKLIST_KEY,
		TAG_TYPES_KEY
	];
	function collectAllSettings() {
		const values = {};
		for (const key of ALL_GM_KEYS) {
			if (key === "feed_cache") continue;
			const raw = GM_getValue(key, null);
			if (raw !== null && raw !== void 0) values[key] = raw;
		}
		return values;
	}
	function downloadJson(filename, json) {
		const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
		const a = document.createElement("a");
		a.href = url;
		a.download = filename;
		document.body.appendChild(a);
		a.click();
		a.remove();
		setTimeout(() => URL.revokeObjectURL(url), 1e4);
	}
	var gazeOverlay = false;
	function applyPollInterval(ms) {
		const prev = CONFIG.pollIntervalMs;
		setPollIntervalMs(ms);
		GM_setValue(POLL_INTERVAL_KEY, CONFIG.pollIntervalMs);
		announce(`poll interval set to ${CONFIG.pollIntervalMs}ms`, {
			label: "undo",
			run: () => applyPollInterval(prev)
		});
		refreshMenuCommands();
	}
	function toggleGazeOverlay(tracker) {
		gazeOverlay = tracker.toggleDebug();
		announce(`gaze overlay ${gazeOverlay ? "on" : "off"}`, {
			label: "undo",
			run: () => toggleGazeOverlay(tracker)
		});
		refreshMenuCommands();
	}
	function registerMenuCommands() {
		if (typeof GM_registerMenuCommand === "function") {
			if (!IS_PROFILE) {
				addMenuCommand("filter", () => "⚙️ Manage comment filter…", openSettings);
				addMenuCommand("users", () => "👤 Manage filtered users…", openFilteredUsers);
				addMenuCommand("posts", () => "🚫 Manage blocked posts…", openBlockedPosts);
				addMenuCommand("uploaders", () => "📦 Manage blocked uploaders…", openBlockedUploaders);
				addMenuCommand("blacklist", () => "🔖 Manage blacklisted tags", openBlacklistDialog);
				addMenuCommand("tag-highlights", () => "🌟 Manage highlighted tags…", openHighlightedTags);
				addMenuCommand("poll", () => `⏱️ Polling interval: ${CONFIG.pollIntervalMs} ms…`, () => {
					const input = prompt(`Poll interval in milliseconds (minimum ${MIN_POLL_INTERVAL_MS}):`, String(CONFIG.pollIntervalMs));
					if (input === null) return;
					const n = Number(input.trim());
					if (!Number.isFinite(n) || n < 15e3) {
						alert(`Invalid interval; enter a number of at least ${MIN_POLL_INTERVAL_MS} ms.`);
						return;
					}
					applyPollInterval(Math.round(n));
				});
				addMenuCommand("pause-top", () => `${flag(isPauseAtTopEnabled())} Pause feed at top`, togglePauseAtTop, { keepOpen: true });
				addMenuCommand("pause-unfocused", () => `${flag(isPauseUnfocusedEnabled())} Pause feed when unfocused`, togglePauseUnfocused, { keepOpen: true });
				addMenuCommand("tag-clamp", () => `${flag(isTagListClamped())} Tag list height restriction`, toggleTagListClamp, { keepOpen: true });
				addMenuCommand("toasts", () => `${flag(isToastsEnabled())} Toasts`, toggleToasts, { keepOpen: true });
				addMenuCommand("autoscroll", () => `${flag(isAutoScrollEnabled())} Auto-scroll`, toggleAutoScroll, { keepOpen: true });
				addMenuCommand("speed", () => `📖 Reading speed: ${getSpeed()}…`, () => {
					const list = SPEED_PRESETS.map((preset, i) => `${i + 1}. ${preset.key} (${preset.wpm} wpm)`).join("\n");
					const input = prompt(`Auto-scroll reading speed (currently ${getSpeed()}):\n${list}\n\nEnter 1-${SPEED_PRESETS.length}:`);
					if (input === null) return;
					const chosen = SPEED_PRESETS[Number(input.trim()) - 1];
					if (!chosen) {
						alert(`Invalid choice; enter a number from 1 to ${SPEED_PRESETS.length}.`);
						return;
					}
					setSpeed(chosen.key);
				});
				const tracker = getGaze();
				if (tracker) {
					addMenuCommand("gaze-calibrate", () => "🎯 Calibrate eye tracker", () => {
						calibrateGaze();
					});
					addMenuCommand("gaze", () => `${flag(tracker.isRunning())} Gaze scrolling`, toggleGaze, { keepOpen: true });
					addMenuCommand("gaze-overlay", () => `${flag(gazeOverlay)} Eye tracking overlay`, () => toggleGazeOverlay(tracker), { keepOpen: true });
				}
			}
			addMenuCommand("export", () => "💾 Export settings (JSON)", () => {
				const json = JSON.stringify({
					format: "r34-comments-feed/settings",
					formatVersion: 1,
					exportedAt: new Date().toISOString(),
					values: collectAllSettings(),
					notes: loadNotesDb(),
					filters: loadConfig(),
					filteredUsers: loadFilteredUsers(),
					blockedPosts: loadBlockedPosts(),
					blockedUploaders: loadBlockedUploaders(),
					highlightedTags: loadHighlightedTags()
				}, null, 2);
				downloadJson(`r34-comments-feed-settings-${new Date().toISOString().replace(/[:.]/g, "-")}.json`, json);
				announce("settings exported");
			});
			addMenuCommand("import", () => "📥 Import settings (JSON)…", () => {
				const json = prompt("Paste settings JSON (notes merge into existing; filters, filtered users and blocked posts are replaced):");
				if (!json) return;
				try {
					const incoming = JSON.parse(json);
					if (!incoming || typeof incoming !== "object" || Array.isArray(incoming)) throw new Error("not an object");
					const payload = incoming;
					const values = payload.values;
					if (values && typeof values === "object" && !Array.isArray(values)) {
						let restored = 0;
						for (const key of ALL_GM_KEYS) {
							const raw = values[key];
							if (raw === void 0) continue;
							GM_setValue(key, raw);
							restored++;
						}
						alert(`Imported: ${restored} store(s) restored. Reload the page to apply.`);
						return;
					}
					const envelope = "notes" in payload || "filters" in payload || "filteredUsers" in payload || "blockedPosts" in payload || "blockedUploaders" in payload || "highlightedTags" in payload;
					const notes = envelope ? payload.notes : payload;
					const report = [];
					if (notes && typeof notes === "object" && !Array.isArray(notes)) {
						const db = loadNotesDb();
						let count = 0;
						for (const [key, value] of Object.entries(notes)) {
							const entry = value;
							if (!entry || typeof entry.note !== "string") continue;
							db[noteKeyFor(key)] = {
								note: entry.note,
								color: typeof entry.color === "string" ? entry.color : null,
								updated: entry.updated || new Date().toISOString()
							};
							count++;
						}
						saveNotesDb(db);
						refreshNoteDecorations();
						report.push(`${count} note(s) merged`);
					}
					if (envelope && payload.filters && typeof payload.filters === "object") {
						const cfg = sanitizeConfig(payload.filters);
						const { compiled, errors } = compileFilters(cfg);
						saveConfig(cfg);
						setLiveFilters(cfg, compiled);
						report.push("regex filters replaced" + (errors.length ? ` (${errors.length} invalid pattern(s) kept but inert)` : ""));
					}
					if (envelope && Array.isArray(payload.filteredUsers)) {
						setFilteredUsers(payload.filteredUsers.filter((s) => typeof s === "string"));
						report.push(`${getFilteredUsers().size} filtered user(s) replaced`);
					}
					if (envelope && Array.isArray(payload.blockedPosts)) {
						setBlockedPosts(payload.blockedPosts);
						report.push(`${getBlockedPosts().size} blocked post(s) replaced`);
					}
					if (envelope && Array.isArray(payload.blockedUploaders)) {
						setBlockedUploaders(payload.blockedUploaders);
						report.push(`${getBlockedUploaders().size} blocked uploader(s) replaced`);
					}
					if (envelope && Array.isArray(payload.highlightedTags)) {
						setHighlightedTags(payload.highlightedTags);
						report.push(`${getHighlightedTags().size} highlighted tag(s) replaced`);
					}
					if (!report.length) throw new Error("no recognised settings found");
					reapplySeatedFilters();
					alert(`Imported: ${report.join("; ")}.`);
				} catch (e) {
					alert("Import failed: " + (e instanceof Error ? e.message : String(e)));
				}
			});
			paintMenuCommands();
		}
	}
	var SHORTCUTS = {
		B() {
			const block = blockTargetUnderPointer();
			if (block) {
				hidePostControl();
				blockPostFromBlock(block);
				return true;
			}
			const node = hoveredCommentOrSole();
			if (!node) return false;
			filterAuthorFromComment(node);
			return true;
		},
		N() {
			if (isNoteEditorOpen()) return false;
			let username = null;
			if (IS_PROFILE) username = profileUsername();
			else {
				const node = hoveredComment();
				const rec = node && commentRecord(node);
				username = rec ? (rec.author.textContent || "").trim() : null;
			}
			if (!username) return false;
			openNoteEditor(username);
			return true;
		},
		"~"() {
			openSettings();
			return true;
		}
	};
	function installShortcuts() {
		document.addEventListener("keydown", (e) => {
			if (isTypingTarget()) return;
			if (e.key === "PageDown" || !IS_PROFILE && (e.key === "Home" || e.key === "End") && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) e.preventDefault();
		}, true);
		document.addEventListener("keydown", (e) => {
			if (!e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
			const handler = SHORTCUTS[e.key];
			if (!handler || isTypingTarget()) return;
			if (handler() !== false) e.preventDefault();
		});
		document.addEventListener("keydown", (e) => {
			if (e.key !== "Insert" || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
			if (!state.listEl || isTypingTarget()) return;
			e.preventDefault();
			state.warmupTicks = 0;
			state.readerScrolled = true;
			log("Insert pressed; firing an immediate poll");
			poll(true);
		});
	}
	var FEED_CSS = `
#comment-list .gcr-today .author {
  background: rgba(240, 128, 0, 0.10);
  border-radius: 4px;
  padding: 2px 6px;
}
#comment-list .author h6 a { cursor: pointer; }

/* User notes: authors holding a note carry a dotted underline, and hovering
   the username shows the note in a tooltip. 'important' so the underline wins
   over the site's / dark reskin's anchor text-decoration rules. */
a.gcr-noted {
  text-decoration: underline dotted !important;
  text-underline-offset: 3px;
}
.gcr-note-icon {
  cursor: pointer;
  font-size: 12px;
  margin-left: 6px;
  text-decoration: none;
  user-select: none;
}
.gcr-note-icon.gcr-note-ghost {
  display: none;
  opacity: 0.45;
}
div.author:hover .gcr-note-ghost {
  display: inline;
}
/* Block-this-post overlay: one element moved onto the hovered thumbnail, so
   it is positioned by script and only painted here. 'important' throughout
   because it sits over the thumbnail column, which the enhanced dark gallery
   reskin restyles wholesale — including rules broad enough to catch a bare
   span. The z-index clears that reskin's own hover chrome. */
.gcr-block-btn {
  position: absolute !important;
  z-index: 40 !important;
  width: 20px !important;
  height: 20px !important;
  box-sizing: border-box !important;
  padding: 0 !important;
  margin: 0 !important;
  border: 1px solid rgba(255, 255, 255, 0.35) !important;
  border-radius: 4px !important;
  background: rgba(20, 21, 23, 0.82) !important;
  color: #e6e6e6 !important;
  font: 12px/18px system-ui, sans-serif !important;
  text-align: center !important;
  text-decoration: none !important;
  cursor: pointer !important;
  user-select: none !important;
  opacity: 0.75 !important;
}
.gcr-block-btn:hover {
  background: #a02525 !important;
  border-color: #e88 !important;
  opacity: 1 !important;
}
/* Tag-list height restriction: the menu toggle sets the body class, the
   clamp itself lives here so every block — restored, merged or freshly
   inserted — picks it up with no per-block work. The header target and the
   150px ceiling match the EDG reskin's former clampCommentTags rule, which
   moved here. */
body.gcr-tags-clamped #comment-list > div.post > div.col2 > div.header {
  max-height: ${CONFIG.tagListMaxHeightPx}px;
  overflow-y: auto;
}
.gcr-note-tooltip {
  position: absolute;
  z-index: 2147483646;
  max-width: 340px;
  padding: 6px 9px;
  border: 1px solid #3a3c40;
  border-radius: 6px;
  background: #1f2023;
  color: #e6e6e6;
  font: 12px/1.4 system-ui, sans-serif;
  white-space: pre-wrap;
  overflow-wrap: break-word;
  pointer-events: none;
}
`;
	function injectFeedCss() {
		const styleElement = document.createElement("style");
		styleElement.textContent = FEED_CSS;
		document.head.appendChild(styleElement);
	}
	injectFeedCss();
	applyTagListClamp();
	applyTagHighlightCss();
	if (!IS_PROFILE) history.scrollRestoration = "manual";
	installVisibilitySuspend();
	if (IS_PROFILE) decorateProfile();
	else init();
	installShortcuts();
	installGazeKeybind();
	installAutoScroll();
	registerMenuCommands();
})();